UNPKG

formsy-react

Version:

A form input builder and validator for React

500 lines (430 loc) 15.2 kB
/* eslint-disable react/no-unused-state, react/default-props-match-prop-types */ import { get, has, set } from 'lodash'; import PropTypes from 'prop-types'; import React from 'react'; import FormsyContext from './FormsyContext'; import { FormsyContextInterface, IModel, InputComponent, IResetModel, IUpdateInputsWithError, IUpdateInputsWithValue, ValidationError, } from './interfaces'; import { debounce, isObject, isString } from './utils'; import * as utils from './utils'; import validationRules from './validationRules'; import { PassDownProps } from './withFormsy'; type FormHTMLAttributesCleaned = Omit<React.FormHTMLAttributes<HTMLFormElement>, 'onChange' | 'onSubmit'>; type OnSubmitCallback = ( model: IModel, resetModel: IResetModel, updateInputsWithError: IUpdateInputsWithError, event: React.SyntheticEvent<HTMLFormElement>, ) => void; type FormElementType = | string | React.ComponentType<{ onReset?: (e: React.SyntheticEvent) => void; onSubmit?: (e: React.SyntheticEvent) => void; disabled?: boolean; children?: React.ReactChildren; }>; export interface FormsyProps extends FormHTMLAttributesCleaned { disabled: boolean; mapping: null | ((model: IModel) => IModel); onChange: (model: IModel, isChanged: boolean) => void; onInvalid: () => void; onReset?: () => void; onSubmit?: OnSubmitCallback; onValidSubmit?: OnSubmitCallback; onInvalidSubmit: OnSubmitCallback; onValid: () => void; preventDefaultSubmit?: boolean; preventExternalInvalidation?: boolean; validationErrors?: null | object; formElement?: FormElementType; } export interface FormsyState { canChange: boolean; contextValue: FormsyContextInterface; formSubmitted?: boolean; isPristine?: boolean; isSubmitting: boolean; isValid: boolean; } const ONE_RENDER_FRAME = 66; export class Formsy extends React.Component<FormsyProps, FormsyState> { public inputs: InstanceType<any & PassDownProps<any>>[]; public emptyArray: any[]; public prevInputNames: any[] | null = null; public static displayName = 'Formsy'; public static propTypes = { disabled: PropTypes.bool, mapping: PropTypes.func, formElement: PropTypes.oneOfType([PropTypes.string, PropTypes.object, PropTypes.func]), onChange: PropTypes.func, onInvalid: PropTypes.func, onInvalidSubmit: PropTypes.func, onReset: PropTypes.func, onSubmit: PropTypes.func, onValid: PropTypes.func, onValidSubmit: PropTypes.func, preventDefaultSubmit: PropTypes.bool, preventExternalInvalidation: PropTypes.bool, validationErrors: PropTypes.object, // eslint-disable-line }; public static defaultProps: Partial<FormsyProps> = { disabled: false, mapping: null, onChange: utils.noop, onInvalid: utils.noop, onInvalidSubmit: utils.noop, onReset: utils.noop, onSubmit: utils.noop, onValid: utils.noop, onValidSubmit: utils.noop, preventDefaultSubmit: true, preventExternalInvalidation: false, validationErrors: {}, formElement: 'form', }; private readonly debouncedValidateForm: () => void; public constructor(props: FormsyProps) { super(props); this.state = { canChange: false, isSubmitting: false, isValid: true, contextValue: { attachToForm: this.attachToForm, detachFromForm: this.detachFromForm, isFormDisabled: props.disabled, isValidValue: this.isValidValue, validate: this.validate, runValidation: this.runValidation, }, }; this.inputs = []; this.emptyArray = []; this.debouncedValidateForm = debounce(this.validateForm, ONE_RENDER_FRAME); } public componentDidMount = () => { this.prevInputNames = this.inputs.map((component) => component.props.name); this.validateForm(); }; public componentDidUpdate = (prevProps: FormsyProps) => { const { validationErrors, disabled } = this.props; if (validationErrors && isObject(validationErrors) && Object.keys(validationErrors).length > 0) { this.setInputValidationErrors(validationErrors); } const newInputNames = this.inputs.map((component) => component.props.name); if (this.prevInputNames && !utils.isSame(this.prevInputNames, newInputNames)) { this.prevInputNames = newInputNames; this.validateForm(); } // Keep the disabled value in state/context the same as from props if (disabled !== prevProps.disabled) { // eslint-disable-next-line this.setState((state) => ({ ...state, contextValue: { ...state.contextValue, isFormDisabled: disabled, }, })); } }; public getCurrentValues = () => this.inputs.reduce((valueAccumulator, component) => { const { props: { name }, state: { value }, } = component; // eslint-disable-next-line no-param-reassign valueAccumulator[name] = utils.protectAgainstParamReassignment(value); return valueAccumulator; }, {}); public getModel = () => { const currentValues = this.getCurrentValues(); return this.mapModel(currentValues); }; public getPristineValues = () => this.inputs.reduce((valueAccumulator, component) => { const { props: { name, value }, } = component; // eslint-disable-next-line no-param-reassign valueAccumulator[name] = utils.protectAgainstParamReassignment(value); return valueAccumulator; }, {}); public setFormPristine = (isPristine: boolean) => { this.setState({ formSubmitted: !isPristine, }); // Iterate through each component and set it as pristine // or "dirty". this.inputs.forEach((component) => { component.setState({ formSubmitted: !isPristine, isPristine, }); }); }; public setInputValidationErrors = (errors) => { const { preventExternalInvalidation } = this.props; const { isValid } = this.state; this.inputs.forEach((component) => { const { name } = component.props; component.setState({ isValid: !(name in errors), validationError: isString(errors[name]) ? [errors[name]] : errors[name], }); }); if (!preventExternalInvalidation && isValid) { this.setFormValidState(false); } }; public setFormValidState = (allIsValid: boolean) => { const { onValid, onInvalid } = this.props; this.setState({ isValid: allIsValid, }); if (allIsValid) { onValid(); } else { onInvalid(); } }; public isValidValue = (component, value) => this.runValidation(component, value).isValid; // eslint-disable-next-line react/destructuring-assignment public isFormDisabled = () => this.props.disabled; public mapModel = (model: IModel): IModel => { const { mapping } = this.props; if (mapping) { return mapping(model); } const returnModel = {}; Object.keys(model).forEach((key) => { set(returnModel, key, model[key]); }); return returnModel; }; public reset = (model?: IModel) => { this.setFormPristine(true); this.resetModel(model); }; private resetInternal = (event) => { const { onReset } = this.props; event.preventDefault(); this.reset(); if (onReset) { onReset(); } }; // Reset each key in the model to the original / initial / specified value private resetModel: IResetModel = (data) => { this.inputs.forEach((component) => { const { name } = component.props; if (data && has(data, name)) { component.setValue(get(data, name)); } else { component.resetValue(); } }); this.validateForm(); }; // Checks validation on current value or a passed value public runValidation = <V>( component: InputComponent<V>, value = component.state.value, ): { isRequired: boolean; isValid: boolean; validationError: ValidationError[] } => { const { validationErrors } = this.props; const { validationError, validationErrors: componentValidationErrors, name } = component.props; const currentValues = this.getCurrentValues(); const validationResults = utils.runRules(value, currentValues, component.validations, validationRules); const requiredResults = utils.runRules(value, currentValues, component.requiredValidations, validationRules); const isRequired = Object.keys(component.requiredValidations).length ? !!requiredResults.success.length : false; const isValid = !validationResults.failed.length && !(validationErrors && validationErrors[component.props.name]); return { isRequired, isValid: isRequired ? false : isValid, validationError: (() => { if (isValid && !isRequired) { return this.emptyArray; } if (validationResults.errors.length) { return validationResults.errors; } if (validationErrors && validationErrors[name]) { return isString(validationErrors[name]) ? [validationErrors[name]] : validationErrors[name]; } if (isRequired) { const error = componentValidationErrors[requiredResults.success[0]] || validationError; return error ? [error] : null; } if (validationResults.failed.length) { return validationResults.failed .map((failed) => (componentValidationErrors[failed] ? componentValidationErrors[failed] : validationError)) .filter((x, pos, arr) => arr.indexOf(x) === pos); // remove duplicates } // This line is not reachable // istanbul ignore next return undefined; })(), }; }; // Method put on each input component to register // itself to the form public attachToForm = (component) => { if (this.inputs.indexOf(component) === -1) { this.inputs.push(component); } const { onChange } = this.props; const { canChange } = this.state; // Trigger onChange if (canChange) { onChange(this.getModel(), this.isChanged()); } this.debouncedValidateForm(); }; // Method put on each input component to unregister // itself from the form public detachFromForm = <V>(component: InputComponent<V>) => { this.inputs = this.inputs.filter((input) => input !== component); this.debouncedValidateForm(); }; // Checks if the values have changed from their initial value public isChanged = () => !utils.isSame(this.getPristineValues(), this.getCurrentValues()); // Update model, submit to url prop and send the model public submit = (event?: React.SyntheticEvent<HTMLFormElement>) => { const { onSubmit, onValidSubmit, onInvalidSubmit, preventDefaultSubmit } = this.props; const { isValid } = this.state; if (preventDefaultSubmit && event && event.preventDefault) { event.preventDefault(); } // Trigger form as not pristine. // If any inputs have not been touched yet this will make them dirty // so validation becomes visible (if based on isPristine) this.setFormPristine(false); const model = this.getModel(); onSubmit(model, this.resetModel, this.updateInputsWithError, event); if (isValid) { onValidSubmit(model, this.resetModel, this.updateInputsWithError, event); } else { onInvalidSubmit(model, this.resetModel, this.updateInputsWithError, event); } }; // Go through errors from server and grab the components // stored in the inputs map. Change their state to invalid // and set the serverError message public updateInputsWithError: IUpdateInputsWithError = (errors, invalidate) => { const { preventExternalInvalidation } = this.props; const { isValid } = this.state; Object.entries(errors).forEach(([name, error]) => { const component = this.inputs.find((input) => input.props.name === name); if (!component) { throw new Error( `You are trying to update an input that does not exist. Verify errors object with input names. ${JSON.stringify( errors, )}`, ); } component.setState({ isValid: preventExternalInvalidation, validationError: utils.isString(error) ? [error] : error, }); }); if (invalidate && isValid) { this.setFormValidState(false); } }; // Set the value of components public updateInputsWithValue: IUpdateInputsWithValue<any> = (data, validate) => { this.inputs.forEach((component) => { const { name } = component.props; if (data && has(data, name)) { component.setValue(get(data, name), validate); } }); }; // Use the binded values and the actual input value to // validate the input and set its state. Then check the // state of the form itself public validate = <V>(component: InputComponent<V>) => { const { onChange } = this.props; const { canChange } = this.state; // Trigger onChange if (canChange) { onChange(this.getModel(), this.isChanged()); } const validationState = this.runValidation<V>(component); // Run through the validations, split them up and call // the validator IF there is a value or it is required component.setState(validationState, this.validateForm); }; // Validate the form by going through all child input components // and check their state public validateForm = () => { // We need a callback as we are validating all inputs again. This will // run when the last input has set its state const onValidationComplete = () => { const allIsValid = this.inputs.every((component) => component.state.isValid); this.setFormValidState(allIsValid); // Tell the form that it can start to trigger change events this.setState({ canChange: true, }); }; if (this.inputs.length === 0) { onValidationComplete(); } else { // Run validation again in case affected by other inputs. The // last component validated will run the onValidationComplete callback this.inputs.forEach((component, index) => { const validationState = this.runValidation(component); const isLastInput = index === this.inputs.length - 1; const callback = isLastInput ? onValidationComplete : null; component.setState(validationState, callback); }); } }; public render() { const { /* eslint-disable @typescript-eslint/no-unused-vars */ children, mapping, onChange, onInvalid, onInvalidSubmit, onReset, onSubmit, onValid, onValidSubmit, preventDefaultSubmit, preventExternalInvalidation, validationErrors, disabled, formElement, ...nonFormsyProps } = this.props; const { contextValue } = this.state; return React.createElement( FormsyContext.Provider, { value: contextValue, }, React.createElement( formElement, { onReset: this.resetInternal, onSubmit: this.submit, ...nonFormsyProps, disabled, }, children, ), ); } }