diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc8e0f..5a52cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,14 @@ and dependency-only bumps are omitted. ### Added +- Error formatting now understands both AJV error shapes: the AJV 8+ + `instancePath` (JSON Pointer, RFC 6901 — including `~0`/`~1` escape + decoding) in addition to the AJV 6 `dataPath` used by the bundled + validator. Behavior with the bundled AJV 6 is strictly unchanged; + this prepares the AJV 8 upgrade and custom validators that emit + JSON Pointer paths. The conversion lives in a new internal + `pointerToFieldPath(pointer)` helper (groundwork for AJV 8; not + part of the public API). - `resetOnSubmit` prop on `
` — set it to `false` to keep the touched/submitted state after a successful submit (useful when the server-side submit can still fail), then call the context's `reset()` diff --git a/src/lib/Form/helpers.js b/src/lib/Form/helpers.js index ada90a9..41d9026 100644 --- a/src/lib/Form/helpers.js +++ b/src/lib/Form/helpers.js @@ -11,7 +11,11 @@ import immutable from 'dot-prop-immutable'; * `'items.0.label'`). Used everywhere internally to locate the input * associated with an error. * - * @typedef {ErrorObject & { field: string }} FormattedError + * `instancePath` is declared here because the bundled AJV 6 typings only + * know `dataPath`; AJV 8 errors carry `instancePath` instead, and + * {@link formatErrors} accepts both shapes. + * + * @typedef {ErrorObject & { instancePath?: string, field: string }} FormattedError */ /** @@ -139,28 +143,74 @@ export const formatData = (data) => { return empty(data); }; +/** + * Decodes a single JSON Pointer reference token (RFC 6901 §4). + * + * The order of the two replacements is mandated by the RFC: `~1` must be + * decoded before `~0`, otherwise the escaped sequence `~01` (which encodes + * the literal string `~1`) would first collapse to `~` + `1` and then be + * wrongly re-decoded as `/`. + * + * @param {string} segment + * @returns {string} + */ +const unescapePointerSegment = (segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~'); + +/** + * Converts a JSON Pointer (RFC 6901, the shape of AJV 8's + * `error.instancePath`, e.g. `'/items/0/label'`) into the library's + * dot-separated field path (`'items.0.label'`). The root pointer `''` + * maps to the empty field path `''`. + * + * @param {string} pointer + * @returns {string} + */ +export const pointerToFieldPath = (pointer) => pointer + .split('/') + .slice(1) + .map(unescapePointerSegment) + .join('.'); + /** * 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). * + * Accepts both AJV error shapes: when `error.instancePath` is present + * (a JSON Pointer, AJV 8+) it takes precedence; otherwise the legacy + * `error.dataPath` (dot/bracket notation, AJV ≤ 6) is used. The library + * currently bundles AJV 6, so the `dataPath` branch is the active one — + * the dual-shape support prepares the AJV 8 upgrade and is a building + * block for a future Standard Schema adapter. + * * @param {ErrorObject[] | null | undefined} errors * @returns {FormattedError[]} */ export const formatErrors = (errors) => (errors || []).map((error) => { const formatted = /** @type {FormattedError} */ (error); - formatted.field = formatted.dataPath; + + if (typeof formatted.instancePath === 'string') { + // AJV 8 shape: JSON Pointer. + formatted.field = pointerToFieldPath(formatted.instancePath); + } else { + // AJV 6 shape: dot notation with bracketed array indexes. A + // degenerate error carrying neither `instancePath` nor `dataPath` + // is treated as pointing at the root instead of crashing. + formatted.field = (formatted.dataPath ?? '') + .replace(/^\./, '') + .replace(/\[([0-9]+)\]/g, '.$1'); + } if (formatted.keyword === 'required' && 'missingProperty' in formatted.params) { // AJV's `required` errors carry the missing key in `params.missingProperty`. - formatted.field = `${formatted.field}.${formatted.params.missingProperty}`; + // At the root the field path is empty: the missing key alone is the path + // (no leading dot to strip since normalization already happened above). + formatted.field = formatted.field + ? `${formatted.field}.${formatted.params.missingProperty}` + : formatted.params.missingProperty; } - formatted.field = formatted.field - .replace(/^\./, '') - .replace(/\[([0-9]+)\]/g, '.$1'); - return formatted; }); diff --git a/src/lib/Form/helpers.test.js b/src/lib/Form/helpers.test.js index 721ff56..c2625fa 100644 --- a/src/lib/Form/helpers.test.js +++ b/src/lib/Form/helpers.test.js @@ -86,6 +86,126 @@ describe('.formatErrors(errors)', () => { const errors = [{ dataPath: 'items[0].tags[1]' }]; expect(helpers.formatErrors(errors)[0].field).toStrictEqual('items.0.tags.1'); }); + + it('should treat an error with neither instancePath nor dataPath as pointing at the root', () => { + // Degenerate input (no AJV version produces it), but it must not + // crash: the missing path is treated as the root path ''. + const errors = [{ + keyword: 'required', + params: { + missingProperty: 'x', + }, + }]; + + expect(() => helpers.formatErrors(errors)).not.toThrow(); + expect(helpers.formatErrors(errors)[0].field).toStrictEqual('x'); + }); + + // AJV 8 shape: `instancePath` is a JSON Pointer (RFC 6901). The cases + // below mirror the `dataPath` set above, proving both shapes produce + // the same `field` values. + describe('with AJV 8 instancePath (JSON Pointer)', () => { + it('should add a property field to each errors', () => { + const errors = [{ instancePath: '/input' }]; + expect(helpers.formatErrors(errors)[0]).toHaveProperty('field', 'input'); + }); + + it('should add a property field for required errors', () => { + let errors = [{ + instancePath: '/nested', + keyword: 'required', + params: { + missingProperty: 'input', + }, + }]; + + expect(helpers.formatErrors(errors)[0].field).toStrictEqual('nested.input'); + + errors = [{ + instancePath: '', + keyword: 'required', + params: { + missingProperty: 'input', + }, + }]; + + expect(helpers.formatErrors(errors)[0].field).toStrictEqual('input'); + }); + + it('should convert pointer array segments to dot notation', () => { + const errors = [{ instancePath: '/arr/0/input' }]; + expect(helpers.formatErrors(errors)[0].field).toStrictEqual('arr.0.input'); + }); + + it('should convert every array index when the pointer contains several', () => { + const errors = [{ instancePath: '/items/0/tags/1' }]; + expect(helpers.formatErrors(errors)[0].field).toStrictEqual('items.0.tags.1'); + }); + + it('should map the root pointer to an empty field', () => { + const errors = [{ instancePath: '' }]; + expect(helpers.formatErrors(errors)[0].field).toStrictEqual(''); + }); + + it('should handle required errors inside an array item', () => { + const errors = [{ + instancePath: '/list/0', + keyword: 'required', + params: { + missingProperty: 'x', + }, + }]; + + expect(helpers.formatErrors(errors)[0].field).toStrictEqual('list.0.x'); + }); + + it('should decode RFC 6901 escape sequences in pointer segments', () => { + // `~1` encodes `/`, `~0` encodes `~`. + expect(helpers.formatErrors([{ instancePath: '/a~1b' }])[0].field) + .toStrictEqual('a/b'); + expect(helpers.formatErrors([{ instancePath: '/a~0b' }])[0].field) + .toStrictEqual('a~b'); + }); + + it('should decode ~1 before ~0 so that ~01 yields a literal ~1', () => { + // `~01` encodes the literal string `~1`; decoding `~0` first + // would wrongly produce `/`. + expect(helpers.formatErrors([{ instancePath: '/a~01b' }])[0].field) + .toStrictEqual('a~1b'); + }); + }); +}); + +describe('.pointerToFieldPath(pointer)', () => { + it('should convert a JSON Pointer to a dot-separated field path', () => { + expect(helpers.pointerToFieldPath('/input')).toStrictEqual('input'); + expect(helpers.pointerToFieldPath('/arr/0/input')).toStrictEqual('arr.0.input'); + expect(helpers.pointerToFieldPath('/items/0/tags/1')).toStrictEqual('items.0.tags.1'); + }); + + it('should map the root pointer to the empty string', () => { + expect(helpers.pointerToFieldPath('')).toStrictEqual(''); + }); + + it('should map the lone-slash pointer to the empty string', () => { + // '/' is the pointer to the property whose key is the empty string + // (RFC 6901). Its field path collides with the root pointer '' — + // a documented, accepted limitation of the dot-path representation + // (an empty-string key is not addressable in dot notation anyway). + expect(helpers.pointerToFieldPath('/')).toStrictEqual(''); + }); + + it('should preserve empty segments in the middle of a pointer', () => { + // '/a//b' addresses data.a[''].b — empty segments are kept, which + // surfaces as consecutive dots in the field path. + expect(helpers.pointerToFieldPath('/a//b')).toStrictEqual('a..b'); + }); + + it('should decode RFC 6901 escape sequences', () => { + expect(helpers.pointerToFieldPath('/a~1b')).toStrictEqual('a/b'); + expect(helpers.pointerToFieldPath('/a~0b')).toStrictEqual('a~b'); + expect(helpers.pointerToFieldPath('/a~01b')).toStrictEqual('a~1b'); + }); }); describe('.filterByFieldNameWithWildcard(fields, fieldName)', () => {