Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ 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. A `pointerToFieldPath(pointer)` helper is
exported alongside.
- `resetOnSubmit` prop on `<Form>` — 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()`
Expand Down
62 changes: 55 additions & 7 deletions src/lib/Form/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/

/**
Expand Down Expand Up @@ -139,28 +143,72 @@ 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.
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;
});

Expand Down
92 changes: 92 additions & 0 deletions src/lib/Form/helpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,98 @@ describe('.formatErrors(errors)', () => {
const errors = [{ dataPath: 'items[0].tags[1]' }];
expect(helpers.formatErrors(errors)[0].field).toStrictEqual('items.0.tags.1');
});

// 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 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)', () => {
Expand Down