ModusCreateOrg/budgeting

View on GitHub
app/components/Form/index.js

Summary

Maintainability
A
0 mins
Test Coverage
// @flow
import * as React from 'react';
import provideContextBroadcast from 'utils/provideContextBroadcast';

type FormError = {
  _error: string,
  [fieldName: string]: string,
};

type FormValues = { [fieldName: string]: mixed };

type FormState = {
  values: FormValues,
  blurred: { [fieldName: string]: boolean },
};

type FormFieldData = {
  name: string,
  value: mixed,
  initialValue: mixed,
  blurred: boolean,
  error: string,
  onBlur: () => void,
  onChange: (value: mixed) => void,
};

export type FormData = {
  fields: { [fieldName: string]: FormFieldData },
  error: string,
  valid: boolean,
  initializeForm: (newValues: FormValues) => void,
  submitForm: () => void,
};

type FormProps = {
  children: React.Node,
  setBroadcastState: (newState: mixed) => void,
  fields: string[],
  initialValues?: FormValues,
  onSubmit: (values: FormValues) => void,
  onSubmitSuccess: () => void,
  onSubmitFail: () => void,
  onFormDataChange: (formData: FormData) => void,
  validate: (values: FormValues, otherProps: Object) => FormError,
};

/**
 * Form component
 *
 * It renders a <form> element, handles all the form state, and provides
 * information and actions to the children components using context.
 *
 * It takes the following props:
 * - fields (required): Array of field names.
 * - initialValues:     Map of initial values.
 * - onSubmit:          Function to call on successful form submit.
 *                      If not provided, the submit logic can be handled in the
 *                      component.
 * - onSubmitSuccess:   Function to call when submit succeeded.
 * - onSubmitFail:      Function to call when submit failed.
 * - onFormDataChange:  Function to call with the form data when it changes.
 * - validate:          Function to validate the form on every change, if not
 *                      provided the form is always valid.
 *
 * The following props will be passed in a context Broadcast:
 * - fields:            Object with the value, blurred state, and errors for every field.
 *                      It also has function to attach the fields to the form state.
 * - error:             A generic error for the entire form (from the `_error` field returned
 *                      in the validation function).
 * - valid:             True is the form is valid.
 * - submitForm:        When called, the form will be validated and the provided 'onSubmit' function
 *                      will be called.
 * - initializeForm:    Initialize the form with the provided field values.
 *
 * To attach the form state to your form fields, you should use a component which takes form data
 * from the context Broadcast. (check `components/Field`)
 *
 */
export class Form extends React.Component<FormProps> {
  static defaultProps = {
    initialValues: {},
  };

  constructor(props: FormProps) {
    super(props);

    const { initialValues } = props;

    // Initialize form state
    if (initialValues) {
      const initialFormState = this.getInitialFormState(initialValues);
      this.setFormState(initialFormState);
    }
  }

  /**
   * Takes an object with form values and returns an initialized state
   * for the form.
   */
  getInitialFormState = (newValues: FormValues): FormState => {
    const { fields } = this.props;

    // make default values for every field not defined in `newValues`,
    // this is needed to use controlled components.
    const values = fields.reduce((result, field) => {
      const hasInitialValue = Object.keys(newValues).includes(field);
      result[field] = hasInitialValue ? newValues[field] : '';
      return result;
    }, {});

    // initialize 'blurred' as false for every field
    const blurred = fields.reduce((accumulator, field) => {
      accumulator[field] = false;
      return accumulator;
    }, {});

    return {
      values,
      blurred,
    };
  };

  /**
   * Return all props that are not used by the component
   */
  getOtherProps(): { [propName: string]: mixed } {
    const {
      fields,
      validate,
      children,
      setBroadcastState,
      initialValues,
      onFormDataChange,
      onSubmit,
      onSubmitSuccess,
      onSubmitFail,
      ...otherProps
    } = this.props;
    return otherProps;
  }

  /**
   * Return a FormData object from the form state.
   *
   * It contains form information and handlers sorted by fields.
   * Meant to be used by other components.
   */
  getFormData = (): FormData => {
    const { fields, initialValues } = this.props;
    const { values, blurred } = this.formState;

    // validate form and get info about errors
    const errors = this.runValidator();
    const formError = errors._error || '';
    const isFormValid = Object.keys(errors).length === 0;

    const fieldsData = fields.reduce((accumulator, field) => {
      accumulator[field] = {
        name: field,
        value: values[field],
        initialValue: initialValues ? initialValues[field] : undefined,
        blurred: blurred[field],
        error: errors[field] || '',
        onBlur: () => {
          this.handleBlur(field);
        },
        onChange: value => {
          this.handleChange(field, value);
        },
      };
      return accumulator;
    }, {});

    return {
      fields: fieldsData,
      error: formError,
      valid: isFormValid,
      initializeForm: this.initializeForm,
      submitForm: this.submitForm,
    };
  };

  setFormState(newFormState: FormState): void {
    const { setBroadcastState } = this.props;

    // set form state
    this.formState = newFormState;

    // get form data and set it in the broadcast
    const formData = this.getFormData();
    setBroadcastState(formData);

    // if a handler exists, call it with form data
    if (this.props.onFormDataChange) {
      this.props.onFormDataChange(formData);
    }
  }

  formState: FormState;

  /**
   * Mark every field as blurred.
   *
   * This is called when the user tries to submit the form so that error messages
   * are visible for every field.
   */
  blurAll(): void {
    const { fields } = this.props;

    const allBlurred = fields.reduce((accumulator, field) => {
      accumulator[field] = true;
      return accumulator;
    }, {});

    this.setFormState({
      ...this.formState,
      blurred: allBlurred,
    });
  }

  /**
   * Handle blurring a form field.
   *
   * Sets `blurred` to true for the field.
   */
  handleBlur = (fieldName: string): void => {
    this.setFormState({
      ...this.formState,
      blurred: {
        ...this.formState.blurred,
        [fieldName]: true,
      },
    });
  };

  /**
   * Handle changing a form field.
   *
   * Sets the new field value.
   */
  handleChange = (fieldName: string, value: mixed): void => {
    this.setFormState({
      ...this.formState,
      values: {
        ...this.formState.values,
        [fieldName]: value,
      },
    });
  };

  /**
   * Initialize form state with the provided form values
   */
  initializeForm = (newValues: FormValues): void => {
    const initialFormState = this.getInitialFormState(newValues);
    this.setFormState(initialFormState);
  };

  /**
   * Checks if the form is valid, and then calls the provided `onSubmit` function.
   */
  submitForm = (event: ?SyntheticEvent<HTMLFormElement>): void => {
    const { onSubmit, onSubmitSuccess, onSubmitFail } = this.props;
    const { values } = this.formState;

    // If form submit was triggered by an event, call `preventDefault()`
    if (event) {
      event.preventDefault();
    }

    const errors = this.runValidator();
    const isFormValid = Object.keys(errors).length === 0;

    // mark all fields as blurred, that way errors can be shown in the UI
    this.blurAll();

    if (isFormValid && onSubmit) {
      onSubmit(values);
      if (onSubmitSuccess) {
        onSubmitSuccess();
      }
    } else if (onSubmitFail) {
      onSubmitFail();
    }
  };

  /**
   * Runs the provided `validator`.
   *
   * Falls back to a default validator which always returns valid.
   *
   * (values, props) => {FormError}
   * A validator takes the current values and props, and returns an object
   * mapping every failing field to a string describing the error.
   *
   * A special `_error` key represents a generic error in the form.
   */
  runValidator(): FormError {
    const { validate } = this.props;
    const { values } = this.formState;

    const defaultValidator = () => ({});
    const validator = validate || defaultValidator;

    return validator(values, this.getOtherProps());
  }

  render() {
    return (
      <form onSubmit={this.submitForm} {...this.getOtherProps()}>
        {this.props.children}
      </form>
    );
  }
}

export default provideContextBroadcast('formData')(Form);