UNPKG

formsy-react

Version:

A form input builder and validator for React

538 lines (537 loc) 22.6 kB
import isPlainObject from "lodash/isPlainObject.js"; import react from "react"; import set from "lodash/set.js"; import has from "lodash/has.js"; import get from "lodash/get.js"; function isArray(value) { return Array.isArray(value); } function isObject(value) { return isPlainObject(value); } function isTypeUndefined(value) { return void 0 === value; } function isDate(value) { return value instanceof Date; } function isFunction(value) { return null !== value && 'function' == typeof value; } function isString(value) { return 'string' == typeof value; } function isNumber(value) { return 'number' == typeof value; } function isRegex(value) { return value instanceof RegExp; } function isValueStringEmpty(value) { return '' === value; } function isValueNullOrUndefined(value) { return null == value; } function isValueUndefined(value) { return void 0 === value; } function protectAgainstParamReassignment(value) { if (isObject(value)) return { ...value }; if (isArray(value)) return [ ...value ]; return value; } function isSame(a, b) { if (typeof a !== typeof b) return false; if (isArray(a) && isArray(b)) { if (a.length !== b.length) return false; return a.every((item, index)=>isSame(item, b[index])); } if (isFunction(a) && isFunction(b)) return a.toString() === b.toString(); if (isDate(a) && isDate(b)) return a.toString() === b.toString(); if (isObject(a) && isObject(b)) { if (Object.keys(a).length !== Object.keys(b).length) return false; return Object.keys(a).every((key)=>isSame(a[key], b[key])); } if (isRegex(a) && isRegex(b)) return a.toString() === b.toString(); return a === b; } function runRules(value, currentValues, validations, validationRules) { const results = { errors: [], failed: [], success: [] }; Object.keys(validations).forEach((validationName)=>{ const validationsVal = validations[validationName]; const validationRulesVal = validationRules[validationName]; const addToResults = (validation)=>{ if (isString(validation)) { results.errors.push(validation); results.failed.push(validationName); } else if (validation) results.success.push(validationName); else results.failed.push(validationName); }; if (validationRulesVal && isFunction(validationsVal)) throw new Error(`Formsy does not allow you to override default validations: ${validationName}`); if (!validationRulesVal && !isFunction(validationsVal)) throw new Error(`Formsy does not have the validation rule: ${validationName}`); if (isFunction(validationsVal)) return addToResults(validationsVal(currentValues, value)); return addToResults(validationRulesVal(currentValues, value, validationsVal)); }); return results; } function debounce(callback, timeout) { let timer; return (...args)=>{ clearTimeout(timer); timer = setTimeout(()=>{ callback.apply(this, args); }, timeout); }; } function isExisty(value) { return !isValueNullOrUndefined(value); } function isEmpty(value) { if (isString(value)) return isValueStringEmpty(value); if (isTypeUndefined(value)) return false; return isValueUndefined(value); } function isDefaultRequiredValue(value) { return isString(value) ? isValueStringEmpty(value) : isValueNullOrUndefined(value); } function matchRegexp(_values, value, regexp) { return !isExisty(value) || isEmpty(value) || regexp.test(`${value}`); } const REGEX_PATTERNS = { ALPHA: /^[A-Z]+$/i, ALPHANUMERIC: /^[0-9A-Z]+$/i, EMAIL: /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i, FLOAT: /^(?:[-+]?(?:\d+))?(?:\.\d*)?(?:[eE][+-]?(?:\d+))?$/, INT: /^(?:[-+]?(?:0|[1-9]\d*))$/, NUMERIC: /^[-+]?(?:\d*[.])?\d+$/, SPECIAL_WORDS: /^[\sA-ZÀ-ÖØ-öø-ÿ]+$/i, URL: /^(?:\w+:)?\/\/([^\s.]+\.\S{2}|localhost[:?\d]*)\S*$/i, WORDS: /^[A-Z\s]+$/i }; const validationRules_validationRules = { equals: (_values, value, eql)=>!isExisty(value) || isEmpty(value) || value === eql, equalsField: (values, value, field)=>value === values[field], isAlpha: (values, value)=>matchRegexp(values, value, REGEX_PATTERNS.ALPHA), isAlphanumeric: (values, value)=>matchRegexp(values, value, REGEX_PATTERNS.ALPHANUMERIC), isDefaultRequiredValue: (_values, value)=>isDefaultRequiredValue(value), isEmail: (values, value)=>matchRegexp(values, value, REGEX_PATTERNS.EMAIL), isEmptyString: (_values, value)=>isEmpty(value), isExisty: (_values, value)=>isExisty(value), isFalse: (_values, value)=>false === value, isFloat: (values, value)=>matchRegexp(values, value, REGEX_PATTERNS.FLOAT), isInt: (values, value)=>matchRegexp(values, value, REGEX_PATTERNS.INT), isLength: (_values, value, length)=>!isExisty(value) || isEmpty(value) || value.length === length, isNumeric: (values, value)=>isNumber(value) || matchRegexp(values, value, REGEX_PATTERNS.NUMERIC), isSpecialWords: (values, value)=>matchRegexp(values, value, REGEX_PATTERNS.SPECIAL_WORDS), isTrue: (_values, value)=>true === value, isUndefined: (_values, value)=>isValueUndefined(value), isUrl: (values, value)=>matchRegexp(values, value, REGEX_PATTERNS.URL), isWords: (values, value)=>matchRegexp(values, value, REGEX_PATTERNS.WORDS), matchRegexp: matchRegexp, maxLength: (_values, value, length)=>!isExisty(value) || value.length <= length, minLength: (_values, value, length)=>!isExisty(value) || isEmpty(value) || value.length >= length }; const addValidationRule = (name, func)=>{ validationRules_validationRules[name] = func; }; const noFormsyErrorMessage = 'Could not find Formsy Context Provider. Did you use withFormsy outside <Formsy />?'; const throwNoFormsyProvider = ()=>{ throw new Error(noFormsyErrorMessage); }; const defaultValue = { attachToForm: throwNoFormsyProvider, detachFromForm: throwNoFormsyProvider, isFormDisabled: true, isValidValue: throwNoFormsyProvider, validate: throwNoFormsyProvider, runValidation: throwNoFormsyProvider }; const FormsyContext = react.createContext(defaultValue); const convertValidationsToObject = (validations)=>{ if (isString(validations)) return validations.split(/,(?![^{[]*[}\]])/g).reduce((validationsAccumulator, validation)=>{ let args = validation.split(':'); const validateMethod = args.shift() || ''; args = args.map((arg)=>{ try { return JSON.parse(arg); } catch (_e) { return arg; } }); if (args.length > 1) throw new Error('Formsy does not support multiple args on string validations. Use object format of validations instead.'); const validationsAccumulatorCopy = { ...validationsAccumulator }; validationsAccumulatorCopy[validateMethod] = args.length ? args[0] : true; return validationsAccumulatorCopy; }, {}); return validations || {}; }; function getDisplayName(component) { return component.displayName || component.name || (isString(component) ? component : 'Component'); } function withFormsy(WrappedComponent) { class WithFormsyWrapper extends react.Component { validations; requiredValidations; static displayName = `Formsy(${getDisplayName(WrappedComponent)})`; constructor(props){ super(props); const { runValidation, validations, required, value = WrappedComponent.defaultValue } = props; this.state = { value }; this.setValidations(validations, required); this.state = { formSubmitted: false, isPristine: true, pristineValue: value, value: value, ...runValidation(this, value) }; } componentDidMount() { const { name, attachToForm } = this.props; if (!name) throw new Error('Form Input requires a name property when used'); attachToForm(this); } shouldComponentUpdate(nextProps, nextState) { const { props, state } = this; const isChanged = (a, b)=>Object.keys(a).some((k)=>a[k] !== b[k]); const isPropsChanged = isChanged(props, nextProps); const isStateChanged = isChanged(state, nextState); return isPropsChanged || isStateChanged; } componentDidUpdate(prevProps) { const { value, validations, required, validate } = this.props; if (!isSame(value, prevProps.value)) this.setValue(value); if (!isSame(validations, prevProps.validations) || !isSame(required, prevProps.required)) { this.setValidations(validations, required); validate(this); } } componentWillUnmount() { const { detachFromForm } = this.props; detachFromForm(this); } getErrorMessage = ()=>{ const messages = this.getErrorMessages(); return messages.length ? messages[0] : null; }; getErrorMessages = ()=>{ const { validationError } = this.state; if (!this.isValid() || this.showRequired()) return validationError || []; return []; }; getValue = ()=>this.state.value; setValidations = (validations, required)=>{ this.validations = convertValidationsToObject(validations) || {}; this.requiredValidations = true === required ? { isDefaultRequiredValue: required } : convertValidationsToObject(required); }; setValue = (value, validate = true)=>{ const { validate: validateForm } = this.props; if (validate) this.setState({ value, isPristine: false }, ()=>{ validateForm(this); }); else this.setState({ value }); }; hasValue = ()=>{ const { value } = this.state; return isDefaultRequiredValue(value); }; isFormDisabled = ()=>this.props.isFormDisabled; isFormSubmitted = ()=>this.state.formSubmitted; isPristine = ()=>this.state.isPristine; isRequired = ()=>!!this.props.required; isValid = ()=>this.state.isValid; isValidValue = (value)=>this.props.isValidValue(this, value); resetValue = ()=>{ const { pristineValue } = this.state; const { validate } = this.props; this.setState({ value: pristineValue, isPristine: true }, ()=>{ validate(this); }); }; showError = ()=>!this.showRequired() && !this.isValid(); showRequired = ()=>this.state.isRequired; render() { const { innerRef } = this.props; const propsForElement = { ...this.props, errorMessage: this.getErrorMessage(), errorMessages: this.getErrorMessages(), hasValue: this.hasValue(), isFormDisabled: this.isFormDisabled(), isFormSubmitted: this.isFormSubmitted(), isPristine: this.isPristine(), isRequired: this.isRequired(), isValid: this.isValid(), isValidValue: this.isValidValue, resetValue: this.resetValue, setValidations: this.setValidations, setValue: this.setValue, showError: this.showError(), showRequired: this.showRequired(), value: this.getValue() }; if (innerRef) propsForElement.ref = innerRef; return react.createElement(WrappedComponent, propsForElement); } } return (props)=>react.createElement(FormsyContext.Consumer, null, (contextValue)=>react.createElement(WithFormsyWrapper, { ...props, ...contextValue })); } const ONE_RENDER_FRAME = 66; class Formsy extends react.Component { static displayName = 'Formsy'; inputs; emptyArray; prevInputNames = null; debouncedValidateForm; constructor(props){ super(props); this.state = { canChange: false, isSubmitting: false, isValid: true, contextValue: { attachToForm: this.attachToForm, detachFromForm: this.detachFromForm, isFormDisabled: props.disabled ?? false, isValidValue: this.isValidValue, validate: this.validate, runValidation: this.runValidation } }; this.inputs = []; this.emptyArray = []; this.debouncedValidateForm = debounce(this.validateForm, ONE_RENDER_FRAME); } componentDidMount = ()=>{ this.prevInputNames = this.inputs.map((component)=>component.props.name); this.validateForm(); }; componentDidUpdate = (prevProps)=>{ const { validationErrors, disabled = false } = 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 && !isSame(this.prevInputNames, newInputNames)) { this.prevInputNames = newInputNames; this.validateForm(); } if ((disabled ?? false) !== (prevProps.disabled ?? false)) this.setState((state)=>({ ...state, contextValue: { ...state.contextValue, isFormDisabled: disabled } })); }; getCurrentValues = ()=>Object.fromEntries(this.inputs.map((component)=>{ const { props: { name }, state: { value } } = component; return [ name, protectAgainstParamReassignment(value) ]; })); getModel = ()=>{ const currentValues = this.getCurrentValues(); return this.mapModel(currentValues); }; getPristineValues = ()=>Object.fromEntries(this.inputs.map((component)=>{ const { props: { name, value } } = component; return [ name, protectAgainstParamReassignment(value) ]; })); setFormPristine = (isPristine)=>{ this.setState({ formSubmitted: !isPristine }); this.inputs.forEach((component)=>{ component.setState({ formSubmitted: !isPristine, isPristine }); }); }; setInputValidationErrors = (errors)=>{ const { preventExternalInvalidation = false } = 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); }; setFormValidState = (allIsValid)=>{ this.setState({ isValid: allIsValid }); if (allIsValid) this.props.onValid?.(); else this.props.onInvalid?.(); }; isValidValue = (component, value)=>this.runValidation(component, value).isValid; isFormDisabled = ()=>this.props.disabled ?? false; mapModel = (model)=>{ const { mapping } = this.props; if ('function' == typeof mapping) return mapping(model); const returnModel = {}; Object.keys(model).forEach((key)=>{ set(returnModel, key, model[key]); }); return returnModel; }; reset = (model)=>{ this.setFormPristine(true); this.resetModel(model); }; runValidation = (component, value = component.state.value)=>{ const { validationErrors = {} } = this.props; const { validationError, validationErrors: componentValidationErrors, name } = component.props; const currentValues = this.getCurrentValues(); const validationResults = runRules(value, currentValues, component.validations || {}, validationRules_validationRules); const requiredResults = runRules(value, currentValues, component.requiredValidations || {}, validationRules_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); })() }; }; attachToForm = (component)=>{ if (-1 === this.inputs.indexOf(component)) this.inputs.push(component); const { canChange } = this.state; if (canChange) this.props.onChange?.(this.getModel(), this.isChanged()); this.debouncedValidateForm(); }; detachFromForm = (component)=>{ this.inputs = this.inputs.filter((input)=>input !== component); this.debouncedValidateForm(); }; isChanged = ()=>!isSame(this.getPristineValues(), this.getCurrentValues()); submit = (event)=>{ const { onSubmit, onValidSubmit, onInvalidSubmit, preventDefaultSubmit = true } = this.props; const { isValid } = this.state; if (preventDefaultSubmit && event?.preventDefault) event.preventDefault(); 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); }; updateInputsWithError = (errors, invalidate)=>{ const { preventExternalInvalidation = false } = 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: isString(error) ? [ error ] : error }); }); if (invalidate && isValid) this.setFormValidState(false); }; updateInputsWithValue = (data, validate)=>{ this.inputs.forEach((component)=>{ const { name } = component.props; if (data && has(data, name)) component.setValue(get(data, name), validate); }); }; validate = (component)=>{ const { onChange } = this.props; const { canChange } = this.state; if (canChange) onChange?.(this.getModel(), this.isChanged()); const validationState = this.runValidation(component); component.setState(validationState, this.validateForm); }; validateForm = ()=>{ const onValidationComplete = ()=>{ const allIsValid = this.inputs.every((component)=>component.state.isValid); this.setFormValidState(allIsValid); this.setState({ canChange: true }); }; if (0 === this.inputs.length) onValidationComplete(); else 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); }); }; render() { const { children, mapping, onChange, onInvalid, onInvalidSubmit, onReset, onSubmit, onValid, onValidSubmit, preventDefaultSubmit, preventExternalInvalidation, validationErrors, disabled = false, formElement = 'form', ...nonFormsyProps } = this.props; const { contextValue } = this.state; return react.createElement(FormsyContext.Provider, { value: contextValue }, react.createElement(formElement || 'form', { onReset: this.resetInternal, onSubmit: this.submit, ...nonFormsyProps, disabled }, children)); } resetInternal = (event)=>{ const { onReset } = this.props; event.preventDefault(); this.reset(); onReset?.(); }; resetModel = (data)=>{ this.inputs.forEach((component)=>{ const { name } = component.props; if (data && has(data, name)) component.setValue(get(data, name)); else component.resetValue(); }); this.validateForm(); }; } const src = Formsy; export default src; export { addValidationRule, validationRules_validationRules as validationRules, withFormsy };