react-jsonschema-form-validation
Version:
Simple form validation using JSON Schema and AJV
292 lines (263 loc) • 9.67 kB
JavaScript
/**
* @import { ErrorObject } from 'ajv'
*/
import Ajv from 'ajv';
import immutable from 'dot-prop-immutable';
/**
* An AJV `ErrorObject` enriched with a `field` property — a normalized,
* dot-separated path to the offending field (e.g. `'user.email'` or
* `'items.0.label'`). Used everywhere internally to locate the input
* associated with an error.
*
* @typedef {ErrorObject & { field: string }} FormattedError
*/
/**
* Minimal shape required by the library on an input target. Structurally
* compatible with `HTMLInputElement`, `HTMLTextAreaElement`, `HTMLSelectElement`
* and any custom object that mimics them (e.g. when an integration like
* `react-select` synthesizes its own events). Only the properties actually
* read by the library appear here.
*
* @typedef {{
* name: string,
* value: string,
* type?: string,
* checked?: boolean,
* files?: FileList | null,
* multiple?: boolean,
* }} FormInputTarget
*/
/**
* Lightweight change event accepted by the form. Any DOM `ChangeEvent`
* or `InputEvent` is structurally compatible with this shape, so users can
* forward native React events without casting.
*
* @typedef {{ target: FormInputTarget }} FormChangeEvent
*/
/**
* Internal `Omit` variant that keeps typed keys strict even when `T` has an
* index signature (like `[key: string]: any` — reactstrap and a few other
* libraries expose these). Standard `Omit<T, K>` on such a type collapses
* the typed keys back into `any` through the index, which effectively kills
* the polymorphic type-checking on `<Field component={X} .../>` etc.
*
* This variant:
* 1. Strips the index signature so `Omit` can operate on named keys only,
* keeping their precise types.
* 2. Only re-attaches the original index signature (with its original
* value type) *if T actually had one to begin with*. The `string
* extends keyof T` guard prevents accidentally re-injecting an index
* signature on plain "record-like" types whose values happen to share
* a common supertype (e.g. `{ label: string; flavor: 'a' | 'b' }`
* structurally extends `{ [k: string]: string }` but does not really
* accept arbitrary keys).
*
* @template T
* @template {PropertyKey} K
* @typedef {(
* Omit<{ [P in keyof T as string extends P ? never : P]: T[P] }, K>
* & (string extends keyof T
* ? (T extends { [k: string]: infer V } ? { [k: string]: V } : {})
* : {})
* )} SafePropsOmit
*/
/**
* Returns a default AJV instance configured for use with the form.
*
* @returns {Ajv.Ajv}
*/
export var createAjv = function createAjv() {
return new Ajv( // Cast: the `v5` option no longer exists in AJV 6+ typings (it was a
// flag from AJV 3/4 that enabled draft-05 features, which became the
// default later). The runtime silently ignores unknown options. Kept
// here verbatim from the historical code so the lib stays byte-for-byte
// equivalent (notably for the Form snapshot test that captures AJV's
// internal `_opts` / `_metaOpts` state).
/** @type {Ajv.Options} */
{
allErrors: true,
v5: true,
$data: true
});
};
/**
* Normalizes empty form values. Returns `undefined` for `''` and `null`
* so that AJV's `required` keyword treats them as missing (which is what
* a form usually expects), and returns the value untouched otherwise.
*
* @template T
* @param {T} value
* @returns {T | undefined}
*/
export var empty = function empty(value) {
// Cast: TS strict refuses `value === ''` / `=== null` when `value` is a
// generic `T` and the literal isn't part of T's domain. We compare via
// an `unknown` view of the same reference; the runtime is unchanged.
var v =
/** @type {unknown} */
value;
return v === '' || v === null ? undefined : value;
};
/**
* Recursively walks `data` and applies `empty()` to every leaf value.
* Returns a new object/array if anything changes, never mutates the input.
* Primitives equal to `''` or `null` collapse to `undefined`, hence the
* `T | undefined` return type.
*
* @template T
* @param {T} data
* @returns {T | undefined}
*/
export var formatData = function formatData(data) {
if (Array.isArray(data)) {
// Cast: `data.map(formatData)` is structurally an array of the same
// item type as `data` itself, but TS can't track that through the
// generic boundary — the runtime is unchanged.
return (
/** @type {T} */
data.map(formatData)
);
}
if (data !== null && typeof data === 'object') {
/** @type {Record<string, unknown>} */
var copy = {}; // Do not modify original object !
// Cast: we just narrowed `data` to a non-null object, but TS still
// sees it as the generic `T`. The cast lets us iterate inherited
// keys via `for...in` (intentional for host objects like `File`
// whose `name` lives on the prototype chain).
var obj =
/** @type {Record<string, unknown>} */
data; // eslint-disable-next-line no-restricted-syntax, guard-for-in
for (var key in obj) {
copy[key] = formatData(obj[key]);
}
return (
/** @type {T} */
copy
);
}
return empty(data);
};
/**
* Enriches each AJV error with a normalized `field` path. The transformation
* is in-place — it mutates the original AJV `ErrorObject`, which is consistent
* with the library's existing behavior (errors come from AJV and are
* disposable between two runs).
*
* @param {ErrorObject[] | null | undefined} errors
* @returns {FormattedError[]}
*/
export var formatErrors = function formatErrors(errors) {
return (errors || []).map(function (error) {
var formatted =
/** @type {FormattedError} */
error;
formatted.field = formatted.dataPath;
if (formatted.keyword === 'required' && 'missingProperty' in formatted.params) {
// AJV's `required` errors carry the missing key in `params.missingProperty`.
formatted.field = "".concat(formatted.field, ".").concat(formatted.params.missingProperty);
}
formatted.field = formatted.field.replace(/^\./, '').replace(/\[([0-9]+)\]/, '.$1');
return formatted;
});
};
/**
* Filters an array of items by their `field` property. The expression can be:
* - a literal name (e.g. `'email'`) — exact match
* - or a prefix ending with `*` (e.g. `'user.*'`) — startsWith match
*
* @template {{ field?: string }} T
* @param {T[]} fields
* @param {string} fieldName
* @returns {T[]}
*/
export var filterByFieldNameWithWildcard = function filterByFieldNameWithWildcard(fields, fieldName) {
/** @type {RegExp | undefined} */
var regex;
if (/\*$/.test(fieldName)) {
regex = new RegExp("^".concat(fieldName.replace(/\*$/, '')));
}
return fields.filter(function (e) {
if (regex) {
// `e.field !== undefined` short-circuits the test() call.
// In practice the lib never feeds `undefined` here (errors and
// touchedFields always carry a string field).
return e.field !== undefined && regex.test(e.field);
}
return e.field === fieldName;
});
};
/**
* Reads the checkbox state. If a checkbox is used to build an array of
* selected values, you must supply a custom `onChange` to handle it.
*
* @param {FormInputTarget} target
* @returns {boolean | undefined}
*/
export var getInputCheckboxValue = function getInputCheckboxValue(target) {
return target.checked;
};
/**
* Reads a file, a list of files (when `multiple`) or the raw value when
* the input is empty.
*
* @param {FormInputTarget} target
* @returns {string | File | File[]}
*/
export var getInputFileValue = function getInputFileValue(target) {
if (target.value === '') return target.value; // Cast on `target`: TS sees `target.files` as `FileList | null`, but the
// original code accesses it unconditionally — on a real
// `<input type="file">` the browser guarantees it is a `FileList`.
// Preserved as-is for exact runtime parity.
var files =
/** @type {{ files: FileList; multiple?: boolean }} */
target.files;
return target.multiple ? Array.from(files) : files[0];
};
/**
* Reads a number from a numeric input. Returns the empty string when the
* input is empty so the JSON Schema `required` keyword can flag it.
*
* @param {FormInputTarget} target
* @returns {number | ''}
*/
export var getInputNumberValue = function getInputNumberValue(target) {
return target.value !== '' ? +target.value : '';
};
/**
* Returns a value from any type of input (text, checkbox, file...)
*
* @param {FormInputTarget} target - A target object from an event (ex: change)
* @returns {unknown}
*/
export var getFieldValue = function getFieldValue(target) {
switch (target.type) {
case 'number':
return getInputNumberValue(target);
case 'checkbox':
return getInputCheckboxValue(target);
case 'file':
return getInputFileValue(target);
default:
return target.value;
}
};
/**
* Returns a copy of `data` with the field paths described by `events` updated
* to their new values. Uses `dot-prop-immutable` so that only the modified
* branches of the object tree get new references; untouched siblings keep
* their identity (useful for `React.memo` / `PureComponent`).
*
* @template {object} T
* @param {T} data
* @param {FormChangeEvent | FormChangeEvent[]} [events]
* @returns {T}
*/
export var updateDataFromEvents = function updateDataFromEvents(data, events) {
if (!events) return data;
var eventsArray = Array.isArray(events) ? events : [events];
eventsArray.forEach(function (event) {
data = immutable.set(data, event.target.name, getFieldValue(event.target));
});
return data;
};