Forms: Pattern Construction

WARNING! v0.13 was just released, and the tutorial is currently undergoing final testing. It's recommended that you DO NOT follow along until this message is removed (please check back tomorrow).

Mental model for the forms implementation

3. Convert Fields to Functions

In this step we'll be converting our form fields into functions.

You can view the finished code for this step by checking out the create.3 branch of the completed project.

What's the problem?

To illustrate the issue, take a look at the text field for the form, show below:

<Field name="text" data={form.data} errors={form.errors} onChange={form.onChange}>
  {(field) => {
    return (
      <div className={`form-group ${field.touched && field.error ? 'has-error' : ''}`}>
        <label>
          Message
        </label>
        <textarea
          className="form-control"
          rows="3"
          value={field.value}
          placeholder="What's happening?"
          onChange={(event) => {
            field.onChange(event, event.target.value)
          }}
          onBlur={field.onBlur}
        />
        {field.touched && field.error ? (
          <span className="help-block">
            {field.error}
          </span>
        ) : null}
      </div>
    )
  }}
</Field>

Most of this form field will be identical across all forms in the application, which includes the classes applied, where the error message appears, even the callback functions like onChange and onBlur().

In fact, there is very little that's unique about this specific text field aside from the field name, the label and the placeholder text.

How do we solve this?

To solve this, we're going to convert out form fields into functions, and only provide the information that is truly unique to that field.

We're also going to store these functions in our application configuration, under config/dialogs.js.

Create Config for Dialogs

Start by creating a new config file called dialogs.js located at config/dialogs.js, and paste the code below into that file:

import React from 'react';
import _ from 'lodash';
import { Connect } from 'lore-hook-connect';
import { Field } from 'lore-react-forms';

export default {

  fieldMap: {
    text: function(form, props, name) {
      const {
        label,
        placeholder
      } = props;

      return (
        <Field key={name} name={name} data={form.data} errors={form.errors} onChange={form.onChange}>
          {(field) => {
            return (
              <div key={name} className={`form-group ${field.touched && field.error ? 'has-error' : ''}`}>
                <label>
                  {label}
                </label>
                <textarea
                  className="form-control"
                  rows="3"
                  value={field.value}
                  placeholder={placeholder}
                  onChange={(event) => {
                    field.onChange(event, event.target.value)
                  }}
                  onBlur={field.onBlur}
                />
                {field.touched && field.error ? (
                  <span className="help-block">
                {field.error}
              </span>
                ) : null}
              </div>
            )
          }}
        </Field>
      );
    },

    select: function(form, props, name) {
      const {
        options,
        label,
        optionLabel
      } = props;

      return (
        <Field key={name} name={name} data={form.data} errors={form.errors} onChange={form.onChange}>
          {(field) => {
            return (
              <div className={`form-group ${field.touched && field.error ? 'has-error' : ''}`}>
                <label>
                  {label}
                </label>
                <Connect callback={(getState, props) => {
                  return {
                    options: _.isFunction(options) ? options(getState, props) : options
                  };
                }}>
                  {(connect) => {
                    return (
                      <select
                        className="form-control"
                        value={field.value}
                        onChange={(event) => {
                          const value = event.target.value;
                          field.onBlur();
                          field.onChange(event, value ? Number(value) : undefined)
                        }}
                      >
                        {[<option key="" value=""/>].concat(connect.options.data.map((datum) => {
                          return (
                            <option key={datum.id} value={datum.id}>
                              {_.isFunction(optionLabel) ? optionLabel(datum) : datum.data[optionLabel]}
                            </option>
                          );
                        }))}
                      </select>
                    )
                  }}
                </Connect>
                {field.touched && field.error ? (
                  <span className="help-block">
                {field.error}
              </span>
                ) : null}
              </div>
            );
          }}
        </Field>
      );
    }
  }

}

The code above defines a fieldMap, which is an map that, when given a name like text or select will return a function that can generate that type of field.

Each function has an identical signature. The first argument is the form, the second is the props unique to that field (like label and placeholder text) and the third is the name of the field.

Why functions?

You could argue that instead of creating a function, we could just create a component, and you'd be right. But as you'll see in later steps, the ultimate goal is to actually create an interface that allows us to describe what kind of form we need, instead of constructing it ourselves. And for that, we need to use functions instead of components.

Refactor Fields to Functions

With our fieldMap created, let's use it in our form to replace our current fields. Update the render() function to look like this:

// src/dialogs/tweet/create/index.js
render() {
  const { data } = this.state;
  const validators = this.getValidators(data);
  const { fieldMap } = lore.config.dialogs;

  return (
    <div className="modal-dialog">
      <div className="modal-content">
        <div className="modal-header">
          ...
        </div>
        <Form
          data={data}
          validators={validators}
          onChange={this.onChange}
          callbacks={{
            onSubmit: this.onSubmit,
            dismiss: this.dismiss
          }}
        >
          {(form) => (
            <PropBarrier>
              <div className="modal-body">
                <div className="row">
                  <div className="col-md-12">
                    {fieldMap['text'](form, {
                      label: 'Message',
                      placeholder: "What's happening?"
                    }, 'text')}
                  </div>
                </div>
                <div className="row">
                  <div className="col-md-12">
                    {fieldMap['select'](form, {
                      label: 'User',
                      options: function(getState) {
                        return getState('user.find');
                      },
                      optionLabel: 'nickname'
                    }, 'userId')}
                  </div>
                </div>
              </div>
              <div className="modal-footer">
                ...
              </div>
            </PropBarrier>
          )}
        </Form>
      </div>
    </div>
  );
}

Review

With these changes in place, we've removed ~60 lines of code from our form, and created an interface that makes is much more obvious what is unique about each field.

Visual Check-in

If everything went well, clicking the create button will now launch a dialog that you can use to create a new tweet.

Code Changes

Below is a list of files modified during this step.

config/dialogs.js

import React from 'react';
import _ from 'lodash';
import { Connect } from 'lore-hook-connect';
import { Field } from 'lore-react-forms';

export default {

  fieldMap: {
    text: function(form, props, name) {
      const {
        label,
        placeholder
      } = props;

      return (
        <Field key={name} name={name} data={form.data} errors={form.errors} onChange={form.onChange}>
          {(field) => {
            return (
              <div key={name} className={`form-group ${field.touched && field.error ? 'has-error' : ''}`}>
                <label>
                  {label}
                </label>
                <textarea
                  className="form-control"
                  rows="3"
                  value={field.value}
                  placeholder={placeholder}
                  onChange={(event) => {
                    field.onChange(event, event.target.value)
                  }}
                  onBlur={field.onBlur}
                />
                {field.touched && field.error ? (
                  <span className="help-block">
                {field.error}
              </span>
                ) : null}
              </div>
            )
          }}
        </Field>
      );
    },

    select: function(form, props, name) {
      const {
        options,
        label,
        optionLabel
      } = props;

      return (
        <Field key={name} name={name} data={form.data} errors={form.errors} onChange={form.onChange}>
          {(field) => {
            return (
              <div className={`form-group ${field.touched && field.error ? 'has-error' : ''}`}>
                <label>
                  {label}
                </label>
                <Connect callback={(getState, props) => {
                  return {
                    options: _.isFunction(options) ? options(getState, props) : options
                  };
                }}>
                  {(connect) => {
                    return (
                      <select
                        className="form-control"
                        value={field.value}
                        onChange={(event) => {
                          const value = event.target.value;
                          field.onBlur();
                          field.onChange(event, value ? Number(value) : undefined)
                        }}
                      >
                        {[<option key="" value=""/>].concat(connect.options.data.map((datum) => {
                          return (
                            <option key={datum.id} value={datum.id}>
                              {_.isFunction(optionLabel) ? optionLabel(datum) : datum.data[optionLabel]}
                            </option>
                          );
                        }))}
                      </select>
                    )
                  }}
                </Connect>
                {field.touched && field.error ? (
                  <span className="help-block">
                {field.error}
              </span>
                ) : null}
              </div>
            );
          }}
        </Field>
      );
    }
  }

}

src/dialogs/tweet/create/index.js

import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import _ from 'lodash';
import { Form, PropBarrier } from 'lore-react-forms';

export default createReactClass({
  displayName: 'CreateTweetDialog',

  propTypes: {
    onCancel: PropTypes.func
  },

  getInitialState() {
    return {
      data: {
        text: '',
        userId: undefined
      }
    };
  },

  request(data) {
    lore.actions.tweet.create(data);
  },

  onSubmit() {
    const { data } = this.state;
    this.request(data);
    this.dismiss();
  },

  dismiss() {
    this.props.onCancel();
  },

  onChange(name, value) {
    const nextData = _.merge({}, this.state.data);
    nextData[name] = value;
    this.setState({
      data: nextData
    });
  },

  getValidators: function(data) {
    return {
      text: [function(value) {
        if (!value) {
          return 'This field is required';
        }
      }],
      userId: [function(value) {
        if (value === undefined) {
          return 'This field is required'
        }
      }]
    };
  },

  render() {
    const { data } = this.state;
    const validators = this.getValidators(data);
    const { fieldMap } = lore.config.dialogs;

    return (
      <div className="modal-dialog">
        <div className="modal-content">
          <div className="modal-header">
            <button type="button" className="close" onClick={this.dismiss}>
              <span>&times;</span>
            </button>
            <h4 className="modal-title">
              Create Tweet
            </h4>
          </div>
          <Form
            data={data}
            validators={validators}
            onChange={this.onChange}
            callbacks={{
              onSubmit: this.onSubmit,
              dismiss: this.dismiss
            }}
          >
            {(form) => (
              <PropBarrier>
                <div className="modal-body">
                  <div className="row">
                    <div className="col-md-12">
                      {fieldMap['text'](form, {
                        label: 'Message',
                        placeholder: "What's happening?"
                      }, 'text')}
                    </div>
                  </div>
                  <div className="row">
                    <div className="col-md-12">
                      {fieldMap['select'](form, {
                        label: 'User',
                        options: function(getState) {
                        return getState('user.find');
                      },
                        optionLabel: 'nickname'
                      }, 'userId')}
                    </div>
                  </div>
                </div>
                <div className="modal-footer">
                  <div className="row">
                    <div className="col-md-12">
                      <button
                        type="button"
                        className="btn btn-default"
                        onClick={form.callbacks.dismiss}
                      >
                        Cancel
                      </button>
                      <button
                        type="button"
                        className="btn btn-primary"
                        disabled={form.hasError}
                        onClick={form.callbacks.onSubmit}
                      >
                        Create
                      </button>
                    </div>
                  </div>
                </div>
              </PropBarrier>
            )}
          </Form>
        </div>
      </div>
    );
  }

});

Next Steps

Next we're going to convert the actions to functions.