UNPKG

@shopify/react-form-state

Version:

Manage React forms tersely and type-safely with no magic

480 lines (407 loc) 10.8 kB
import { objectSpread2 as _objectSpread2, asyncToGenerator as _asyncToGenerator } from './_virtual/_rollupPluginBabelHelpers.js'; import React from 'react'; import { mapObject, flatMap, set } from './utilities.mjs'; import List from './components/List.mjs'; import Nested from './components/Nested.mjs'; import isEqual from 'fast-deep-equal'; class FormState extends React.PureComponent { constructor(...args) { var _this; super(...args); _this = this; this.state = createFormState(this.props.initialValues, this.props.externalErrors); this.mounted = false; this.fieldsWithHandlers = new WeakMap(); this.reset = () => { return new Promise(resolve => { this.setState((_state, props) => createFormState(props.initialValues, props.externalErrors), () => resolve()); }); }; this.submit = /*#__PURE__*/function () { var _ref = _asyncToGenerator(function* (event) { const { onSubmit, validateOnSubmit } = _this.props; const { formData } = _this; if (!_this.mounted) { return; } if (event && event.preventDefault && !event.defaultPrevented) { event.preventDefault(); } if (onSubmit == null) { return; } _this.setState({ submitting: true }); if (validateOnSubmit) { yield _this.validateForm(); const clientErrors = _this.clientErrors; if (clientErrors.length > 0) { _this.setState({ submitting: false, errors: clientErrors }); return; } } const errors = (yield onSubmit(formData)) || []; if (!_this.mounted) { return; } if (errors.length > 0) { _this.updateRemoteErrors(errors); _this.setState({ submitting: false }); } else { _this.setState({ submitting: false, errors }); } }); return function (_x) { return _ref.apply(this, arguments); }; }(); this.fieldWithHandlers = (field, fieldPath) => { if (this.fieldsWithHandlers.has(field)) { return this.fieldsWithHandlers.get(field); } const result = _objectSpread2(_objectSpread2({}, field), {}, { name: String(fieldPath), onChange: this.updateField.bind(this, fieldPath), onBlur: this.blurField.bind(this, fieldPath) }); this.fieldsWithHandlers.set(field, result); return result; }; } static getDerivedStateFromProps(newProps, oldState) { const { initialValues, onInitialValuesChange, externalErrors = [] } = newProps; const externalErrorsChanged = !isEqual(externalErrors, oldState.externalErrors); const updatedExternalErrors = externalErrorsChanged ? { externalErrors, fields: fieldsWithErrors(oldState.fields, [...externalErrors, ...oldState.errors]) } : null; switch (onInitialValuesChange) { case 'ignore': return updatedExternalErrors; case 'reset-where-changed': return reconcileFormState(initialValues, oldState, externalErrors); case 'reset-all': default: const oldInitialValues = initialValuesFromFields(oldState.fields); const valuesMatch = isEqual(oldInitialValues, initialValues); if (valuesMatch) { return updatedExternalErrors; } return createFormState(initialValues, externalErrors); } } componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } render() { const { children } = this.props; const { submitting } = this.state; const { submit, reset, formData } = this; return children(_objectSpread2(_objectSpread2({}, formData), {}, { submit, reset, submitting })); } // eslint-disable-next-line @shopify/react-prefer-private-members validateForm() { return new Promise(resolve => { this.setState(runAllValidators, () => resolve()); }); } // eslint-disable-next-line @shopify/react-prefer-private-members get formData() { const { errors } = this.state; const { externalErrors = [] } = this.props; const { fields, dirty, valid } = this; return { dirty, valid, errors: [...errors, ...externalErrors], fields }; } get dirty() { return this.state.dirtyFields.length > 0; } get valid() { const { errors, externalErrors } = this.state; return !this.hasClientErrors && errors.length === 0 && externalErrors.length === 0; } get hasClientErrors() { const { fields } = this.state; return Object.keys(fields).some(fieldPath => { const field = fields[fieldPath]; return field.error != null; }); } get clientErrors() { const { fields } = this.state; return flatMap(Object.values(fields), ({ error }) => collectErrors(error)); } get fields() { const { fields } = this.state; const fieldDescriptors = mapObject(fields, this.fieldWithHandlers); return fieldDescriptors; } updateField(fieldPath, value) { this.setState(({ fields, dirtyFields }) => { const field = fields[fieldPath]; const newValue = typeof value === 'function' ? value(field.value) : value; const dirty = !isEqual(newValue, field.initialValue); const updatedField = this.getUpdatedField({ fieldPath, field, value: newValue, dirty }); return { dirtyFields: this.getUpdatedDirtyFields({ fieldPath, dirty, dirtyFields }), fields: updatedField === field ? fields : _objectSpread2(_objectSpread2({}, fields), {}, { [fieldPath]: updatedField }) }; }); } getUpdatedDirtyFields({ fieldPath, dirty, dirtyFields }) { const dirtyFieldsSet = new Set(dirtyFields); if (dirty) { dirtyFieldsSet.add(fieldPath); } else { dirtyFieldsSet.delete(fieldPath); } const newDirtyFields = Array.from(dirtyFieldsSet); return dirtyFields.length === newDirtyFields.length ? dirtyFields : newDirtyFields; } getUpdatedField({ fieldPath, field, value, dirty }) { // We only want to update errors as the user types if they already have an error. // https://polaris.shopify.com/patterns/error-messages#section-form-validation const skipValidation = field.error == null; const error = skipValidation ? field.error : this.validateFieldValue(fieldPath, { value, dirty }); if (value === field.value && error === field.error) { return field; } return _objectSpread2(_objectSpread2({}, field), {}, { value, dirty, error }); } blurField(fieldPath) { const { fields } = this.state; const field = fields[fieldPath]; const error = this.validateFieldValue(fieldPath, field); if (error == null) { return; } this.setState(state => ({ fields: _objectSpread2(_objectSpread2({}, state.fields), {}, { [fieldPath]: _objectSpread2(_objectSpread2({}, state.fields[fieldPath]), {}, { error }) }) })); } validateFieldValue(fieldPath, { value, dirty }) { if (!dirty) { return; } const { validators = {} } = this.props; const { fields } = this.state; return runValidator(validators[fieldPath], value, fields); } updateRemoteErrors(errors) { this.setState(({ fields, externalErrors }) => ({ errors, fields: fieldsWithErrors(fields, [...errors, ...externalErrors]) })); } } FormState.List = List; FormState.Nested = Nested; function fieldsWithErrors(fields, errors) { const errorDictionary = errors.reduce((accumulator, { field, message }) => { if (field == null) { return accumulator; } return set(accumulator, field, message); }, {}); return mapObject(fields, (field, path) => { if (!errorDictionary[path]) { return field; } return _objectSpread2(_objectSpread2({}, field), {}, { error: errorDictionary[path] }); }); } function reconcileFormState(values, oldState, externalErrors = []) { const { fields: oldFields } = oldState; const dirtyFields = new Set(oldState.dirtyFields); const fields = mapObject(values, (value, key) => { const oldField = oldFields[key]; if (isEqual(value, oldField.initialValue)) { return oldField; } dirtyFields.delete(key); return { value, initialValue: value, dirty: false }; }); return _objectSpread2(_objectSpread2({}, oldState), {}, { dirtyFields: Array.from(dirtyFields), fields: fieldsWithErrors(fields, externalErrors) }); } function createFormState(values, externalErrors = []) { const fields = mapObject(values, value => { return { value, initialValue: value, dirty: false }; }); return { dirtyFields: [], errors: [], submitting: false, externalErrors, fields: fieldsWithErrors(fields, externalErrors) }; } function initialValuesFromFields(fields) { return mapObject(fields, ({ initialValue }) => initialValue); } function runValidator(validate = () => {}, value, fields) { if (typeof validate === 'function') { return validate(value, fields); } if (!Array.isArray(validate)) { return; } const errors = validate.map(validator => validator(value, fields)).filter(input => input != null); if (errors.length === 0) { return; } return errors; } function runAllValidators(state, props) { const { fields } = state; const { validators } = props; if (!validators) { return null; } const updatedFields = mapObject(fields, (field, path) => { return _objectSpread2(_objectSpread2({}, field), {}, { error: runValidator(validators[path], field.value, fields) }); }); return _objectSpread2(_objectSpread2({}, state), {}, { fields: updatedFields }); } function collectErrors(message) { if (!message) { return []; } if (typeof message === 'string') { return [{ message }]; } if (Array.isArray(message)) { return flatMap(message, itemError => collectErrors(itemError)); } return flatMap(Object.values(message), nestedError => collectErrors(nestedError)); } export default FormState;