UNPKG

@shopify/react-form-state

Version:

Manage React forms tersely and type-safely with no magic

481 lines (409 loc) 10.8 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var React = require('react'); var utilities = require('./utilities.js'); var List = require('./components/List.js'); var Nested = require('./components/Nested.js'); var isEqual = require('fast-deep-equal'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } var React__default = /*#__PURE__*/_interopDefaultLegacy(React); var isEqual__default = /*#__PURE__*/_interopDefaultLegacy(isEqual); /* eslint-disable no-case-declarations */ class FormState extends React__default["default"].PureComponent { constructor(...args) { super(...args); 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 = async 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) { await this.validateForm(); const clientErrors = this.clientErrors; if (clientErrors.length > 0) { this.setState({ submitting: false, errors: clientErrors }); return; } } const errors = (await onSubmit(formData)) || []; if (!this.mounted) { return; } if (errors.length > 0) { this.updateRemoteErrors(errors); this.setState({ submitting: false }); } else { this.setState({ submitting: false, errors }); } }; this.fieldWithHandlers = (field, fieldPath) => { if (this.fieldsWithHandlers.has(field)) { return this.fieldsWithHandlers.get(field); } const result = { ...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__default["default"](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__default["default"](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({ ...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 utilities.flatMap(Object.values(fields), ({ error }) => collectErrors(error)); } get fields() { const { fields } = this.state; const fieldDescriptors = utilities.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__default["default"](newValue, field.initialValue); const updatedField = this.getUpdatedField({ fieldPath, field, value: newValue, dirty }); return { dirtyFields: this.getUpdatedDirtyFields({ fieldPath, dirty, dirtyFields }), fields: updatedField === field ? fields : { // FieldStates<Fields> is not spreadable due to a TS bug // https://github.com/Microsoft/TypeScript/issues/13557 ...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 { ...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: { // FieldStates<Fields> is not spreadable due to a TS bug // https://github.com/Microsoft/TypeScript/issues/13557 ...state.fields, [fieldPath]: { ...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["default"]; FormState.Nested = Nested["default"]; function fieldsWithErrors(fields, errors) { const errorDictionary = errors.reduce((accumulator, { field, message }) => { if (field == null) { return accumulator; } return utilities.set(accumulator, field, message); }, {}); return utilities.mapObject(fields, (field, path) => { if (!errorDictionary[path]) { return field; } return { ...field, error: errorDictionary[path] }; }); } function reconcileFormState(values, oldState, externalErrors = []) { const { fields: oldFields } = oldState; const dirtyFields = new Set(oldState.dirtyFields); const fields = utilities.mapObject(values, (value, key) => { const oldField = oldFields[key]; if (isEqual__default["default"](value, oldField.initialValue)) { return oldField; } dirtyFields.delete(key); return { value, initialValue: value, dirty: false }; }); return { ...oldState, dirtyFields: Array.from(dirtyFields), fields: fieldsWithErrors(fields, externalErrors) }; } function createFormState(values, externalErrors = []) { const fields = utilities.mapObject(values, value => { return { value, initialValue: value, dirty: false }; }); return { dirtyFields: [], errors: [], submitting: false, externalErrors, fields: fieldsWithErrors(fields, externalErrors) }; } function initialValuesFromFields(fields) { return utilities.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 = utilities.mapObject(fields, (field, path) => { return { ...field, error: runValidator(validators[path], field.value, fields) }; }); return { ...state, fields: updatedFields }; } function collectErrors(message) { if (!message) { return []; } if (typeof message === 'string') { return [{ message }]; } if (Array.isArray(message)) { return utilities.flatMap(message, itemError => collectErrors(itemError)); } return utilities.flatMap(Object.values(message), nestedError => collectErrors(nestedError)); } exports["default"] = FormState;