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

1. Add Create Dialog

In this step we'll be added a dialog that we can use to create new tweets. This dialog will allow you to enter the text for a tweet, as well as specify the user who said it.

Add Dialog

To start, create a new file called index.js located at src/dialogs/tweet/create/index.js. Paste the code below into this file.

import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import _ from 'lodash';
import { Connect } from 'lore-hook-connect';

export default createReactClass({
  displayName: 'CreateTweetDialog',

  propTypes: {
    onCancel: PropTypes.func
  },

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

  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'
        }
      }]
    };
  },

  getErrors: function(validatorDictionary, data) {
    return _.mapValues(data, function(value, key) {
      const validators = validatorDictionary[key];
      let error = null;
      if (validators) {
        validators.forEach(function(validator) {
          error = error || validator(value);
        });
      }
      return error;
    });
  },

  hasError: function(errors) {
    const errorCount = _.reduce(errors, function(result, value, key) {
      if (value) {
        return result + 1;
      }

      return result;
    }, 0);

    return errorCount > 0;
  },

  onBlur: function(field) {
    const touched = this.state.touched;
    touched[field] = true;
    this.setState({
      touched: touched
    });
  },

  render() {
    const { data, touched } = this.state;
    const validators = this.getValidators(data);
    const errors = this.getErrors(validators, data);
    const hasError = this.hasError(errors);

    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>
          <div className="modal-body">
            <div className="row">
              <div className="col-md-12">
                <div className={`form-group ${touched['text'] && errors['text'] ? 'has-error' : ''}`}>
                  <label>
                    Message
                  </label>
                  <textarea
                    className="form-control"
                    rows="3"
                    value={data.text}
                    placeholder="What's happening?"
                    onChange={(event) => {
                      this.onChange('text', event.target.value)
                    }}
                    onBlur={() => {
                      this.onBlur('text');
                    }}
                  />
                  {touched['text'] && errors['text'] ? (
                    <span className="help-block">
                      {errors['text']}
                    </span>
                  ) : null}
                </div>
              </div>
            </div>
            <div className="row">
              <div className="col-md-12">
                <div className={`form-group ${touched['userId'] && errors['userId'] ? 'has-error' : ''}`}>
                  <label>
                    User
                  </label>
                  <Connect callback={(getState, props) => {
                    return {
                      options: getState('user.find')
                    };
                  }}>
                    {(connect) => {
                      return (
                        <select
                          className="form-control"
                          value={data.userId}
                          onChange={(event) => {
                            const value = event.target.value;
                            this.onBlur('userId');
                            this.onChange('userId', value ? Number(value) : undefined)
                          }}
                        >
                          {[<option key="" value=""/>].concat(connect.options.data.map((datum) => {
                            return (
                              <option key={datum.id} value={datum.id}>
                                {datum.data.nickname}
                              </option>
                            );
                          }))}
                        </select>
                      )
                    }}
                  </Connect>
                  {touched['userId'] && errors['userId'] ? (
                    <span className="help-block">
                      {errors['userId']}
                    </span>
                  ) : null}
                </div>
              </div>
            </div>
          </div>
          <div className="modal-footer">
            <div className="row">
              <div className="col-md-12">
                <button
                  type="button"
                  className="btn btn-default"
                  onClick={this.dismiss}
                >
                  Cancel
                </button>
                <button
                  type="button"
                  className="btn btn-primary"
                  disabled={hasError}
                  onClick={this.onSubmit}
                >
                  Create
                </button>
              </div>
            </div>
          </div>
        </div>
      </div>
    );
  }

});

Review Dialog

Let's take a minute to review the form we just added, since we'll be making a series of modifications to simplify it over the next several steps.

Overall, the form is pretty typical. It has two fields; a text field for capturing the message, and a dropdown field for specifying which user said it. The form also has two actions; a cancel button which will dismiss the dialog, and a submit button which will trigger an API call to the create the tweet. Additionally, there is also some basic validation that marks both fields as required, and the submit button will only be enabled once the form is valid.

To provide the functionality just described, the form also defines several internal functions, described below:

  • getInitialState() which stores the initial values for the form
  • request() which triggers the API call to create the tweet
  • onSubmit() which invokes the API request and dismisses the dialog
  • dismiss() which dismisses the dialog
  • onChange() which updates the state of the form as the user makes changes
  • getValidators() which stores the validation logic for the form
  • getErrors() which calculates errors for each fields based on what the user has entered
  • hasError() which is a convenience function to generate a simple boolean as to whether the form has any errors
  • onBlur() which is a callback used to keep track of which fields the user has interacted with

The Goal

All-in-all, this form is pretty basic, and comes in at 210 lines long. The length isn't really a problem, but it's a little concerning how much of the code doesn't provide unique value. There's a lot of boilerplate that would need to exist in every form, and that can lead to unintentional bugs developed by creating new forms by copy/pasting older forms, and also creating a situation where changes to one form (such as the design or behavior) need to then be made to all forms.

Over the next several steps, we're going to be aggressively "trimming the boilerplate", and attempting to reduce the form to only the code that is essential and unique to that specific form.

Launch Create Dialog

Next, open the CreateButton component located at src/components/CreateButton.js and modify the onClick() callback to look like this:

...
import CreateTweetDialog from '../dialogs/tweet/create';
...
  onClick() {
    lore.dialog.show(function() {
      return (
        <CreateTweetDialog />
      );
    })
  },
...

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.

src/components/CreateButton.js

import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import CreateTweetDialog from '../dialogs/tweet/create/index.8';

export default createReactClass({
  displayName: 'CreateButton',

  onClick() {
    lore.dialog.show(function() {
      return (
        <CreateTweetDialog />
      );
    })
  },

  render() {
    return (
      <button
        type="button"
        className="btn btn-primary btn-lg create-button"
        onClick={this.onClick}>
        +
      </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 { Connect } from 'lore-hook-connect';

export default createReactClass({
  displayName: 'CreateTweetDialog',

  propTypes: {
    onCancel: PropTypes.func
  },

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

  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'
        }
      }]
    };
  },

  getErrors: function(validatorDictionary, data) {
    return _.mapValues(data, function(value, key) {
      const validators = validatorDictionary[key];
      let error = null;
      if (validators) {
        validators.forEach(function(validator) {
          error = error || validator(value);
        });
      }
      return error;
    });
  },

  hasError: function(errors) {
    const errorCount = _.reduce(errors, function(result, value, key) {
      if (value) {
        return result + 1;
      }

      return result;
    }, 0);

    return errorCount > 0;
  },

  onBlur: function(field) {
    const touched = this.state.touched;
    touched[field] = true;
    this.setState({
      touched: touched
    });
  },

  render() {
    const { data, touched } = this.state;
    const validators = this.getValidators(data);
    const errors = this.getErrors(validators, data);
    const hasError = this.hasError(errors);

    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>
          <div className="modal-body">
            <div className="row">
              <div className="col-md-12">
                <div className={`form-group ${touched['text'] && errors['text'] ? 'has-error' : ''}`}>
                  <label>
                    Message
                  </label>
                  <textarea
                    className="form-control"
                    rows="3"
                    value={data.text}
                    placeholder="What's happening?"
                    onChange={(event) => {
                      this.onChange('text', event.target.value)
                    }}
                    onBlur={() => {
                      this.onBlur('text');
                    }}
                  />
                  {touched['text'] && errors['text'] ? (
                    <span className="help-block">
                      {errors['text']}
                    </span>
                  ) : null}
                </div>
              </div>
            </div>
            <div className="row">
              <div className="col-md-12">
                <div className={`form-group ${touched['userId'] && errors['userId'] ? 'has-error' : ''}`}>
                  <label>
                    User
                  </label>
                  <Connect callback={(getState, props) => {
                    return {
                      options: getState('user.find')
                    };
                  }}>
                    {(connect) => {
                      return (
                        <select
                          className="form-control"
                          value={data.userId}
                          onChange={(event) => {
                            const value = event.target.value;
                            this.onBlur('userId');
                            this.onChange('userId', value ? Number(value) : undefined)
                          }}
                        >
                          {[<option key="" value=""/>].concat(connect.options.data.map((datum) => {
                            return (
                              <option key={datum.id} value={datum.id}>
                                {datum.data.nickname}
                              </option>
                            );
                          }))}
                        </select>
                      )
                    }}
                  </Connect>
                  {touched['userId'] && errors['userId'] ? (
                    <span className="help-block">
                      {errors['userId']}
                    </span>
                  ) : null}
                </div>
              </div>
            </div>
          </div>
          <div className="modal-footer">
            <div className="row">
              <div className="col-md-12">
                <button
                  type="button"
                  className="btn btn-default"
                  onClick={this.dismiss}
                >
                  Cancel
                </button>
                <button
                  type="button"
                  className="btn btn-primary"
                  disabled={hasError}
                  onClick={this.onSubmit}
                >
                  Create
                </button>
              </div>
            </div>
          </div>
        </div>
      </div>
    );
  }

});

Next Steps

Next we're going to simplify the fields.