Skip to content

Instantly share code, notes, and snippets.

@jordangarcia
Created August 7, 2018 03:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jordangarcia/8af9be5eaa12cd46a9233535f16bc2a2 to your computer and use it in GitHub Desktop.
Save jordangarcia/8af9be5eaa12cd46a9233535f16bc2a2 to your computer and use it in GitHub Desktop.

React Form Abstraction

What problems are we trying to solve?

  1. There is not a generic solution for handling dirty state checking and reverts throughout our codebase.

  2. Validation is a mess, either it's duplicated as inline validation in the "Input" component and in the "Form" component. Other places it exist in the parent component and passed down through props to the "Input", this means our inputs require the parent component to know and do a ton of validation making them no longer easily portable.

Requirements for a good React Form Abstraction

  1. Generalizes and abstracts form dirty state, and reverts
  2. Allows to define validation once for an input while keeping form inputs portable and easily composeable.
  3. A standardized interface for validation functions and how errors are presented both inline and at the form level.

Simple Example

import _ from 'lodash';
import React from 'react';
import PropTypes from 'prop-types';

import { Input, Button } from 'optimizely-oui'

import SectionModule from '../../section_module/';


@Form({
  // Populates the form HoC with initial state for the entity being edited
  // accessible via `props.formState.clean` and `props.form.state.editing`
  initialStateGetter: SectionModule.getters.currentlyEditingExperiment,
})
class MyForm extends React.Component {
  constructor(props) {
    super(props);
    // global validation functino

    // form {FormInterface} is passed to wrapped component as prop
    const { form } = this.props;
    form.addValidationFn({
      keypath: 'name',

      getErrors(val) {
        // these should be composable
        let hasError
        if (val.length === 0) {
          return {
            hasError: true,
            details: {
              message: 'Name is required',
            }
          }
        }

        if (val.length > 63) {
          return {
            hasError: true,
            details: {
              message: 'Name must be less than 64 characters',
            }
          }
        }

        return {
          hasError: false,
          details: {},
        }
      },
    })
  }

  handleSave = () => {
    const { form } = this.props;
    // this call will populate errors and pass them through props to the child components
    // Question: should this return something?
    form.validateAll();
    if (!form.isFormValid()) {
      // you can also use this display global errors
      return;
    }
    const data = form.getValue().toJS();
    // do save operation with data
  }

  render() {
    const {
      form,
      formState,
    } = this.props;

    const nameField = form.field('name');

    return (
      <form>
        <p>
          is dirty:
          <strong>
            { formState.isDirty ? 'true': 'false' }
          </strong>
        </p>

        <Input
          value={ nameField.getValue() }
          onChange={ (e) => nameField.setValue(e.target.value) }
          errors={ nameField.getErrors() }
        />

        <div>
          <Button
            isDisabled={ !formState.isDirty }
            onClick={ form.revert }>
            Revert
          </Button>
          <Button
            onClick={ this.handleSave }>
            Save
          </Button>
        </div>
      </form>
    )
  }
}

Architecture

 +------------------------+
 |        Form HoC        |
 +-------+--------+-------+
         |        |
         +        +
   `form` api   `formState`

+--------------------------+
|                          |
|   Wrapped components     |
|   `props.form`           |
|   `props.formState`      |
+------------+-------------+
             |
             |
             |
             v
         FormInput

Form HoC

  • Receives a getter for initial form state
  • Passes form and formState down as props
  • exposes functionality such as revert, getValue(), setValue(val)

FormInput

  • Any component with the following interface
<FormInput
  form={{
    setValue,
    getValue,
    getErrors,
    addValidationFn,
  }}
 />
 // or for convenience

<FormInput
  form={ props.form.field('name') }
/>

A quick note on validation

We would like a system where the structure of the error object and the presentational logic for displaying inline error messages is defined in the same place, this allows predictability and better inline validation without cross-coupling form and input components.

API Reference

FormInterface

Passes as a props.form to the wrapped component of the Form HoC.

form.getValue()

returns all of the form data

form.getValue(keypath)

returns data for that particular keypath, ex: form.getValue('variations')

form.setValue(formData)

Sets the data for the entire form, if validateOnChange is enabled it will re-validate

form.setValue(keypath, value)

Sets data for a particular keypath, if validateOnChange option is true then it will revalidate that keypath.

form.revert()

Reverts the editing state to the original "initialState"

form.validateAll()

Iterates and runs all validation functions, useful for right before submission. Validation results is accessible via form.isFormValid()

form.isFormValid()

returns boolean based on the current validation state. Note: this does not do validation, so you must either call form.validateAll()

form.addValidationFn(validationFn)

validationFn

form.addValidationFn({
  keypath: 'name',
  getErrors(name, formData) {
    // can access `formData` to look at entire contents of form

    // must return an object of the shape ErrorObject
    return {
      hasErrors: false,
      details: {},
    };
  }
})

form.field(keypath)

Creates a scoped FormFieldInterface scoped to a particular keypath

FormFieldInterface

Example:

const nameField = props.form.field('name');
nameField.setValue('Alan');

nameField.getValue() // returns 'Alan'

nameField.addValidationFn({
  getErrors() {
    return {
      hasErrors: true,
      details: {
        msg: 'this is fake error'
      },
    }
  },
});

nameField.validate();

const errors = nameField.getErrors();  // would return the `getErrors() result`

In practice it's often used to pass an namespaced object to FormInputs

 <FormInput
   form={ props.form.field('name') }
 />

form.getValue()

returns the value for the particular field

form.setValue(value)

Sets the data for the particular field

form.getErrors()

returns Error object for the particular field

form.validate()

Runs any validation fn registered on this keypath and puts errors in formState.errors which is in turned passed down to components via form.field(fieldName).getErrors()

form.addValidationFn()

Adds a validation function for the scoped keypath

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment