react-formal
Version:
Classy HTML form management for React
502 lines (500 loc) • 16.1 kB
JavaScript
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 };