react-jsonschema-form-validation
Version:
Simple form validation using JSON Schema and AJV
463 lines (404 loc) • 16.2 kB
JavaScript
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;