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

5. Create Schema

In this step we're going to repeat the process of converting code into functions, but this time we're going to create functions for the primary parts of our form, the things that are likely to stay the same across the majority of forms in our application.

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

What's the problem?

To illustrate the issue, take a look at the code below, which reflects the core of current form:

<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">
            {textField(form, {
              label: 'Message',
              placeholder: "What's happening?"
            }, 'text')}
          </div>
        </div>
        <div className="row">
          <div className="col-md-12">
            {selectField(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">
            {defaultAction(form, {
              label: 'Cancel',
              onClick: form.callbacks.dismiss
            })}
            {primaryAction(form, {
              label: 'Create',
              disabled: form.hasError,
              onClick: form.callbacks.onSubmit
            })}
          </div>
        </div>
      </div>
    </PropBarrier>
  )}
</Form>

While our form is pretty compact at this point, at only ~50 lines of code, the pattern we're building up is all about aggressive removal of boilerplate, and there's still some boilerplate here we want to strip out.

The code that's left now represents the basic structure of our form, captured below:

  • The modal-body which is where the store the fields
  • The row and col-md-12 wrapper around each field
  • The modal-footer, row, and col-md-12 wrapper around the actions
  • The absence of any wrapper code around each action.

In this step, we want to remove that boilerplate.

How do we solve this?

To solve this, we're going to create a schema that conatins a series of functions able to generate the basic parts of our form; the sections for the fields, actions, and the wrapper code around each individual field and action.

Add Schema to Dialogs Config

To start, open the dialogs.js config and add a new field called schema that looks like this:

// config/dialogs.js
...
  schema: {
    action: function(form) {
      return (action) => {
        return action;
      }
    },
    actions: function(form) {
      return (actions) => {
        return (
          <div className="modal-footer">
            <div className="row">
              <div className="col-md-12">
                {actions.map(function (action, index) {
                  return React.cloneElement(action, { key: index })
                })}
              </div>
            </div>
          </div>
        );
      }
    },
    field: function(form) {
      return (field) => {
        return (
          <div className="row">
            <div className="col-md-12">
              {field}
            </div>
          </div>
        );
      }
    },
    fields: function(form) {
      return (fields) => {
        return (
          <div className="modal-body">
            {fields.map(function(field, index) {
              return React.cloneElement(field, { key: index })
            })}
          </div>
        );
      };
    }
  },
...

Refactor Form to Schema Functions

With our schema created, let's use it in our form to replace the remaining structural boilerplate. 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 { schema, fieldMap, actionMap } = 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>
              {schema.fields(form)(
                [
                  schema.field(form)(
                    fieldMap['text'](form, {
                      label: 'Message',
                      placeholder: "What's happening?"
                    }, 'text')
                  ),
                  schema.field(form)(
                    fieldMap['select'](form, {
                      label: 'User',
                      options: function(getState) {
                        return getState('user.find');
                      },
                      optionLabel: 'nickname'
                    }, 'userId')
                  ),
                ]
              )}
              {schema.actions(form)(
                [
                  schema.action(form)(
                    actionMap['default'](form, {
                      label: 'Cancel',
                      onClick: form.callbacks.dismiss
                    }, 'cancel')
                  ),
                  schema.action(form)(
                    actionMap['primary'](form, {
                      label: 'Create',
                      disabled: form.hasError,
                      onClick: form.callbacks.onSubmit
                    }, 'submit')
                  ),
                ]
              )}
            </PropBarrier>
          )}
        </Form>
      </div>
    </div>
  );
}

Review

With these changes in place, we no longer have any structural boilerplate associated with our form. The downside is that this code is a little harder to read that React components, and doesn't really resemble React code at all anymore. We'll address both of those issues in the next step.

Visual Check-in

If everything went well, clicking the create button will still 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 {

  schema: {
    action: function(form) {
      return (action) => {
        return action;
      }
    },
    actions: function(form) {
      return (actions) => {
        return (
          <div className="modal-footer">
            <div className="row">
              <div className="col-md-12">
                {actions.map(function (action, index) {
                  return React.cloneElement(action, { key: index })
                })}
              </div>
            </div>
          </div>
        );
      }
    },
    field: function(form) {
      return (field) => {
        return (
          <div className="row">
            <div className="col-md-12">
              {field}
            </div>
          </div>
        );
      }
    },
    fields: function(form) {
      return (fields) => {
        return (
          <div className="modal-body">
            {fields.map(function(field, index) {
              return React.cloneElement(field, { key: index })
            })}
          </div>
        );
      };
    }
  },

  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>
      );
    }
  },

  actionMap: {
    default: function(form, props, key) {
      const {
        label,
        ...other
      } = props;

      return (
        <button
          key={key}
          type="button"
          className="btn btn-default"
          {...other}
        >
          {label}
        </button>
      );
    },

    primary: function(form, props, key) {
      const {
        label,
        ...other
      } = props;

      return (
        <button
          key={key}
          type="button"
          className="btn btn-primary"
          {...other}
        >
          {label}
        </button>
      );
    }
  }

}

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 { schema, fieldMap, actionMap } = 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>
                {schema.fields(form)(
                  [
                    schema.field(form)(
                      fieldMap['text'](form, {
                        label: 'Message',
                        placeholder: "What's happening?"
                      }, 'text')
                    ),
                    schema.field(form)(
                      fieldMap['select'](form, {
                        label: 'User',
                        options: function(getState) {
                          return getState('user.find');
                        },
                        optionLabel: 'nickname'
                      }, 'userId')
                    ),
                  ]
                )}
                {schema.actions(form)(
                  [
                    schema.action(form)(
                      actionMap['default'](form, {
                        label: 'Cancel',
                        onClick: form.callbacks.dismiss
                      }, 'cancel')
                    ),
                    schema.action(form)(
                      actionMap['primary'](form, {
                        label: 'Create',
                        disabled: form.hasError,
                        onClick: form.callbacks.onSubmit
                      }, 'submit')
                    ),
                  ]
                )}
              </PropBarrier>
            )}
          </Form>
        </div>
      </div>
    );
  }

});

Next Steps

Next we're going to move our schema code into a SchemaForm component.