UNPKG

react-jsonschema-form-validation

Version:
463 lines (404 loc) 16.2 kB
import _objectWithoutProperties from "@babel/runtime/helpers/esm/objectWithoutProperties"; import _toConsumableArray from "@babel/runtime/helpers/esm/toConsumableArray"; import _objectSpread from "@babel/runtime/helpers/esm/objectSpread2"; import _classCallCheck from "@babel/runtime/helpers/esm/classCallCheck"; import _createClass from "@babel/runtime/helpers/esm/createClass"; import _possibleConstructorReturn from "@babel/runtime/helpers/esm/possibleConstructorReturn"; import _getPrototypeOf from "@babel/runtime/helpers/esm/getPrototypeOf"; import _inherits from "@babel/runtime/helpers/esm/inherits"; /** * @import { * ReactNode, * ElementType, * FormEvent, * FormHTMLAttributes, * ComponentProps, * } from 'react' * @import { DebouncedFunc } from 'lodash' * @import { JSONSchema7Definition } from 'json-schema' * @import { FormattedError, FormChangeEvent, SafePropsOmit } from './helpers' * @import { ErrorMessagesMap } from './Context.types' */ /** * Options controlling how the form scrolls to the first invalid field on * a failed submit. * * @typedef {{ * offset?: number, * align?: 'top' | 'middle' | 'bottom' | (string & {}), * duration?: number, * ease?: string, * }} JfvScrollOptions */ import Ajv from 'ajv'; import classnames from 'classnames'; import throttle from 'lodash.throttle'; import memoize from 'memoize-one'; import React, { PureComponent } from 'react'; import scrollToElement from 'scroll-to-element'; import FormContext from './Context'; import { createAjv, filterByFieldNameWithWildcard, formatData, formatErrors, updateDataFromEvents } from './helpers'; /** * Base props of `<Form>` — the validation-specific props the component * handles itself. The public, polymorphic `FormProps<T, C>` extends this * with the props of the underlying component `C` and typed data `T`. * * @typedef {{ * ajv?: Ajv.Ajv, * children?: ReactNode, * className?: string, * component?: ElementType, * data?: Record<string, unknown>, * throttleDuration?: number, * errorMessages?: ErrorMessagesMap, * onChange?: ((data: Record<string, unknown>, event?: FormChangeEvent) => void) | null, * onSubmit: (event: FormEvent) => void, * schema: JSONSchema7Definition, * scrollToError?: boolean, * scrollOptions?: JfvScrollOptions, * }} FormBaseProps */ /** * Polymorphic props of `<Form>`. Two type parameters: * - `T` — shape of the form data. Inferred from `data` / `onChange`, or * you can pass it explicitly via `<Form<UserData> …>`. Default * `Record<string, unknown>`. Note that `T` is a *promise* by the * caller: nothing at compile time ensures the JSON Schema actually * validates `T`. * - `C` — element type used for the form wrapper (the `component` prop). * Default `'form'`. When supplied, every prop accepted by `C` is * also accepted on `<Form>` (autocomplete + typo detection). * * Field reference: * - `schema` — JSON-Schema used to validate `data`. * - `data` — current form values, fully controlled by the parent. * - `onChange` — called with the updated data on every field change. * - `onSubmit` — called only when validation passes at submit time. * - `errorMessages` — map of error messages shared by every `<FieldError>` * descendant (see `ErrorMessagesMap`). * - `ajv` — optional pre-configured AJV instance, useful to plug * in custom keywords/formats. * - `component` — element rendered for the form wrapper (default `<form noValidate>`). * * @template [T = Record<string, unknown>] * @template {ElementType} [C = 'form'] * @typedef {( * Omit<FormBaseProps, 'component' | 'data' | 'onChange'> * & { * component?: C, * data?: T, * onChange?: ((data: T, event?: FormChangeEvent) => void) | null, * } * & SafePropsOmit<ComponentProps<C>, keyof FormBaseProps | 'ref'> * )} FormProps */ /** * Internal state held by `<Form>`. Exposed (via the context) to descendants * so they can react to validation results and touched fields. * * @typedef {{ * errors: FormattedError[], * isSubmitted: boolean, * touchedFields: string[], * valid: boolean, * }} FormState */ /** * Return type of `lodash.throttle` applied to the form validator. The * `cancel` / `flush` methods (from lodash's `DebouncedFunc<T>` interface) * discard pending runs when the schema changes or the component unmounts. * * @typedef {DebouncedFunc<(data: object) => void>} ThrottledValidator */ /** * Default wrapper element used when no `component` prop is supplied. Renders * a plain `<form noValidate>` so the browser's native validation UI does not * interfere with AJV's. * * @param {FormHTMLAttributes<HTMLFormElement>} props */ var DefaultFormComponent = function DefaultFormComponent(props) { return React.createElement("form", Object.assign({ noValidate: true }, props)); }; // Module-level defaults — shared by every `<Form>` instance via both // `Form.defaultProps` and the destructuring fallbacks in the methods below. // Extracting them here (instead of duplicating the literals) keeps the AJV // instance stable across renders (critical for `memoGetValidator`'s // memoization) and removes the need to cast `undefined`-typed props. var DEFAULT_AJV = createAjv(); var DEFAULT_THROTTLE_DURATION = 200; /** @type {Record<string, unknown>} */ var DEFAULT_DATA = {}; /** @type {FormState} */ var initialState = { errors: [], isSubmitted: false, touchedFields: [], valid: true }; /** @extends {PureComponent<FormBaseProps, FormState>} */ var Form = /*#__PURE__*/ function (_PureComponent) { _inherits(Form, _PureComponent); function Form() { var _getPrototypeOf2; var _this; _classCallCheck(this, Form); for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { args[_key] = arguments[_key]; } _this = _possibleConstructorReturn(this, (_getPrototypeOf2 = _getPrototypeOf(Form)).call.apply(_getPrototypeOf2, [this].concat(args))); _this.state = _objectSpread({}, initialState); _this.memoGetClassnames = memoize(function ( /** @type {string | undefined} */ className, /** @type {boolean} */ isSubmitted) { return classnames('Jfv_Form', className, { isSubmitted: isSubmitted }); }); _this.memoGetContext = memoize(function ( /** @type {FormState} */ state, /** @type {ErrorMessagesMap | undefined} */ errorMessages) { return _objectSpread({}, state, { errorMessages: errorMessages, getFieldErrors: _this.getFieldErrors, handleFieldChange: _this.handleFieldChange, isFieldTouched: _this.isFieldTouched, isFieldInvalid: _this.isFieldInvalid, isTouched: _this.isTouched, touch: _this.touch }); }); _this.memoGetValidator = memoize(function ( /** @type {Ajv.Ajv} */ ajv, /** @type {JSONSchema7Definition} */ schema, /** @type {number} */ throttleDuration) { var validate = ajv.compile(schema); /** @param {object} data */ var validator = function validator(data) { var formattedData = formatData(data); // Cast: AJV's `compile()` return type includes `boolean | Promise<...>` // because async schemas exist. We do not use them, so the result is // always a synchronous boolean here. var valid = /** @type {boolean} */ validate(formattedData); var errors = formatErrors(validate.errors); _this.setState({ valid: valid, errors: errors }); }; if (_this.throttledValidator) _this.throttledValidator.cancel(); _this.throttledValidator = throttle(validator, throttleDuration); // We memoize the throttled function so that two consecutive validations // with the same data reference skip work entirely (AJV is fast but the // no-op short-circuit is even faster). return memoize(_this.throttledValidator); }); _this.getClassnames = function () { var className = _this.props.className; var isSubmitted = _this.state.isSubmitted; return _this.memoGetClassnames(className, isSubmitted); }; _this.getContext = function () { var errorMessages = _this.props.errorMessages; return _this.memoGetContext(_this.state, errorMessages); }; _this.getFieldErrors = function (fieldNames) { var names = Array.isArray(fieldNames) ? fieldNames : [fieldNames]; var errors = _this.state.errors; return names.reduce(function (fieldsErrors, fieldName) { return [].concat(_toConsumableArray(fieldsErrors), _toConsumableArray(filterByFieldNameWithWildcard(errors, fieldName))); }, /** @type {FormattedError[]} */ []); }; _this.getValidator = function () { // Destructuring defaults reference the same module-level constants // declared in `Form.defaultProps`, so `memoGetValidator`'s // memoization stays stable across renders while TS sees the // non-optional types it needs. var _this$props = _this.props, _this$props$ajv = _this$props.ajv, ajv = _this$props$ajv === void 0 ? DEFAULT_AJV : _this$props$ajv, schema = _this$props.schema, _this$props$throttleD = _this$props.throttleDuration, throttleDuration = _this$props$throttleD === void 0 ? DEFAULT_THROTTLE_DURATION : _this$props$throttleD; return _this.memoGetValidator(ajv, schema, throttleDuration); }; _this.handleFieldChange = function (event, value) { var _this$props2 = _this.props, _this$props2$data = _this$props2.data, data = _this$props2$data === void 0 ? DEFAULT_DATA : _this$props2$data, onChange = _this$props2.onChange; if (onChange) { // Cast on `value`: `FormInputTarget.value` is typed as `string` to // mirror real DOM inputs. When the change is synthesized from a // (name, value) pair, `value` can be any JSON-compatible scalar — // the runtime stores it verbatim, the cast satisfies the // structural type without altering behavior. var castValue = /** @type {string} */ value; var realEvent = typeof event === 'string' ? { target: { name: event, value: castValue } } : event; var newData = updateDataFromEvents(data, realEvent); onChange(newData, realEvent); } }; _this.handleSubmit = function (event) { event.preventDefault(); _this.submit(event); }; _this.handleSubmitError = function () { var _process, _process$env; var scrollToError = _this.props.scrollToError; var errors = _this.state.errors; /* istanbul ignore next */ if (typeof process !== 'undefined' && ((_process = process) === null || _process === void 0 ? void 0 : (_process$env = _process.env) === null || _process$env === void 0 ? void 0 : _process$env.REACT_APP_JFV_DEBUG) === 'true') { console.log(errors); // eslint-disable-line no-console } if (scrollToError) _this.scrollToFirstError(); }; _this.handleSubmitSuccess = function (event) { var onSubmit = _this.props.onSubmit; _this.reset(); onSubmit(event); }; _this.isFieldInvalid = function (fieldNames) { var _this2; var names = Array.isArray(fieldNames) ? fieldNames : [fieldNames]; // The spread here only propagates `names[0]` — `getFieldErrors` reads // a single `fieldNames` argument, so any element beyond the first is // ignored at runtime. Behavior preserved as-is; the tuple cast just // makes the spread legal for TS. var tuple = /** @type {[string]} */ names; return (_this2 = _this).getFieldErrors.apply(_this2, _toConsumableArray(tuple)).length > 0; }; _this.isFieldTouched = function (fieldNames) { var names = Array.isArray(fieldNames) ? fieldNames : [fieldNames]; var touchedFields = _this.state.touchedFields; return !!names.find(function (fieldName) { return filterByFieldNameWithWildcard(touchedFields.map(function (field) { return { field: field }; }), fieldName).length > 0; }); }; _this.isTouched = function () { var touchedFields = _this.state.touchedFields; return !!touchedFields.length; }; _this.reset = function () { return _this.setState(initialState); }; _this.scrollToFirstError = function () { var scrollOptions = _this.props.scrollOptions; var errors = _this.state.errors; var firstError = errors[0]; var element = document.getElementsByName(firstError.field)[0]; scrollToElement(element, scrollOptions); }; _this.submit = function (event) { var valid = _this.state.valid; _this.setState({ isSubmitted: true }); if (valid) _this.handleSubmitSuccess(event);else _this.handleSubmitError(); }; _this.touch = function (fieldNames) { var names = Array.isArray(fieldNames) ? fieldNames : [fieldNames]; var touchedFields = _this.state.touchedFields; _this.setState({ touchedFields: _toConsumableArray(new Set([].concat(_toConsumableArray(touchedFields), _toConsumableArray(names)))) }); }; _this.validate = function () { var _this$props$data = _this.props.data, data = _this$props$data === void 0 ? DEFAULT_DATA : _this$props$data; var validate = _this.getValidator(); validate(data); }; return _this; } _createClass(Form, [{ key: "componentDidMount", value: function componentDidMount() { this.validate(); } }, { key: "componentDidUpdate", value: function componentDidUpdate() { this.validate(); } }, { key: "componentWillUnmount", value: function componentWillUnmount() { if (this.throttledValidator) this.throttledValidator.cancel(); } }, { key: "render", value: function render() { var _this$props3 = this.props, ajv = _this$props3.ajv, children = _this$props3.children, className = _this$props3.className, _this$props3$componen = _this$props3.component, FormComponent = _this$props3$componen === void 0 ? DefaultFormComponent : _this$props3$componen, data = _this$props3.data, throttleDuration = _this$props3.throttleDuration, errorMessages = _this$props3.errorMessages, onChange = _this$props3.onChange, onSubmit = _this$props3.onSubmit, schema = _this$props3.schema, scrollOptions = _this$props3.scrollOptions, scrollToError = _this$props3.scrollToError, props = _objectWithoutProperties(_this$props3, ["ajv", "children", "className", "component", "data", "throttleDuration", "errorMessages", "onChange", "onSubmit", "schema", "scrollOptions", "scrollToError"]); return React.createElement(FormContext.Provider, { value: this.getContext() }, React.createElement(FormComponent, Object.assign({ className: this.getClassnames(), onSubmit: this.handleSubmit }, props), children)); } }]); return Form; }(PureComponent); Form.defaultProps = { ajv: DEFAULT_AJV, children: null, className: '', component: DefaultFormComponent, data: DEFAULT_DATA, errorMessages: {}, onChange: null, scrollToError: true, scrollOptions: { offset: 0, align: 'middle', duration: 900 }, throttleDuration: DEFAULT_THROTTLE_DURATION }; // Polymorphic re-typing: the class is non-generic internally (uses // `FormBaseProps`); the cast on the default export restores both generics // (`T` for data shape, `C` for wrapper element) on the public API. export default /** @type {<T = Record<string, unknown>, C extends ElementType = 'form'>( props: FormProps<T, C> ) => JSX.Element | null} */ /** @type {unknown} */ Form;