grommet/grommet

View on GitHub
src/js/components/Form/Form.js

Summary

Maintainability
F
4 days
Test Coverage
import React, {
  forwardRef,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { useAnalytics } from '../../contexts';
import { MessageContext } from '../../contexts/MessageContext';
import { useForwardedRef } from '../../utils';

import { FormContext } from './FormContext';
import { FormPropTypes } from './propTypes';

const defaultValue = {};
const defaultTouched = {};
const defaultValidationResults = {
  errors: {},
  infos: {},
};
const defaultThreshold = 0.5;

const stringToArray = (string) => {
  const match = string?.match(/^(.+)\[([0-9]+)\]\.(.*)$/);
  if (match) {
    const [, arrayName, indexOfArray, arrayObjName] = match;
    return {
      indexOfArray,
      arrayName,
      arrayObjName,
    };
  }
  return undefined;
};

const getValueAt = (valueObject, pathArg) => {
  if (valueObject === undefined) return undefined;
  const path = Array.isArray(pathArg) ? pathArg : pathArg.split('.');
  if (path.length === 1) return valueObject[path];
  return getValueAt(valueObject[path.shift()], path);
};

const setValueAt = (valueObject, pathArg, value) => {
  const object = valueObject;
  const path = Array.isArray(pathArg) ? pathArg : pathArg.split('.');
  if (path.length === 1) object[path] = value;
  else {
    const key = path.shift();
    if (!object[key]) object[key] = {};
    setValueAt(object[key], path, value);
  }
};

const getFieldValue = (name, value) => {
  const isArrayField = stringToArray(name);
  if (isArrayField) {
    const { indexOfArray, arrayName, arrayObjName } = isArrayField;
    const obj = value[arrayName]?.[indexOfArray];
    return arrayObjName ? obj?.[arrayObjName] : obj;
  }
  return getValueAt(value, name);
};

const setFieldValue = (name, componentValue, prevValue) => {
  const nextValue = { ...prevValue };
  const isArrayField = stringToArray(name);
  if (isArrayField) {
    const { indexOfArray, arrayName, arrayObjName } = isArrayField;
    if (!nextValue[arrayName]) nextValue[arrayName] = [];
    if (arrayObjName) {
      if (!nextValue[arrayName][indexOfArray])
        nextValue[arrayName][indexOfArray] = {
          [arrayObjName]: componentValue,
        };
      nextValue[arrayName][indexOfArray][arrayObjName] = componentValue;
    } else nextValue[arrayName][indexOfArray] = componentValue;
  } else {
    setValueAt(nextValue, name, componentValue);
  }
  return nextValue;
};

const validateCharacterCount = (format, rule, value) => {
  const { max, threshold } = rule;

  const getMessage = () => {
    const charactersRemaining = (plural) => ({
      id: `formField.maxCharacters.remaining.${plural ? 'plural' : 'singular'}`,
      values: { number: max - value.length },
    });

    const charactersOverLimit = (plural) => ({
      id: `formField.maxCharacters.overLimit.${plural ? 'plural' : 'singular'}`,
      values: { number: value.length - max },
    });

    if (max - value.length >= 0) {
      return format(charactersRemaining(max - value.length > 1));
    }
    return format(charactersOverLimit(value.length - max > 1));
  };

  return value.length / max > (threshold ?? defaultThreshold)
    ? {
        status: max - value.length >= 0 ? 'info' : 'error',
        message: getMessage(),
      }
    : undefined;
};

// Apply validation rule to field value and send correct messaging.
const validate = (rule, fieldValue, formValue, format, messages) => {
  let result;

  if (typeof rule === 'function') {
    result = rule(fieldValue, formValue);
  } else if (rule.regexp) {
    if (!rule.regexp.test(fieldValue)) {
      result = rule.message || format({ id: 'form.invalid', messages });
      if (rule.status) {
        result = { message: result, status: rule.status };
      }
    }
  } else if (rule.max) {
    result = validateCharacterCount(format, rule, fieldValue);
  }

  return result;
};

// Validates particular key in formValue
const validateName =
  (validationRules, required) => (name, formValue, format, messages) => {
    const fieldValue = getFieldValue(name, formValue);
    let validationResult;

    if (
      required &&
      // false is for CheckBox
      (fieldValue === undefined ||
        fieldValue === '' ||
        fieldValue === false ||
        (Array.isArray(fieldValue) && !fieldValue.length))
    ) {
      validationResult = format({ id: 'form.required', messages });
    } else if (validationRules) {
      if (Array.isArray(validationRules)) {
        validationRules.some((rule) => {
          validationResult = validate(
            rule,
            fieldValue,
            formValue,
            format,
            messages,
          );
          return !!validationResult;
        });
      } else {
        validationResult = validate(
          validationRules,
          fieldValue,
          formValue,
          format,
          messages,
        );
      }
    }
    return validationResult;
  };

// Validates all keys in formValue
const validateForm = (
  validationRules,
  formValue,
  format,
  messages,
  omitValid,
) => {
  const nextErrors = {};
  const nextInfos = {};
  validationRules.forEach(([name, { field, input, validateOn }]) => {
    if (!omitValid) {
      nextErrors[name] = undefined;
      nextInfos[name] = undefined;
    }
    if (!validateOn) return;
    let result;
    if (input) {
      // input() a validation function supplied through useFormInput()
      result = input(name, formValue, format, messages);
    }
    if (field && !result) {
      // field() a validation function supplied through useFormField()
      result = field(name, formValue, format, messages);
    }
    // typeof error === 'object' is implied for both cases of error with
    // a status message and for an error object that is a react node
    if (typeof result === 'object') {
      if (result.status === 'info') {
        nextInfos[name] = result.message;
      } else {
        nextErrors[name] = result.message || result; // could be a node
      }
    } else if (typeof result === 'string') {
      nextErrors[name] = result;
    }
  });
  return [nextErrors, nextInfos];
};

const isInstantValidate = (validateOn) =>
  ['blur', 'change'].includes(validateOn);

const Form = forwardRef(
  (
    {
      children,
      errors: errorsProp = defaultValidationResults.errors,
      infos: infosProp = defaultValidationResults.infos,
      messages,
      kind,
      onChange,
      onReset,
      onSubmit,
      onValidate,
      validate: validateOnProp = 'submit',
      value: valueProp,
      ...rest
    },
    ref,
  ) => {
    const formRef = useForwardedRef(ref);
    const { format } = useContext(MessageContext);
    const [valueState, setValueState] = useState(valueProp || defaultValue);
    const value = useMemo(
      () => valueProp || valueState,
      [valueProp, valueState],
    );
    const [touched, setTouched] = useState(defaultTouched);
    const [validateOn, setValidateOn] = useState(validateOnProp);
    const [validationResults, setValidationResults] = useState({
      errors: errorsProp,
      infos: infosProp,
    });
    // maintain a copy of validationResults in a ref for useEffects
    // which can't depend on validationResults directly without
    // causing infinite renders.
    const validationResultsRef = useRef({});
    // Simulated onMount state. Consider Form to be mounted once it has
    // accounted for values originating from controlled inputs (available
    // at second rendering).
    const [mounted, setMounted] = useState('unmounted');
    useEffect(() => {
      if (!mounted) setMounted('mounting');
      else if (mounted === 'mounting') setMounted('mounted');
    }, [mounted]);
    // `pendingValidation` is the name of the FormField awaiting validation.
    const [pendingValidation, setPendingValidation] = useState(undefined);

    const validationRulesRef = useRef({});
    const requiredFields = useRef([]);
    const analyticsRef = useRef({ start: new Date(), errors: {} });

    const sendAnalytics = useAnalytics();

    const buildValid = useCallback(
      (nextErrors) => {
        let valid = false;
        valid = requiredFields.current
          .filter((n) => Object.keys(validationRulesRef.current).includes(n))
          .every(
            (field) =>
              value[field] && (value[field] !== '' || value[field] !== false),
          );

        if (Object.keys(nextErrors).length > 0) valid = false;
        return valid;
      },
      [value],
    );

    // Only keep validation results for current form fields. In the case of a
    // dynamic form, a field possessing an error may have been removed from the
    // form; need to clean up any previous related validation results.
    const filterRemovedFields = (prevValidations) => {
      const nextValidations = prevValidations;
      return Object.keys(nextValidations)
        .filter(
          (n) =>
            !validationRulesRef.current[n] || nextValidations[n] === undefined,
        )
        .forEach((n) => delete nextValidations[n]);
    };

    const updateAnalytics = () => {
      const errorFields = Object.keys(validationResultsRef.current?.errors);
      const errorCounts = analyticsRef.current.errors;

      if (errorFields.length > 0) {
        errorFields.forEach((key) => {
          errorCounts[key] = (errorCounts[key] || 0) + 1;
        });
      }
    };

    const applyValidationRules = useCallback(
      (validationRules) => {
        const [validatedErrors, validatedInfos] = validateForm(
          validationRules,
          value,
          format,
          messages,
        );

        setValidationResults((prevValidationResults) => {
          // Keep any previous errors and infos for untouched keys,
          // these may have come from a Submit.
          const nextErrors = {
            ...prevValidationResults.errors,
            ...validatedErrors,
          };
          const nextInfos = {
            ...prevValidationResults.infos,
            ...validatedInfos,
          };
          // Remove previous errors and infos for keys no longer in the
          // form, these may have been fields removed from a dynamic form.
          filterRemovedFields(nextErrors);
          filterRemovedFields(nextInfos);
          const nextValidationResults = {
            errors: nextErrors,
            infos: nextInfos,
          };
          if (onValidate)
            onValidate({
              ...nextValidationResults,
              valid: buildValid(nextErrors),
            });
          validationResultsRef.current = nextValidationResults;
          updateAnalytics();
          return nextValidationResults;
        });
      },
      [buildValid, format, messages, onValidate, value],
    );

    // Validate all fields holding values onMount if set to
    // validate when blur or change.
    useEffect(() => {
      const validationRules = Object.entries(validationRulesRef.current);
      // Use simulated onMount state to account for values provided by
      // controlled inputs.
      if (
        mounted !== 'mounted' &&
        (isInstantValidate(validateOn) ||
          validationRules.some(([, v]) => isInstantValidate(v.validateOn))) &&
        Object.keys(value).length > 0 &&
        Object.keys(touched).length === 0
      ) {
        applyValidationRules(
          validationRules
            .filter(([n, v]) => getFieldValue(n, value) && v.validateOn)
            // Exlude empty arrays which may be initial values in
            // an input such as DateInput.
            .filter(
              ([n]) =>
                !(
                  Array.isArray(getFieldValue(n, value)) &&
                  getFieldValue(n, value).length === 0
                ),
            ),
        );
      }
    }, [applyValidationRules, mounted, touched, validateOn, value]);

    // Run validation against fields with pendingValidations from onBlur
    // and/or onChange.
    useEffect(() => {
      const validationRules = Object.entries(validationRulesRef.current);
      const timer = setTimeout(() => {
        if (
          pendingValidation &&
          (isInstantValidate(validateOn) ||
            validationRules.some(([, v]) => isInstantValidate(v.validateOn)))
        ) {
          applyValidationRules(
            validationRules.filter(
              ([n, v]) =>
                (touched[n] || pendingValidation.includes(n)) && v.validateOn,
            ),
          );
          setPendingValidation(undefined);
        }
        // Complete any potential click events before running onBlur validation.
        // Otherwise, click events like reset, etc. may not be registered. For a
        // detailed scenario/discussion, see: https://github.com/grommet/grommet/issues/4863
        // Values empirically tested; 120 was selected because it is the largest
        // Chrome: 100, Safari: 120, Firefox: 80
      }, 120);
      return () => clearTimeout(timer);
    }, [applyValidationRules, pendingValidation, touched, validateOn]);

    // Re-run validation rules for all fields with prior errors.
    // if validate=blur this helps re-validate if there are errors
    // as the user fixes them (basically act like validate=change for that)
    useEffect(() => {
      const validationRules = Object.entries(validationRulesRef.current);
      if (
        validationResultsRef.current?.errors &&
        Object.keys(validationResultsRef.current.errors).length > 0
      ) {
        applyValidationRules(
          validationRules.filter(
            ([n]) => touched[n] && validationResultsRef.current.errors[n],
          ),
        );
      }
    }, [applyValidationRules, touched]);

    useEffect(() => {
      const element = formRef.current;
      analyticsRef.current = { start: new Date(), errors: {} };
      sendAnalytics({
        type: 'formOpen',
        element,
      });
      return () => {
        if (!analyticsRef.current.submitted) {
          sendAnalytics({
            type: 'formClose',
            element,
            errors: analyticsRef.current.errors,
            elapsed:
              new Date().getTime() - analyticsRef.current.start.getTime(),
          });
        }
      };
    }, [sendAnalytics, formRef]);

    // There are three basic patterns of handling form input value state:
    //
    // 1 - form controlled
    //
    // In this model, the caller sets `value` and `onChange` properties
    // on the Form component to supply the values used by the input fields.
    // In useFormContext(), componentValue would be undefined and formValue
    // is be set to whatever the form state has. Whenever the form state
    // changes, we update the contextValue so the input component will use
    // that. When the input component changes, we will call update() to
    // update the form state.
    //
    // 2 - input controlled
    //
    // In this model, the caller sets `value` and `onChange` properties
    // on the input components, like TextInput, to supply the value for it.
    // In useFormContext(), componentValue is this value and we ensure to
    // update the form state, via update(), and set the contextValue from
    // the componentValue. When the input component changes, we will
    // call update() to update the form state.
    //
    // 3 - uncontrolled
    //
    // In this model, the caller doesn't set a `value` or `onChange` property
    // at either the form or input component levels.
    // In useFormContext(), componentValue is undefined and valueProp is
    // undefined and nothing much happens here. That is, unless the
    // calling component needs to know the state in order to work, such
    // as CheckBox or Select. In this case, those components supply
    // an initialValue, which will trigger updating the contextValue so
    // they can have access to it.
    //
    const formContextValue = useMemo(() => {
      const useFormInput = ({
        name,
        value: componentValue,
        initialValue,
        validate: validateArg,
      }) => {
        const [inputValue, setInputValue] = useState(initialValue);
        const formValue = name ? getFieldValue(name, value) : undefined;
        // for dynamic forms, we need to track when an input has been added to
        // the form value. if the input is unmounted, we will delete its
        // key/value from the form value.
        const keyCreated = useRef(false);

        // This effect is for pattern #2, where the controlled input
        // component is driving the value via componentValue.
        useEffect(() => {
          if (
            name && // we have somewhere to put this
            componentValue !== undefined && // input driving
            (Array.isArray(componentValue) && Array.isArray(formValue)
              ? componentValue.toString() !== formValue.toString()
              : componentValue !== formValue) // don't already have it
          ) {
            setValueState((prevValue) =>
              setFieldValue(name, componentValue, prevValue),
            );
            // don't onChange on programmatic changes
          }
        }, [componentValue, formValue, name]);

        // on unmount, if the form is uncontrolled, remove the key/value
        // from the form value
        useEffect(
          () => () => {
            if (keyCreated.current) {
              keyCreated.current = false;
              setValueState((prevValue) => {
                const nextValue = { ...prevValue };
                const isArrayField = stringToArray(name);
                if (isArrayField) {
                  const { arrayName } = isArrayField;
                  delete nextValue[arrayName];
                } else {
                  delete nextValue[name];
                }
                return nextValue;
              });
            }
          },
          // eslint-disable-next-line react-hooks/exhaustive-deps
          [], // only run onmount and unmount
        );

        // Create validation rules for fields
        useEffect(() => {
          if (validateArg) {
            if (!validationRulesRef.current[name]) {
              validationRulesRef.current[name] = {};
            }
            validationRulesRef.current[name].input = validateName(validateArg);
            return () => delete validationRulesRef.current[name].input;
          }
          return undefined;
        }, [validateArg, name]);

        let useValue;
        if (componentValue !== undefined)
          // input component drives, pattern #2
          useValue = componentValue;
        else if (valueProp && name && formValue !== undefined)
          // form drives, pattern #1
          useValue = formValue;
        else if (formValue === undefined && name)
          // form has reset, so reset input value as well
          useValue = initialValue;
        else useValue = inputValue;

        return [
          useValue,
          (nextComponentValue) => {
            if (name) {
              // we have somewhere to put this
              const nextTouched = { ...touched };
              nextTouched[name] = true;

              if (!touched[name]) {
                // don't update if not needed
                setTouched(nextTouched);
              }

              // if nextValue doesn't have a key for name, this must be
              // uncontrolled form. we will flag this field was added so
              // we know to remove its value from the form if it is dynamically
              // removed
              if (!(name in value)) keyCreated.current = true;
              const nextValue = setFieldValue(name, nextComponentValue, value);
              setValueState(nextValue);
              if (onChange) onChange(nextValue, { touched: nextTouched });
            }
            if (initialValue !== undefined) setInputValue(nextComponentValue);
          },
        ];
      };

      const useFormField = ({
        error: errorArg,
        info: infoArg,
        name,
        required,
        disabled,
        validate: validateArg,
        validateOn: validateOnArg,
      }) => {
        const error = disabled
          ? undefined
          : errorArg || validationResults.errors[name];
        const info = infoArg || validationResults.infos[name];

        useEffect(() => {
          setValidateOn((prevValues) => {
            if (typeof prevValues === 'string') {
              return { [name]: validateOnArg || validateOnProp };
            }
            return { ...prevValues, [name]: validateOnArg || validateOnProp };
          });
        }, [validateOnArg, name]);

        // Create validation rules for field
        useEffect(() => {
          const index = requiredFields.current.indexOf(name);
          if (required) {
            if (index === -1) requiredFields.current.push(name);
          } else if (index !== -1) requiredFields.current.splice(index, 1);

          if (validateArg || required) {
            if (!validationRulesRef.current[name]) {
              validationRulesRef.current[name] = {};
            }
            validationRulesRef.current[name].field = validateName(
              validateArg,
              required,
            );

            // priority is given to validateOn prop on formField, if it is
            // undefined, then we will use the validate prop value of Form.
            // The reason we don't want to add validateOn = "submit" here is
            // because we don't want to trigger validation of "submit" field
            // when the user is typing in the instant (blur, change)
            // validation fields.
            if (validateOnArg && validateOnArg !== 'submit') {
              validationRulesRef.current[name].validateOn = validateOnArg;
            } else if (!validateOnArg && validateOnProp !== 'submit') {
              validationRulesRef.current[name].validateOn = validateOnProp;
            }
            return () => {
              delete validationRulesRef.current[name].field;
              delete validationRulesRef.current[name].validateOn;
              const requiredFieldIndex = requiredFields.current.indexOf(name);
              if (requiredFieldIndex !== -1) {
                requiredFields.current.splice(requiredFieldIndex, 1);
              }
            };
          }

          return undefined;
        }, [error, name, required, validateArg, disabled, validateOnArg]);

        return {
          error,
          info,
          inForm: true,
          onBlur:
            validateOnArg === 'blur' || validateOn[name] === 'blur'
              ? () =>
                  setPendingValidation(
                    pendingValidation ? [...pendingValidation, name] : [name],
                  )
              : undefined,
          onChange:
            validateOnArg === 'change' || validateOn[name] === 'change'
              ? () =>
                  setPendingValidation(
                    pendingValidation ? [...pendingValidation, name] : [name],
                  )
              : undefined,
        };
      };

      return { useFormField, useFormInput, kind };
    }, [
      onChange,
      kind,
      pendingValidation,
      touched,
      validateOn,
      validationResults.errors,
      validationResults.infos,
      value,
      valueProp,
      validateOnProp,
    ]);

    return (
      <form
        ref={formRef}
        {...rest}
        onReset={(event) => {
          sendAnalytics({
            type: 'formReset',
            element: formRef.current,
            data: event,
            errors: analyticsRef.current.errors,
            elapsed:
              new Date().getTime() - analyticsRef.current.start.getTime(),
          });
          setPendingValidation(undefined);
          if (!valueProp) {
            setValueState(defaultValue);
            if (onChange) onChange(defaultValue, { touched: defaultTouched });
          }
          setTouched(defaultTouched);
          setValidationResults(defaultValidationResults);
          analyticsRef.current = { start: new Date(), errors: {} };
          if (onReset) {
            event.persist(); // extract from React's synthetic event pool
            const adjustedEvent = event;
            adjustedEvent.value = defaultValue;
            onReset(adjustedEvent);
          }
        }}
        onSubmit={(event) => {
          // Don't submit the form via browser form action. We don't want it
          // if the validation fails. And, we assume a javascript action handler
          // otherwise.
          event.preventDefault();
          // Prevent any "outer" forms from performing `onSubmit`.
          // Nesting forms is not recommended in HTML. However, with React
          // portals, if the portal (such as Grommet's Layer) contains a form
          // and is nested within another form in the React tree, the event
          // bubbles up to the "outer" form even though in the DOM the portal
          // doesn't render as child of the "outer" form.
          // https://legacy.reactjs.org/docs/portals.html#event-bubbling-through-portals
          if (
            formRef.current &&
            (event.target === formRef.current ||
              event.target.form === formRef.current)
          ) {
            setPendingValidation(undefined);
            // adding validateOn: "submit" prop to the undefined validateOn
            // fields as we want to trigger "submit" validation once form
            // is submitted
            const newValidationRulesRef = Object.keys(
              validationRulesRef.current,
            ).reduce((acc, key) => {
              acc[key] = validationRulesRef.current[key];
              if (!acc[key].validateOn) {
                acc[key] = {
                  ...validationRulesRef.current[key],
                  validateOn: 'submit',
                };
              }
              return acc;
            }, {});

            const [nextErrors, nextInfos] = validateForm(
              Object.entries(newValidationRulesRef),
              value,
              format,
              messages,
              true,
            );

            setValidationResults(() => {
              const nextValidationResults = {
                errors: nextErrors,
                infos: nextInfos,
                // Show form's validity when clicking on Submit
                valid: buildValid(nextErrors),
              };
              if (onValidate) onValidate(nextValidationResults);
              validationResultsRef.current = nextValidationResults;
              updateAnalytics();
              return nextValidationResults;
            });

            if (Object.keys(nextErrors).length === 0 && onSubmit) {
              event.persist(); // extract from React's synthetic event pool
              const adjustedEvent = event;
              adjustedEvent.value = value;
              adjustedEvent.touched = touched;
              onSubmit(adjustedEvent);
              sendAnalytics({
                type: 'formSubmit',
                element: formRef.current,
                data: adjustedEvent,
                errors: analyticsRef.current.errors,
                elapsed:
                  new Date().getTime() - analyticsRef.current.start.getTime(),
              });
              analyticsRef.current.errors = {};
              analyticsRef.current.submitted = true;
            }
          }
        }}
      >
        <FormContext.Provider value={formContextValue}>
          {children}
        </FormContext.Provider>
      </form>
    );
  },
);

Form.displayName = 'Form';
Form.propTypes = FormPropTypes;

export { Form };