UNPKG

@shopify/react-form-state

Version:
531 lines (444 loc) 12.3 kB
/* eslint-disable no-case-declarations */ import * as React from 'react'; import {mapObject, set, isEqual} from './utilities'; import { FieldDescriptors, FieldState, ValueMapper, FieldStates, ValidationFunction, } from './types'; import {List, Nested} from './components'; export interface RemoteError { field?: string[] | null; message: string; } type MaybeArray<T> = T | T[]; type MaybePromise<T> = T | Promise<T>; interface SubmitHandler<Fields> { (formDetails: FormData<Fields>): | MaybePromise<RemoteError[]> | MaybePromise<void>; } export type Validator<T, F> = MaybeArray<ValidationFunction<T, F>>; export type ValidatorDictionary<FieldMap> = { [FieldPath in keyof FieldMap]: Validator<FieldMap[FieldPath], FieldMap> }; export interface FormData<Fields> { fields: FieldDescriptors<Fields>; dirty: boolean; valid: boolean; errors: RemoteError[]; } export interface FormDetails<Fields> extends FormData<Fields> { submitting: boolean; reset(): void; submit(): void; } interface Props<Fields> { initialValues: Fields; children(form: FormDetails<Fields>): React.ReactNode; validators?: Partial<ValidatorDictionary<Fields>>; onSubmit?: SubmitHandler<Fields>; validateOnSubmit?: boolean; onInitialValuesChange?: 'reset-all' | 'reset-where-changed' | 'ignore'; externalErrors?: RemoteError[]; } interface State<Fields> { submitting: boolean; fields: FieldStates<Fields>; dirtyFields: (keyof Fields)[]; errors: RemoteError[]; externalErrors: RemoteError[]; } export default class FormState< Fields extends Object > extends React.PureComponent<Props<Fields>, State<Fields>> { static List = List; static Nested = Nested; static getDerivedStateFromProps<T>(newProps: Props<T>, oldState: State<T>) { 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); } } state = createFormState(this.props.initialValues, this.props.externalErrors); private mounted = false; private fieldsWithHandlers = new WeakMap(); 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, }); } public validateForm() { return new Promise(resolve => { this.setState(runAllValidators, () => resolve()); }); } public reset = () => { return new Promise(resolve => { this.setState( (_state, props) => createFormState(props.initialValues, props.externalErrors), () => resolve(), ); }); }; private get formData() { const {errors} = this.state; const {externalErrors = []} = this.props; const {fields, dirty, valid} = this; return { dirty, valid, errors: [...errors, ...externalErrors], fields, }; } private get dirty() { return this.state.dirtyFields.length > 0; } private get valid() { const {errors, externalErrors} = this.state; return ( !this.hasClientErrors && errors.length === 0 && externalErrors.length === 0 ); } private get hasClientErrors() { const {fields} = this.state; return Object.keys(fields).some(fieldPath => { const field = fields[fieldPath]; return field.error != null; }); } private get fields() { const {fields} = this.state; const fieldDescriptors: FieldDescriptors<Fields> = mapObject( fields, this.fieldWithHandlers, ); return fieldDescriptors; } private submit = async (event?: 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(); if (this.hasClientErrors) { this.setState({submitting: false}); 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}); } }; private fieldWithHandlers = <Key extends keyof Fields>( field: FieldStates<Fields>[Key], fieldPath: Key, ) => { if (this.fieldsWithHandlers.has(field)) { // eslint-disable-next-line typescript/no-non-null-assertion return this.fieldsWithHandlers.get(field)!; } const result = { ...(field as FieldState<Fields[Key]>), name: String(fieldPath), onChange: this.updateField.bind(this, fieldPath), onBlur: this.blurField.bind(this, fieldPath), }; this.fieldsWithHandlers.set(field, result); return result; }; private updateField<Key extends keyof Fields>( fieldPath: Key, value: Fields[Key] | ValueMapper<Fields[Key]>, ) { this.setState<any>(({fields, dirtyFields}: State<Fields>) => { const field = fields[fieldPath]; const newValue = typeof value === 'function' ? (value as ValueMapper<Fields[Key]>)(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 : { // FieldStates<Fields> is not spreadable due to a TS bug // https://github.com/Microsoft/TypeScript/issues/13557 ...(fields as any), [fieldPath]: updatedField, }, }; }); } private getUpdatedDirtyFields<Key extends keyof Fields>({ fieldPath, dirty, dirtyFields, }: { fieldPath: Key; dirty: boolean; dirtyFields: (keyof Fields)[]; }) { 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; } private getUpdatedField<Key extends keyof Fields>({ fieldPath, field, value, dirty, }: { fieldPath: Key; field: FieldStates<Fields>[Key]; value: Fields[Key]; dirty: boolean; }) { // 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 as FieldState<Fields[Key]>), value, dirty, error, }; } private blurField<Key extends keyof Fields>(fieldPath: Key) { const {fields} = this.state; const field = fields[fieldPath]; const error = this.validateFieldValue<Key>(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 as any), [fieldPath]: { ...(state.fields[fieldPath] as FieldState<Fields[Key]>), error, }, }, })); } private validateFieldValue<Key extends keyof Fields>( fieldPath: Key, {value, dirty}: Pick<FieldState<Fields[Key]>, 'value' | 'dirty'>, ) { if (!dirty) { return; } const {validators = {}} = this.props; const {fields} = this.state; // eslint-disable-next-line consistent-return return runValidator(validators[fieldPath], value, fields); } private updateRemoteErrors(errors: RemoteError[]) { this.setState(({fields, externalErrors}) => ({ errors, fields: fieldsWithErrors(fields, [...errors, ...externalErrors]), })); } } function fieldsWithErrors<Fields>( fields: Fields, errors: RemoteError[], ): Fields { const errorDictionary = errors.reduce( (accumulator: any, {field, message}) => { if (field == null) { return accumulator; } return set(accumulator, field, message); }, {}, ); return mapObject(fields, (field, path) => { if (!errorDictionary[path]) { return field; } return { ...field, error: errorDictionary[path], }; }); } function reconcileFormState<Fields>( values: Fields, oldState: State<Fields>, externalErrors: RemoteError[] = [], ): State<Fields> { const {fields: oldFields} = oldState; const dirtyFields = new Set(oldState.dirtyFields); const fields: FieldStates<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 { ...oldState, dirtyFields: Array.from(dirtyFields), fields: fieldsWithErrors(fields, externalErrors), }; } function createFormState<Fields>( values: Fields, externalErrors: RemoteError[] = [], ): State<Fields> { const fields: FieldStates<Fields> = mapObject(values, value => { return { value, initialValue: value, dirty: false, }; }); return { dirtyFields: [], errors: [], submitting: false, externalErrors, fields: fieldsWithErrors(fields, externalErrors), }; } function initialValuesFromFields<Fields>(fields: FieldStates<Fields>): Fields { return mapObject(fields, ({initialValue}) => initialValue); } function runValidator<T, F>( validate: Validator<T, F> = () => {}, value: T, fields: FieldStates<F>, ) { if (typeof validate === 'function') { // eslint-disable-next-line consistent-return return validate(value, fields); } if (!Array.isArray(validate)) { // eslint-disable-next-line consistent-return return; } const errors = validate .map(validator => validator(value, fields)) .filter(input => input != null); if (errors.length === 0) { // eslint-disable-next-line consistent-return return; } // eslint-disable-next-line consistent-return return errors; } function runAllValidators<FieldMap>( state: State<FieldMap>, props: Props<FieldMap>, ) { const {fields} = state; const {validators} = props; if (!validators) { return null; } const updatedFields = mapObject(fields, (field, path) => { return { ...field, error: runValidator(validators[path], field.value, fields), }; }); return { ...state, fields: updatedFields, } as State<FieldMap>; }