UNPKG

react-formal

Version:

Classy HTML form management for React

502 lines (500 loc) 16.1 kB
function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; } /* eslint-disable @typescript-eslint/no-use-before-define */ import PropTypes from 'prop-types'; import React, { useEffect, useImperativeHandle, useMemo, useRef, Fragment, useState, useCallback } from 'react'; import shallowequal from 'shallowequal'; import useFormBindingContext, { BindingContext, formGetter, formSetter } from './BindingContext'; import { useUncontrolledProp } from 'uncontrollable'; import { reach, isSchema } from 'yup'; import useEventCallback from '@restart/hooks/useEventCallback'; import useMergeState from '@restart/hooks/useMergeState'; import useMounted from '@restart/hooks/useMounted'; import useTimeout from '@restart/hooks/useTimeout'; import { FormContext } from './Contexts'; import createErrorManager, { isValidationError } from './errorManager'; import * as ErrorUtils from './Errors'; import errToJSON from './utils/errToJSON'; import notify from './utils/notify'; let done = e => setTimeout(() => { throw e; }); function useErrorContext(errors) { const ref = useRef(null); if (!ref.current) { return ref.current = errors != null ? errors : null; } if (!shallowequal(ref.current.errors, errors)) { ref.current = errors != null ? errors : null; } return ref.current; } function validatePath({ path }, _ref) { let { value, schema } = _ref, rest = _objectWithoutPropertiesLoose(_ref, ["value", "schema"]); const validation = path === '' || path === '.' ? schema.validate(value, rest) : schema.validateAt(path, value, rest); return validation.then(() => null).catch(err => err); } const EMPTY_TOUCHED = {}; /** @alias Form */ const _Form = /*#__PURE__*/React.forwardRef((_ref2, ref) => { let { children, defaultValue, value: propValue, onChange: propOnChange, errors: propErrors, onError: propOnError, defaultErrors = ErrorUtils.EMPTY_ERRORS, defaultTouched = EMPTY_TOUCHED, touched: propTouched, onTouch: propOnTouch, schema, submitForm, getter = formGetter, setter = formSetter, delay = 300, debug, noValidate, onValidate, onBeforeSubmit, onSubmit, onSubmitFinished, onInvalidSubmit, onReset, context, stripUnknown, abortEarly, strict = false, as: Element = 'form' } = _ref2, elementProps = _objectWithoutPropertiesLoose(_ref2, ["children", "defaultValue", "value", "onChange", "errors", "onError", "defaultErrors", "defaultTouched", "touched", "onTouch", "schema", "submitForm", "getter", "setter", "delay", "debug", "noValidate", "onValidate", "onBeforeSubmit", "onSubmit", "onSubmitFinished", "onInvalidSubmit", "onReset", "context", "stripUnknown", "abortEarly", "strict", "as"]); const [value, onChange] = useUncontrolledProp(propValue, defaultValue, propOnChange); const [errors, onError] = useUncontrolledProp(propErrors, defaultErrors, propOnError); const [touched, onTouch] = useUncontrolledProp(propTouched, defaultTouched, propOnTouch); const shouldValidate = !!schema && !noValidate; const flushTimeout = useTimeout(); const submitTimeout = useTimeout(); const resetTimeout = useTimeout(); const isMounted = useMounted(); const queueRef = useRef([]); const errorManager = useMemo(() => createErrorManager(validatePath), []); const handleChange = useEventCallback((model, paths) => { let nextTouched = touched; onChange(model, paths); paths.forEach(path => { if (touched && touched[path]) return; if (nextTouched === touched) nextTouched = Object.assign({}, touched, { [path]: true });else nextTouched[path] = true; }); if (nextTouched !== touched) onTouch(nextTouched, paths); }); const getSchemaForPath = useCallback((path, currentValue = value) => schema && path && reach(schema, path, currentValue, context), [value, schema, context]); const formValueContext = useFormBindingContext({ formValue: value, onChange: handleChange, setter, getter, getSchemaForPath }); const yupOptions = { strict, context, stripUnknown, abortEarly: abortEarly == null ? false : abortEarly }; const isSubmittingRef = useRef(false); const [submits, setSubmitState] = useMergeState(() => ({ submitCount: 0, submitAttempts: 0, submitting: false })); const [resets, setResets] = useState(0); function setSubmitting(submitting) { if (!isMounted()) return; isSubmittingRef.current = submitting; setSubmitState({ submitting }); } const errorContext = useErrorContext(errors); const isUpdateRef = useRef(false); useEffect(() => { // don't do this on mount if (!isUpdateRef.current) { isUpdateRef.current = true; return; } if (errors) { enqueue(Object.keys(errors)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [schema]); const flush = () => { flushTimeout.set(() => { let fields = queueRef.current; if (!fields.length) return; queueRef.current = []; errorManager.collect(fields, errors, Object.assign({ schema, value }, yupOptions)).then(nextErrors => { if (nextErrors !== errors) { maybeWarn(debug, errors, 'field validation'); notify(onError, [nextErrors]); } }).catch(done); }, delay); }; useEffect(() => { flush(); }); function enqueue(fields) { queueRef.current.push(...fields); } const handleValidationRequest = (fields, type, args) => { if (!shouldValidate) return; notify(onValidate, [{ type, fields, args }]); enqueue(fields); if (type !== 'onChange') flush(); }; const handleFieldError = (name, fieldErrors) => { handleError(Object.assign(ErrorUtils.remove(errors, name), fieldErrors)); }; const handleError = nextErrors => { notify(onError, [nextErrors]); }; const handleSubmitSuccess = validatedValue => { notify(onError, []); notify(onSubmit, [validatedValue]); return Promise.resolve(submitForm && submitForm(validatedValue)).then(() => { setSubmitting(false); setSubmitState(s => ({ submitCount: s.submitCount + 1, submitAttempts: s.submitAttempts + 1 })); notify(onSubmitFinished); return true; }, err => { setSubmitting(false); notify(onSubmitFinished, [err]); throw err; }); }; const handleSubmitError = err => { if (!isValidationError(err)) throw err; const nextErrors = errToJSON(err); maybeWarn(debug, nextErrors, 'onSubmit'); setSubmitState(s => ({ submitAttempts: s.submitAttempts + 1 })); notify(onError, [nextErrors]); notify(onInvalidSubmit, [nextErrors]); setSubmitting(false); notify(onSubmitFinished, [err]); return false; }; const clearPendingValidations = () => { flushTimeout.clear(); queueRef.current.length = 0; }; const handleSubmit = e => { if (e && e.preventDefault && e.stopPropagation) { e.preventDefault(); e.stopPropagation(); } clearPendingValidations(); submitTimeout.set(() => submit().catch(done)); }; const handleReset = e => { if (e && e.preventDefault && e.stopPropagation) { e.preventDefault(); e.stopPropagation(); } notify(onReset); onChange(defaultValue || {}, []); onError(defaultErrors || ErrorUtils.EMPTY_ERRORS); resetTimeout.set(() => setResets(prevResets => prevResets += 1)); }; const submit = () => { if (isSubmittingRef.current) { return Promise.resolve(false); } clearPendingValidations(); notify(onBeforeSubmit, [{ value, errors }]); setSubmitting(true); return (!shouldValidate ? Promise.resolve(value) : schema.validate(value, Object.assign({}, yupOptions, { abortEarly: false, strict: false })) // no catch, we aren't interested in errors from onSubmit handlers ).then(handleSubmitSuccess, handleSubmitError); }; useImperativeHandle(ref, () => ({ submit, validate(fields) { errorManager.collect(fields, errors, Object.assign({ schema, value }, yupOptions)); } })); const actions = Object.assign(useRef({}).current, { yupContext: context, onSubmit: handleSubmit, onReset: handleReset, onValidate: handleValidationRequest, onFieldError: handleFieldError, formHasValidation: () => shouldValidate }); const contextValue = useMemo(() => ({ touched, actions, errors: errorContext, submits, resets }), [touched, actions, errorContext, submits, resets]); if (Element === 'form') { elementProps.noValidate = true; // disable html5 validation } elementProps.onSubmit = handleSubmit; elementProps.onReset = handleReset; let useChildren = Element == null || Element === false; // if it's a fragment no props if (Element === Fragment || useChildren && React.Children.only(children).type === Fragment) { elementProps = {}; } return /*#__PURE__*/React.createElement(BindingContext.Provider, { value: formValueContext }, /*#__PURE__*/React.createElement(FormContext.Provider, { value: contextValue }, Element == null || Element === false ? /*#__PURE__*/React.cloneElement(React.Children.only(children), elementProps) : /*#__PURE__*/React.createElement(Element, elementProps, children))); }); function maybeWarn(debug, errors, target) { if (!debug) return; if (process.env.NODE_ENV !== 'production') { let keys = Object.keys(errors || ErrorUtils.EMPTY_ERRORS); if (keys.length) { console.error(`[react-formal] (${target}) invalid fields: ${keys.join(', ')}`); } } } _Form.propTypes = { /** * Form value object, can be left [uncontrolled](/controllables); * use the `defaultValue` prop to initialize an uncontrolled form. */ value: PropTypes.object, /** * Callback that is called when the `value` prop changes. * * ```ts static * function ( * value: any, * updatedPaths: string[] * ) * ``` */ onChange: PropTypes.func, /** * An object hash of field errors for the form. The object should be keyed with paths * with the values being an array of errors or message objects. Errors can be * left [uncontrolled](/controllables) (use `defaultErrors` to set an initial value) * or managed along with the `onError` callback. You can use any object shape you'd like for * errors, as long as you provide the Form.Message component an `extract` prop that * understands how to pull out the strings message. By default it understands strings and objects * with a `'message'` property. * * ```jsx static * <Form errors={{ * "name.first": [ * 'First names are required', * { * message: "Names must be at least 2 characters long", * type: 'min' * } * ], * }}/> * ``` */ errors: PropTypes.object, /** * Callback that is called when a validation error occurs. It is called with an `errors` object * * ```jsx renderAsComponent * import Form from '@docs/components/FormWithResult'; * import * as yup from 'yup' * * const schema = yup.object({ * name: yup.string().required().min(15) * }) * * const [errors, setErrors] = useState({}); * * <Form * schema={schema} * errors={errors} * onError={errors => { * if (errors.name) { * errors.name = 'hijacked!' * } * * setErrors(errors) * }}> * <label> * Name * <Form.Field name='name'/> * </label> * <Form.Message for='name' className="error" /> * * <Form.Submit type='submit'>Submit</Form.Submit> * </Form> * ``` */ onError: PropTypes.func, /** An object hash of field paths and whether they have been "touched" yet */ touched: PropTypes.object, /** * Callback that is called when a field is touched. It is called with an `touched` object */ onTouch: PropTypes.func, /** * Callback that is called whenever a validation is triggered. * It is called _before_ the validation is actually run. * * ```js static * function onValidate(event) { * let { type, fields, args } = event * } * ``` */ onValidate: PropTypes.func, /** * Callback that is fired in response to a submit, _before_ validation runs. * * ```js static * function onSubmit(formValue) { * // do something with valid value * } * ``` */ onBeforeSubmit: PropTypes.func, /** * Callback that is fired in response to a submit, after validation runs for the entire form. * * ```js static * function onSubmit(formValue) { * // do something with valid value * } * ``` */ onSubmit: PropTypes.func, /** * Callback that is fired in response to a form reset. `onReset` fires before * the accompanying `onChange`. * * ```js static * function onReset() { * // reset has been called * } * ``` */ onReset: PropTypes.func, onSubmitFinished: PropTypes.func, /* */ submitForm: PropTypes.func, /** * Callback that is fired when the native onSubmit event is triggered. Only relevant when * the `component` prop renders a `<form/>` tag. onInvalidSubmit will trigger only if the form is invalid. * * ```js static * function onInvalidSubmit(errors){ * // do something with errors * } * ``` */ onInvalidSubmit: PropTypes.func, /** * A value getter function. `getter` is called with `path` and `value` and * should return the plain **javascript** value at the path. * * ```ts static * function( * path: string, * value: any, * ): Object * ``` */ getter: PropTypes.func, /** * A value setter function. `setter` is called with `path`, the form `value` and the path `value`. * The `setter` must return updated form `value`, which allows you to leave the original value unmutated. * * The default implementation uses the [react immutability helpers](http://facebook.github.io/react/docs/update.html), * letting you treat the form `value` as immutable. * * ```ts static * function( * path: string, * formValue: any, * pathValue: any * ): Object * ``` */ setter: PropTypes.func, /** * Time in milliseconds that validations should be debounced. Reduces the amount of validation calls * made at the expense of a slight delay. Helpful for performance. */ delay: PropTypes.number, /** * Validations will be strict, making no attempt to coarce input values to the appropriate type. */ strict: PropTypes.bool, /** * Turns off input validation for the Form, value updates will continue to work. */ noValidate: PropTypes.bool, /** * A tag name or Component class the Form should render. * * If `null` are `false` the form will simply render it's child. In * this instance there must only be one child. */ as: PropTypes.oneOfType([PropTypes.elementType, PropTypes.oneOf([null, false])]), /** * A Yup schema that validates the Form `value` prop. Used to validate the form input values * For more information about the yup api check out: https://github.com/jquense/yup/blob/master/README.md * @type {Schema} */ schema(props, name, componentName) { let err = null; if (props[name]) { if (!isSchema(props[name])) err = new Error('`schema` must be a proper yup schema: (' + componentName + ')'); } return err; }, /** * yup schema context */ context: PropTypes.object, /** * toggle debug mode, which `console.warn`s validation errors */ debug: PropTypes.bool }; _Form.displayName = 'Form'; export default _Form; export { formGetter as getter, formSetter as setter };