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

4. Convert Actions to Functions

In this step we're going to repeat a similar process as when we converted the form fields to functions, but this time we're going to do it for the actions.

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

Add Actions to Dialogs Config

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

// config/dialogs.js
...
  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>
      );
    }
  }
...

Refactor Actions to Functions

With our actionMap created, let's use it in our form to replace our current actions. 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, actionMap } = 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>
              <div className="modal-footer">
                <div className="row">
                  <div className="col-md-12">
                    {actionMap['default'](form, {
                      label: 'Cancel',
                      onClick: form.callbacks.dismiss
                    })}
                    {actionMap['primary'](form, {
                      label: 'Create',
                      disabled: form.hasError,
                      onClick: form.callbacks.onSubmit
                    })}
                  </div>
                </div>
              </div>
            </PropBarrier>
          )}
        </Form>
      </div>
    </div>
  );
}

Review

With these changes in place, we haven't really removed an lines of code, but we do now have a consistent interface for creation fields and actions.

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 {

  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 { 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>
                <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">
                      {actionMap['default'](form, {
                        label: 'Cancel',
                        onClick: form.callbacks.dismiss
                      })}
                      {actionMap['primary'](form, {
                        label: 'Create',
                        disabled: form.hasError,
                        onClick: form.callbacks.onSubmit
                      })}
                    </div>
                  </div>
                </div>
              </PropBarrier>
            )}
          </Form>
        </div>
      </div>
    );
  }

});

Next Steps

Next we're going to create a schema for defining the sections of our form.