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

7. Create Blueprint

At this point we've converted our form into a format that allows us to describe what we want instead of explicitly constructing it.

In this next step we'll take the final step in our pattern construction journey, and convert our entire dialog into a blueprint that we can re-use.

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

What's the problem?

To illustrate the issue, take a look at the code surrounding our form, show below:

import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import _ from 'lodash';
import SchemaForm from '../_forms/SchemaForm';

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>
          <SchemaForm ... />
        </div>
      </div>
    );
  }

});

This is the code that represents the dialog structure and behavior, the thing the form we just refactored is nested inside of.

And similar to our form, most of this behavior is not unique. In fact, it's quite common for dialogs in an application to have a similar structure, appearance, and behavior.

How do we solve this?

To address this issue, we're going to create a blueprint for this type of dialog, specifically an optimistic create dialog. After we do this, we'll able to use this code to create any kind of resource in our application.

The type of dialog we're using in this example is fairly simply, so the benefits of creating a blueprint for a dialog are not well represented, primarily because this dialog is very simple and gets dismissed as soon as the user submits it.

But if you imagine that instead of closing the dialog immediately, we decide we want to display a loading experience, and only close dialog after the request succeeds (or display an error to the user if it fails) then the dialog logic gets much more complex, and contains a whole lot more boilerplate.

This approach of creating blueprints for dialogs provides more benefit as the dialog behavior becomes more complex (e.g. wizards, error handling, loading experiences, etc).

Create Blueprint

Create a new file called Optimistic.js located at src/dialogs/_blueprints/create/Optimistic.js, and paste in this code:

// src/dialogs/_blueprints/Optimistic.js
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import _ from 'lodash';
import SchemaForm from '../../_forms/SchemaForm';

export default createReactClass({
  displayName: 'Optimistic',

  propTypes: {
    onCancel: PropTypes.func
  },

  getInitialState: function() {
    const { data } = this.props;

    return {
      data: data
    };
  },

  request(data) {
    const { request } = this.props;
    return request(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) {
    const { validators } = this.props;
    return validators(data);
  },

  render() {
    const {
      title,
      schema,
      fieldMap,
      actionMap,
      fields,
      actions
    } = this.props;
    const { data } = this.state;
    const validators = this.getValidators(data);

    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">
              {title}
            </h4>
          </div>
          <SchemaForm
            schema={schema}
            fieldMap={fieldMap}
            actionMap={actionMap}
            data={data}
            validators={validators}
            onChange={this.onChange}
            callbacks={{
              onSubmit: this.onSubmit,
              dismiss: this.dismiss
            }}
            fields={fields}
            actions={actions}
          />
        </div>
      </div>
    );
  }

});

Refactor Dialog to use Blueprint

Next, update your dialog to use this blueprint. Replace the contents of the file with the code below:

// 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 OptimisticBlueprint from '../../_blueprints/Optimistic';

export default createReactClass({
  displayName: 'CreateTweetDialog',

  propTypes: {
    onCancel: PropTypes.func
  },

  request(data) {
    lore.actions.tweet.create(_.defaults({
      createdAt: new Date().toISOString()
    }, data));
  },

  render() {
    const { schema, fieldMap, actionMap } = lore.config.dialogs;

    return (
      <OptimisticBlueprint
        title="Create Tweet"
        onCancel={this.props.onCancel}
        schema={schema}
        fieldMap={fieldMap}
        actionMap={actionMap}
        request={this.request}
        data={{
          text: '',
          userId: undefined
        }}
        validators={function(data) {
          return {
            text: [function(value) {
              if (!value) {
                return 'This field is required';
              }
            }],
            userId: [function(value) {
              if (value === undefined) {
                return 'This field is required'
              }
            }]
          };
        }}
        fields={[
          {
            key: 'text',
            type: 'text',
            props: {
              label: 'Message',
              placeholder: "What's happening?"
            }
          },
          {
            key: 'userId',
            type: 'select',
            props: {
              label: 'User',
              options: function(getState) {
                return getState('user.find');
              },
              optionLabel: 'nickname'
            }
          }
        ]}
        actions={[
          {
            key: 'cancel',
            type: 'default',
            props: (form) => {
              return {
                label: 'Cancel',
                onClick: form.callbacks.dismiss
              };
            }
          },
          {
            key: 'submit',
            type: 'primary',
            props: (form) => {
              return {
                label: 'Create',
                disabled: form.hasError,
                onClick: form.callbacks.onSubmit
              };
            }
          }
        ]}
      />
    );
  }

});

Review

At this point, we have reduced our dialog from ~210 lines of code to ~100, and created a way of describing out dialog that contains practically zero boilerplate.

The code we have now only describes the fields and actions that exist, the initial data, how the fields should be validated, and what should happen once the form is submitted.

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.

src/dialogs/_blueprints/Optimistic.js

import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import _ from 'lodash';
import SchemaForm from '../_forms/SchemaForm';

export default createReactClass({
  displayName: 'Optimistic',

  propTypes: {
    onCancel: PropTypes.func
  },

  getInitialState: function() {
    const { data } = this.props;

    return {
      data: data
    };
  },

  request(data) {
    const { request } = this.props;
    return request(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) {
    const { validators } = this.props;
    return validators(data);
  },

  render() {
    const {
      title,
      schema,
      fieldMap,
      actionMap,
      fields,
      actions
    } = this.props;
    const { data } = this.state;
    const validators = this.getValidators(data);

    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">
              {title}
            </h4>
          </div>
          <SchemaForm
            schema={schema}
            fieldMap={fieldMap}
            actionMap={actionMap}
            data={data}
            validators={validators}
            onChange={this.onChange}
            callbacks={{
              onSubmit: this.onSubmit,
              dismiss: this.dismiss
            }}
            fields={fields}
            actions={actions}
          />
        </div>
      </div>
    );
  }

});

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 OptimisticBlueprint from '../../_blueprints/Optimistic';

export default createReactClass({
  displayName: 'CreateTweetDialog',

  propTypes: {
    onCancel: PropTypes.func
  },

  request(data) {
    lore.actions.tweet.create(_.defaults({
      createdAt: new Date().toISOString()
    }, data));
  },

  render() {
    const { schema, fieldMap, actionMap } = lore.config.dialogs;

    return (
      <OptimisticBlueprint
        title="Create Tweet"
        onCancel={this.props.onCancel}
        schema={schema}
        fieldMap={fieldMap}
        actionMap={actionMap}
        request={this.request}
        data={{
          text: '',
          userId: undefined
        }}
        validators={function(data) {
          return {
            text: [function(value) {
              if (!value) {
                return 'This field is required';
              }
            }],
            userId: [function(value) {
              if (value === undefined) {
                return 'This field is required'
              }
            }]
          };
        }}
        fields={[
          {
            key: 'text',
            type: 'text',
            props: {
              label: 'Message',
              placeholder: "What's happening?"
            }
          },
          {
            key: 'userId',
            type: 'select',
            props: {
              label: 'User',
              options: function(getState) {
                return getState('user.find');
              },
              optionLabel: 'nickname'
            }
          }
        ]}
        actions={[
          {
            key: 'cancel',
            type: 'default',
            props: (form) => {
              return {
                label: 'Cancel',
                onClick: form.callbacks.dismiss
              };
            }
          },
          {
            key: 'submit',
            type: 'primary',
            props: (form) => {
              return {
                label: 'Create',
                disabled: form.hasError,
                onClick: form.callbacks.onSubmit
              };
            }
          }
        ]}
      />
    );
  }

});

Next Steps

Next we're going to refactor the update dialog.