@structuralists/scaffolding 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/preview.tsx +42 -0
- package/AGENTS.md +9 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/forms/CLAUDE.md +15 -9
- package/src/forms/plan.md +20 -9
- package/src/forms/state/useFormState/deriveErrors.test.ts +39 -4
- package/src/forms/state/useFormState/errorAt.test.ts +2 -2
- package/src/forms/state/useFormState/useFieldBinding.test.tsx +34 -0
- package/src/forms/state/useFormState/useFormState.stories.tsx +182 -0
- package/src/forms/state/validations/types.ts +7 -5
- package/src/forms/state/validations/walk.test.ts +125 -12
- package/src/forms/state/validations/walk.ts +26 -15
package/.storybook/preview.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Preview } from '@storybook/react-vite';
|
|
2
|
+
import { useEffect } from 'storybook/preview-api';
|
|
2
3
|
import { MemoryRouter } from 'react-router';
|
|
3
4
|
import * as prettier from 'prettier/standalone';
|
|
4
5
|
import * as babel from 'prettier/plugins/babel';
|
|
@@ -11,6 +12,28 @@ import '../tokens.css';
|
|
|
11
12
|
const formatCache = new Map<string, string>();
|
|
12
13
|
|
|
13
14
|
const preview: Preview = {
|
|
15
|
+
globalTypes: {
|
|
16
|
+
theme: {
|
|
17
|
+
description: 'Design-token theme (sets data-theme on <html>)',
|
|
18
|
+
toolbar: {
|
|
19
|
+
title: 'Theme',
|
|
20
|
+
icon: 'paintbrush',
|
|
21
|
+
items: [
|
|
22
|
+
{ value: 'system', title: 'System (default)' },
|
|
23
|
+
{ value: 'light-warm', title: 'Light / Warm' },
|
|
24
|
+
{ value: 'light-paper', title: 'Light / Paper' },
|
|
25
|
+
{ value: 'light-sepia', title: 'Light / Sepia' },
|
|
26
|
+
{ value: 'dark-warm', title: 'Dark / Warm' },
|
|
27
|
+
{ value: 'dark-neutral', title: 'Dark / Neutral' },
|
|
28
|
+
{ value: 'dark-dimmed', title: 'Dark / Dimmed' },
|
|
29
|
+
],
|
|
30
|
+
dynamicTitle: true,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
initialGlobals: {
|
|
35
|
+
theme: 'system',
|
|
36
|
+
},
|
|
14
37
|
parameters: {
|
|
15
38
|
layout: 'padded',
|
|
16
39
|
|
|
@@ -71,6 +94,25 @@ const preview: Preview = {
|
|
|
71
94
|
}
|
|
72
95
|
},
|
|
73
96
|
decorators: [
|
|
97
|
+
// Theme toggle: mirrors the toolbar selection onto <html data-theme="…">,
|
|
98
|
+
// matching how consuming apps select a theme (see tokens.css). 'system'
|
|
99
|
+
// leaves the attribute unset so the prefers-color-scheme fallback applies —
|
|
100
|
+
// that is also the initial global, so the vitest story run renders
|
|
101
|
+
// identically to before the toggle existed.
|
|
102
|
+
(Story, context) => {
|
|
103
|
+
const theme = context.globals.theme as string | undefined;
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const root = document.documentElement;
|
|
107
|
+
if (!theme || theme === 'system') {
|
|
108
|
+
root.removeAttribute('data-theme');
|
|
109
|
+
} else {
|
|
110
|
+
root.setAttribute('data-theme', theme);
|
|
111
|
+
}
|
|
112
|
+
}, [theme]);
|
|
113
|
+
|
|
114
|
+
return <Story />;
|
|
115
|
+
},
|
|
74
116
|
(Story) => (
|
|
75
117
|
<MemoryRouter>
|
|
76
118
|
<Story />
|
package/AGENTS.md
CHANGED
|
@@ -54,6 +54,15 @@ Built-in React hooks (`useState`, `useEffect`, etc.) keep their stock
|
|
|
54
54
|
positional signatures — this rule applies only to hooks defined in this
|
|
55
55
|
package.
|
|
56
56
|
|
|
57
|
+
## Storybook theme toggle
|
|
58
|
+
|
|
59
|
+
The Storybook toolbar has a Theme control (paintbrush icon) listing all six
|
|
60
|
+
`tokens.css` themes plus "System (default)". It is wired in
|
|
61
|
+
`.storybook/preview.tsx` via `globalTypes` + a global decorator that sets
|
|
62
|
+
`data-theme` on `<html>` — the same mechanism consuming apps use. "System"
|
|
63
|
+
leaves the attribute unset (prefers-color-scheme fallback) and is the initial
|
|
64
|
+
value, so the vitest story run is unaffected by the toggle.
|
|
65
|
+
|
|
57
66
|
## Testing
|
|
58
67
|
|
|
59
68
|
### Story tests (the main gate)
|
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ export const SignupCard = () => {
|
|
|
53
53
|
};
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
Browse every component (with live controls) in the Storybook: `bun run storybook`.
|
|
56
|
+
Browse every component (with live controls) in the Storybook: `bun run storybook`. The toolbar's Theme control (paintbrush icon) previews any of the themes above — "System (default)" leaves `data-theme` unset, following system preference.
|
|
57
57
|
|
|
58
58
|
## Consumer notes
|
|
59
59
|
|
package/package.json
CHANGED
package/src/forms/CLAUDE.md
CHANGED
|
@@ -104,13 +104,17 @@ Grammar doctrine, in force at the type level and in the runtime walk:
|
|
|
104
104
|
- **A nested spec on an absent (null/undefined) section validates
|
|
105
105
|
nothing**, at the type level (only the present branch refines;
|
|
106
106
|
nullability survives around the refined interior) and at runtime (the
|
|
107
|
-
walk skips — nothing to walk). Same for `each` over a null list
|
|
107
|
+
walk skips — nothing to walk). Same for `each` over a null list; a
|
|
108
|
+
"required list" is a leaf validator on the list field.
|
|
108
109
|
- **`F` stays naked in the structural arms.** Nullable sections/lists work
|
|
109
110
|
purely by distribution; wrapping the checked type in `NonNullable` breaks
|
|
110
111
|
it and blows the recursion stack (TS2589).
|
|
111
|
-
- **
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
- **An `each` spec validates every element.** Failures are addressed with
|
|
113
|
+
the numeric index step (`['drivers', 3, 'name']`) — the same step
|
|
114
|
+
semantics as `read()`/`Path`, so `errorAt` and the bindings look element
|
|
115
|
+
errors up like any other path. Elements fail independently
|
|
116
|
+
(first-error-wins applies within one field's validator array, not across
|
|
117
|
+
elements); an empty list passes.
|
|
114
118
|
|
|
115
119
|
## Aggregation: `perField`
|
|
116
120
|
|
|
@@ -260,9 +264,10 @@ type FormErrors<T> = readonly FormError<T>[]; // isValid === (errors.le
|
|
|
260
264
|
```
|
|
261
265
|
|
|
262
266
|
Paths are as deep as the failing node: a root leaf contributes `['email']`,
|
|
263
|
-
a nested-spec leaf `['homeAddress', 'postalCode']
|
|
264
|
-
|
|
265
|
-
|
|
267
|
+
a nested-spec leaf `['homeAddress', 'postalCode']`, a leaf inside a list
|
|
268
|
+
element carries the numeric index step (`['drivers', 3, 'name']`). Sibling
|
|
269
|
+
nodes fail independently — first-error-wins applies *within* one validator
|
|
270
|
+
array, not across fields or list elements.
|
|
266
271
|
|
|
267
272
|
Read one field's message with the typed accessor, never by hand-assembled
|
|
268
273
|
keys — serialized path strings (`'drivers.0.name'`) are deliberately not
|
|
@@ -507,8 +512,9 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
507
512
|
entry's subtree, addresses accumulated as `PathStep[]`s). `Validations<T>`
|
|
508
513
|
accepts bare `(val) => string | null` functions too — they simply narrow
|
|
509
514
|
nothing. The walk disambiguates a structural spec against the VALUE at
|
|
510
|
-
the path (array ⇒ `{ each }`,
|
|
511
|
-
nested spec; absent ⇒ skip), never against
|
|
515
|
+
the path (array ⇒ `{ each }`, run against every element with the index
|
|
516
|
+
as the path step; object ⇒ nested spec; absent ⇒ skip), never against
|
|
517
|
+
the constraint's shape.
|
|
512
518
|
The walk's entry type (`ConstraintEntry`) is how the compiler polices
|
|
513
519
|
the error loop in `state/useFormState/deriveErrors.ts`: `constraints[key]` is
|
|
514
520
|
*assigned* to it, never cast,
|
package/src/forms/plan.md
CHANGED
|
@@ -57,6 +57,7 @@ TS wall can't strand finished work behind it.
|
|
|
57
57
|
identity gate for the default-`V` case.
|
|
58
58
|
- **Phase 2 (nested object specs + recursive `Refine`): ✅ done** — see
|
|
59
59
|
"Phases" below.
|
|
60
|
+
- **Phase 3 (list `each` runtime): ✅ done** — see "Phases" below.
|
|
60
61
|
|
|
61
62
|
## Goal
|
|
62
63
|
|
|
@@ -248,6 +249,11 @@ multi-second check time:
|
|
|
248
249
|
0.87 s, 133,956 instantiations, 57,762 types (+7.1% instantiations over
|
|
249
250
|
post-item-6; matches the spike's equivalent non-stress milestone at
|
|
250
251
|
134,441 almost exactly)
|
|
252
|
+
- post-phase-3 (`each` runtime + the `ListValidation` story with its
|
|
253
|
+
numeric-path bindings; no new type machinery): check 0.84 s, 136,741
|
|
254
|
+
instantiations, 58,813 types (+1.8% over the pre-phase-3 HEAD at
|
|
255
|
+
134,330/58,034 — mostly the story's new hook call site and deep
|
|
256
|
+
`Path`/`ValueAt` bindings)
|
|
251
257
|
|
|
252
258
|
## Runtime consequences (can't be dodged)
|
|
253
259
|
|
|
@@ -320,15 +326,20 @@ tests, story updates where visible, probe ratchet.
|
|
|
320
326
|
predicted — nested errors just carry longer paths). **The `each` TYPE
|
|
321
327
|
arm landed here too** (the spike proved the grammar whole, and carving
|
|
322
328
|
it out of `RefineField` would have been artificial), but the runtime
|
|
323
|
-
walk for it
|
|
324
|
-
list
|
|
325
|
-
not validating; on a null list it
|
|
326
|
-
semantics.
|
|
327
|
-
3. **List `each` specs — runtime.**
|
|
328
|
-
paths carry the numeric step (`['drivers', 3, 'name']`),
|
|
329
|
-
phase-2 throw. (The type level — refined element flowing
|
|
330
|
-
`Array<...>` — already landed with phase 2.)
|
|
331
|
-
|
|
329
|
+
walk for it was phase 3 — in the interim an `each` constraint on a
|
|
330
|
+
present list THREW from the walk (pinned in walk.test.ts) rather than
|
|
331
|
+
silently not validating; on a null list it skipped, which already
|
|
332
|
+
matched phase-3 semantics.
|
|
333
|
+
3. **List `each` specs — runtime.** ✅ *done* — the walk visits every
|
|
334
|
+
element; error paths carry the numeric step (`['drivers', 3, 'name']`),
|
|
335
|
+
replacing the phase-2 throw. (The type level — refined element flowing
|
|
336
|
+
through `Array<...>` — already landed with phase 2.) Absent/null list ⇒
|
|
337
|
+
skip, same decided semantics as absent sections; elements fail
|
|
338
|
+
independently; walk-level semantics pinned React-free in walk.test.ts,
|
|
339
|
+
the typed boundary in deriveErrors.test.ts, element errors through
|
|
340
|
+
`errorMessage` at numeric paths in useFieldBinding.test.tsx, and the
|
|
341
|
+
visible flow in the `ListValidation` story. Two probes the spike said
|
|
342
|
+
to keep pinned were already in the phase-2 suite: the `audit`-style
|
|
332
343
|
"object field literally named `each`" disambiguation probe, and the
|
|
333
344
|
`each: <bare validator>` negative (rejected by TypeScript's weak-type
|
|
334
345
|
check — an obscure checker rule worth a canary).
|
|
@@ -75,10 +75,12 @@ describe('deriveFormErrors', () => {
|
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
// The recursive grammar: nested object specs
|
|
79
|
-
// address failures with real multi-step paths
|
|
80
|
-
//
|
|
81
|
-
// walk.test.ts
|
|
78
|
+
// The recursive grammar: nested object specs and list `each` specs walk the
|
|
79
|
+
// value tree and address failures with real multi-step paths — numeric steps
|
|
80
|
+
// for list elements. Walk-level semantics (element iteration, sibling
|
|
81
|
+
// independence, absent-value skips) are pinned in walk.test.ts; these tests
|
|
82
|
+
// pin the typed boundary: a constraints literal checked against the form
|
|
83
|
+
// type produces `Path<T>`-addressed entries.
|
|
82
84
|
|
|
83
85
|
type ProfileForm = {
|
|
84
86
|
email: string | undefined;
|
|
@@ -87,12 +89,16 @@ type ProfileForm = {
|
|
|
87
89
|
postalCode: string | undefined;
|
|
88
90
|
};
|
|
89
91
|
mailingAddress: { city: string | undefined } | undefined;
|
|
92
|
+
pets: Array<{ name: string | undefined }>;
|
|
93
|
+
pastPolicies: Array<{ insurer: string | undefined }> | null;
|
|
90
94
|
};
|
|
91
95
|
|
|
92
96
|
const emptyProfile: ProfileForm = {
|
|
93
97
|
email: undefined,
|
|
94
98
|
homeAddress: { city: undefined, postalCode: undefined },
|
|
95
99
|
mailingAddress: undefined,
|
|
100
|
+
pets: [],
|
|
101
|
+
pastPolicies: null,
|
|
96
102
|
};
|
|
97
103
|
|
|
98
104
|
describe('deriveFormErrors — nested object specs', () => {
|
|
@@ -168,3 +174,32 @@ describe('deriveFormErrors — nested object specs', () => {
|
|
|
168
174
|
]);
|
|
169
175
|
});
|
|
170
176
|
});
|
|
177
|
+
|
|
178
|
+
describe('deriveFormErrors — list `each` specs', () => {
|
|
179
|
+
test('each element failure is addressed with the numeric index step', () => {
|
|
180
|
+
const profile: ProfileForm = {
|
|
181
|
+
...emptyProfile,
|
|
182
|
+
pets: [{ name: 'Rex' }, { name: undefined }],
|
|
183
|
+
};
|
|
184
|
+
const errors = deriveFormErrors(profile, {
|
|
185
|
+
pets: { each: { name: notEmpty('name') } },
|
|
186
|
+
});
|
|
187
|
+
expect(errors).toEqual([
|
|
188
|
+
{ path: ['pets', 1, 'name'], error: "'name' cannot be empty" },
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('an `each` spec on a null list is skipped; a leaf validator is how a list is required', () => {
|
|
193
|
+
const errors = deriveFormErrors(emptyProfile, {
|
|
194
|
+
pastPolicies: { each: { insurer: notEmpty('insurer') } },
|
|
195
|
+
});
|
|
196
|
+
expect(errors).toEqual([]);
|
|
197
|
+
|
|
198
|
+
const required = deriveFormErrors(emptyProfile, {
|
|
199
|
+
pastPolicies: notEmpty('pastPolicies'),
|
|
200
|
+
});
|
|
201
|
+
expect(required).toEqual([
|
|
202
|
+
{ path: ['pastPolicies'], error: "'pastPolicies' cannot be empty" },
|
|
203
|
+
]);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -3,8 +3,8 @@ import { errorAt } from './errorAt';
|
|
|
3
3
|
import type { FormErrors } from './types';
|
|
4
4
|
|
|
5
5
|
// errorAt's equality must be exact over multi-step and numeric-step paths —
|
|
6
|
-
// the recursive grammar produces real multi-step addresses (nested specs
|
|
7
|
-
//
|
|
6
|
+
// the recursive grammar produces real multi-step addresses (nested specs,
|
|
7
|
+
// and numeric index steps from runtime `each` over list elements).
|
|
8
8
|
type Form = {
|
|
9
9
|
email: string | undefined;
|
|
10
10
|
address: { city: string | undefined };
|
|
@@ -138,6 +138,40 @@ describe('getFormFieldPropsAt — error-display policy', () => {
|
|
|
138
138
|
).toBeUndefined();
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
test('element errors from an `each` spec surface at numeric paths, per element', () => {
|
|
142
|
+
const { result } = renderHook(() =>
|
|
143
|
+
useFormState({
|
|
144
|
+
initialValues,
|
|
145
|
+
constraints: { pets: { each: { name: notEmpty('name') } } },
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
act(() => {
|
|
150
|
+
result.current.getFormFieldPropsAt(['pets', 1, 'name']).onChange(undefined);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// The raw list addresses the failing element by its index step …
|
|
154
|
+
expect(result.current.errors).toEqual([
|
|
155
|
+
{ path: ['pets', 1, 'name'], error: "'name' cannot be empty" },
|
|
156
|
+
]);
|
|
157
|
+
// … and errorMessage applies the same display policy at that path.
|
|
158
|
+
expect(
|
|
159
|
+
result.current.getFormFieldPropsAt(['pets', 1, 'name']).errorMessage,
|
|
160
|
+
).toBeUndefined();
|
|
161
|
+
|
|
162
|
+
act(() => {
|
|
163
|
+
result.current.getFormFieldPropsAt(['pets', 1, 'name']).onBlur();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(
|
|
167
|
+
result.current.getFormFieldPropsAt(['pets', 1, 'name']).errorMessage,
|
|
168
|
+
).toBe("'name' cannot be empty");
|
|
169
|
+
// The sibling element passed — no error at its own numeric path.
|
|
170
|
+
expect(
|
|
171
|
+
result.current.getFormFieldPropsAt(['pets', 0, 'name']).errorMessage,
|
|
172
|
+
).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
141
175
|
test('repeat blurs on the same path keep the touched list stable', () => {
|
|
142
176
|
// At the useFieldBinding boundary, where the touched list is returned.
|
|
143
177
|
const { result } = renderHook(() =>
|
|
@@ -441,6 +441,188 @@ export const FieldBinding: Story = {
|
|
|
441
441
|
},
|
|
442
442
|
};
|
|
443
443
|
|
|
444
|
+
type FleetFormValues = {
|
|
445
|
+
fleetName: string | undefined;
|
|
446
|
+
drivers: Array<{
|
|
447
|
+
name: string | undefined;
|
|
448
|
+
licenseNumber: string | undefined;
|
|
449
|
+
}>;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// What onSubmit receives: the `each` spec refines the ELEMENT type and the
|
|
453
|
+
// refinement flows through Array<...> — every driver's constrained fields
|
|
454
|
+
// lose undefined. The setSubmitted(vals) call compiling is the proof.
|
|
455
|
+
type SubmittedFleet = {
|
|
456
|
+
fleetName: string;
|
|
457
|
+
drivers: Array<{ name: string; licenseNumber: string }>;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// Phase 3 of the recursive grammar: a `{ each: … }` spec validates EVERY
|
|
461
|
+
// list element, and failures are addressed with the numeric index step —
|
|
462
|
+
// ['drivers', 1, 'name'] — the same step semantics the bindings read. Each
|
|
463
|
+
// driver's fields are bound at their numeric paths, so element errors land
|
|
464
|
+
// on exactly the element that failed.
|
|
465
|
+
const ListValidationDemo = () => {
|
|
466
|
+
const [submitted, setSubmitted] = useState<SubmittedFleet | null>(null);
|
|
467
|
+
|
|
468
|
+
const { values, errors, getFormFieldPropsAt, submit } = useFormState({
|
|
469
|
+
initialValues: {
|
|
470
|
+
fleetName: undefined,
|
|
471
|
+
drivers: [
|
|
472
|
+
{ name: undefined, licenseNumber: undefined },
|
|
473
|
+
{ name: undefined, licenseNumber: undefined },
|
|
474
|
+
],
|
|
475
|
+
} as FleetFormValues,
|
|
476
|
+
constraints: {
|
|
477
|
+
fleetName: notEmpty('fleetName'),
|
|
478
|
+
drivers: {
|
|
479
|
+
each: {
|
|
480
|
+
name: notEmpty('name'),
|
|
481
|
+
licenseNumber: [
|
|
482
|
+
notEmpty('licenseNumber'),
|
|
483
|
+
minLength('licenseNumber', 5),
|
|
484
|
+
],
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
onSubmit: (vals) => setSubmitted(vals),
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
return (
|
|
492
|
+
<form
|
|
493
|
+
style={{ maxWidth: 420, display: 'grid', gap: 16 }}
|
|
494
|
+
onSubmit={(e) => {
|
|
495
|
+
e.preventDefault();
|
|
496
|
+
submit();
|
|
497
|
+
}}
|
|
498
|
+
>
|
|
499
|
+
<TextInputForForm
|
|
500
|
+
label="Fleet name"
|
|
501
|
+
formFieldProps={getFormFieldPropsAt(['fleetName'])}
|
|
502
|
+
/>
|
|
503
|
+
|
|
504
|
+
{values.drivers.map((_, index) => (
|
|
505
|
+
// Index keys are fine here: the list is fixed-size. Stable element
|
|
506
|
+
// keys for editable lists are plan item 8.
|
|
507
|
+
<fieldset
|
|
508
|
+
key={index}
|
|
509
|
+
style={{ display: 'grid', gap: 12, border: '1px solid var(--ui-border, #ddd)', borderRadius: 6, padding: 12 }}
|
|
510
|
+
>
|
|
511
|
+
<legend style={{ fontSize: 13, padding: '0 4px' }}>
|
|
512
|
+
Driver {index + 1}
|
|
513
|
+
</legend>
|
|
514
|
+
<TextInputForForm
|
|
515
|
+
label={`Driver ${index + 1} name`}
|
|
516
|
+
formFieldProps={getFormFieldPropsAt(['drivers', index, 'name'])}
|
|
517
|
+
/>
|
|
518
|
+
<TextInputForForm
|
|
519
|
+
label={`Driver ${index + 1} license`}
|
|
520
|
+
hint="At least 5 characters"
|
|
521
|
+
formFieldProps={getFormFieldPropsAt([
|
|
522
|
+
'drivers',
|
|
523
|
+
index,
|
|
524
|
+
'licenseNumber',
|
|
525
|
+
])}
|
|
526
|
+
/>
|
|
527
|
+
</fieldset>
|
|
528
|
+
))}
|
|
529
|
+
|
|
530
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
531
|
+
<Button type="submit" variant="primary">
|
|
532
|
+
Save fleet
|
|
533
|
+
</Button>
|
|
534
|
+
{/* errorAt reads one element's raw error at its numeric path — no
|
|
535
|
+
display gating, the live truth the bindings' errorMessage sits
|
|
536
|
+
on top of. */}
|
|
537
|
+
<span style={{ fontSize: 13 }} data-testid="raw-driver-2-name-error">
|
|
538
|
+
{"raw ['drivers', 1, 'name']: "}
|
|
539
|
+
{errorAt(errors, ['drivers', 1, 'name']) ?? '—'}
|
|
540
|
+
</span>
|
|
541
|
+
</div>
|
|
542
|
+
|
|
543
|
+
{submitted && (
|
|
544
|
+
<pre
|
|
545
|
+
style={{
|
|
546
|
+
background: 'var(--ui-surface-muted, #f4f4f4)',
|
|
547
|
+
padding: 12,
|
|
548
|
+
borderRadius: 6,
|
|
549
|
+
fontSize: 12,
|
|
550
|
+
margin: 0,
|
|
551
|
+
}}
|
|
552
|
+
>
|
|
553
|
+
{`onSubmit received (narrowed type):\n${JSON.stringify(submitted, null, 2)}`}
|
|
554
|
+
</pre>
|
|
555
|
+
)}
|
|
556
|
+
</form>
|
|
557
|
+
);
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
export const ListValidation: Story = {
|
|
561
|
+
render: () => (
|
|
562
|
+
<div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
|
|
563
|
+
<ListValidationDemo />
|
|
564
|
+
</div>
|
|
565
|
+
),
|
|
566
|
+
// Walks the per-element flow: errorAt at a numeric path is live from the
|
|
567
|
+
// start, element errors surface per element (not per list), fixing one
|
|
568
|
+
// element leaves its siblings' errors alone, and the refined element type
|
|
569
|
+
// flows through Array<...> to onSubmit.
|
|
570
|
+
play: async ({ canvasElement }) => {
|
|
571
|
+
const canvas = within(canvasElement);
|
|
572
|
+
|
|
573
|
+
// errorAt is raw truth — the numeric-path lookup sees driver 2's error
|
|
574
|
+
// before anything is touched or submitted. (Exact-text queries below
|
|
575
|
+
// don't match this line — its text includes the path prefix.)
|
|
576
|
+
await expect(
|
|
577
|
+
canvas.getByTestId('raw-driver-2-name-error'),
|
|
578
|
+
).toHaveTextContent("'name' cannot be empty");
|
|
579
|
+
// The bindings withhold everything until touched/submit-attempted.
|
|
580
|
+
await expect(
|
|
581
|
+
canvas.queryAllByText("'name' cannot be empty"),
|
|
582
|
+
).toHaveLength(0);
|
|
583
|
+
|
|
584
|
+
// Fix driver 1's name, then submit: every remaining element error
|
|
585
|
+
// appears at its own element — driver 1 name stays clean.
|
|
586
|
+
await userEvent.type(canvas.getByLabelText(/^Driver 1 name/), 'Ayrton');
|
|
587
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Save fleet' }));
|
|
588
|
+
await expect(
|
|
589
|
+
canvas.getAllByText("'name' cannot be empty"),
|
|
590
|
+
).toHaveLength(1); // driver 2's field only — driver 1's name passed
|
|
591
|
+
await expect(
|
|
592
|
+
canvas.getAllByText("'licenseNumber' cannot be empty"),
|
|
593
|
+
).toHaveLength(2); // both drivers' licenses
|
|
594
|
+
await expect(
|
|
595
|
+
canvas.getByText("'fleetName' cannot be empty"),
|
|
596
|
+
).toBeInTheDocument();
|
|
597
|
+
|
|
598
|
+
// Validator arrays keep first-error-wins semantics INSIDE an element:
|
|
599
|
+
// a short license moves that element (and only it) to the minLength
|
|
600
|
+
// message.
|
|
601
|
+
await userEvent.type(canvas.getByLabelText(/^Driver 1 license/), 'abc');
|
|
602
|
+
await expect(
|
|
603
|
+
canvas.getByText("'licenseNumber' must be at least 5 characters"),
|
|
604
|
+
).toBeInTheDocument();
|
|
605
|
+
await expect(
|
|
606
|
+
canvas.getAllByText("'licenseNumber' cannot be empty"),
|
|
607
|
+
).toHaveLength(1); // driver 2 still on notEmpty
|
|
608
|
+
|
|
609
|
+
// Fix everything; the narrowed payload reaches onSubmit.
|
|
610
|
+
await userEvent.type(canvas.getByLabelText(/^Driver 1 license/), 'de');
|
|
611
|
+
await userEvent.type(canvas.getByLabelText(/^Driver 2 name/), 'Michele');
|
|
612
|
+
await userEvent.type(
|
|
613
|
+
canvas.getByLabelText(/^Driver 2 license/),
|
|
614
|
+
'XK-4471',
|
|
615
|
+
);
|
|
616
|
+
await userEvent.type(canvas.getByLabelText(/^Fleet name/), 'Scuderia');
|
|
617
|
+
// Driver 2's name is fixed — the raw errorAt lookup clears live.
|
|
618
|
+
await expect(
|
|
619
|
+
canvas.getByTestId('raw-driver-2-name-error'),
|
|
620
|
+
).toHaveTextContent('—');
|
|
621
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Save fleet' }));
|
|
622
|
+
await expect(canvas.getByText(/onSubmit received/)).toBeInTheDocument();
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
|
|
444
626
|
type DebuggerDemoValues = {
|
|
445
627
|
email: string | undefined;
|
|
446
628
|
nickname: string | undefined;
|
|
@@ -43,11 +43,13 @@ export type FieldConstraint<F> =
|
|
|
43
43
|
| (F extends FormValuesObject ? Validations<F> : never)
|
|
44
44
|
| (F extends FormValueList ? ListConstraint<F[number]> : never);
|
|
45
45
|
|
|
46
|
-
// The constraint form for a list field: a spec applied to each element.
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
46
|
+
// The constraint form for a list field: a spec applied to each element. At
|
|
47
|
+
// runtime the walk validates EVERY element of a present list against the
|
|
48
|
+
// `each` spec, and each failure carries the numeric index step
|
|
49
|
+
// (`['drivers', 3, 'name']`) — the same step semantics as `read()`/`Path`,
|
|
50
|
+
// so `errorAt` and the bindings look element errors up like any other path.
|
|
51
|
+
// An absent (null/undefined) list has no elements to walk and skips — a
|
|
52
|
+
// "required list" is a leaf validator on the list field instead.
|
|
51
53
|
export type ListConstraint<Element extends FormValuesObject> = {
|
|
52
54
|
readonly each: Validations<Element>;
|
|
53
55
|
// room to grow, e.g. a `self` slot for list-level rules (min count,
|
|
@@ -196,20 +196,133 @@ describe('validateEntry — value-model disambiguation', () => {
|
|
|
196
196
|
});
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
-
describe('validateEntry — list `each` specs
|
|
200
|
-
test('
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
199
|
+
describe('validateEntry — list `each` specs', () => {
|
|
200
|
+
test('the element spec runs against every element, failures addressed by numeric step', () => {
|
|
201
|
+
const result = validateEntry(
|
|
202
|
+
{ each: { name: fail('name required') } },
|
|
203
|
+
[{ name: undefined }, { name: undefined }],
|
|
204
|
+
['drivers'],
|
|
205
|
+
);
|
|
206
|
+
expect(result).toEqual([
|
|
207
|
+
{ path: ['drivers', 0, 'name'], error: 'name required' },
|
|
208
|
+
{ path: ['drivers', 1, 'name'], error: 'name required' },
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('only failing elements contribute — the numeric step is the element index, not a count', () => {
|
|
213
|
+
const named = { name: 'ok' };
|
|
214
|
+
const result = validateEntry(
|
|
215
|
+
{
|
|
216
|
+
each: {
|
|
217
|
+
name: (val: unknown) => (val == null ? 'name required' : null),
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
[named, named, named, { name: undefined }],
|
|
221
|
+
['drivers'],
|
|
222
|
+
);
|
|
223
|
+
expect(result).toEqual([
|
|
224
|
+
{ path: ['drivers', 3, 'name'], error: 'name required' },
|
|
225
|
+
]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('elements fail independently: a failing element does not stop the others', () => {
|
|
229
|
+
const spy = mock(() => null);
|
|
230
|
+
validateEntry(
|
|
231
|
+
{ each: { name: fail('boom'), age: spy } },
|
|
232
|
+
[{ name: 'a', age: 1 }, { name: 'b', age: 2 }],
|
|
233
|
+
['drivers'],
|
|
234
|
+
);
|
|
235
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
236
|
+
expect(spy).toHaveBeenCalledWith(1);
|
|
237
|
+
expect(spy).toHaveBeenCalledWith(2);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('element validators receive the element field value, not the list', () => {
|
|
241
|
+
const spy = mock((val: string) => (val === 'Rex' ? null : 'not Rex'));
|
|
242
|
+
expect(
|
|
243
|
+
validateEntry({ each: { name: spy } }, [{ name: 'Rex' }], ['pets']),
|
|
244
|
+
).toEqual([]);
|
|
245
|
+
expect(spy).toHaveBeenCalledWith('Rex');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('an empty list yields no errors', () => {
|
|
249
|
+
expect(
|
|
250
|
+
validateEntry({ each: { name: fail('x') } }, [], ['drivers']),
|
|
251
|
+
).toEqual([]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('an `each` spec on a null list is skipped — nothing to walk', () => {
|
|
255
|
+
// Same decided semantics as absent sections: the type level refines only
|
|
256
|
+
// the present branch of a nullable list, so the runtime mirror is to
|
|
257
|
+
// skip. A "required list" is a leaf validator on the list field.
|
|
211
258
|
expect(
|
|
212
259
|
validateEntry({ each: { insurer: fail('x') } }, null, ['pastPolicies']),
|
|
213
260
|
).toEqual([]);
|
|
214
261
|
});
|
|
262
|
+
|
|
263
|
+
test('an `each` spec on an undefined list is skipped', () => {
|
|
264
|
+
const never = mock(() => 'never reached');
|
|
265
|
+
expect(
|
|
266
|
+
validateEntry({ each: { insurer: never } }, undefined, ['pastPolicies']),
|
|
267
|
+
).toEqual([]);
|
|
268
|
+
expect(never).not.toHaveBeenCalled();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('each-in-each: failures at list-in-list depth carry both numeric steps', () => {
|
|
272
|
+
const result = validateEntry(
|
|
273
|
+
{ each: { incidents: { each: { date: fail('bad date') } } } },
|
|
274
|
+
[
|
|
275
|
+
{ incidents: [] },
|
|
276
|
+
{ incidents: [{ date: undefined }, { date: undefined }] },
|
|
277
|
+
],
|
|
278
|
+
['drivers'],
|
|
279
|
+
);
|
|
280
|
+
expect(result).toEqual([
|
|
281
|
+
{ path: ['drivers', 1, 'incidents', 0, 'date'], error: 'bad date' },
|
|
282
|
+
{ path: ['drivers', 1, 'incidents', 1, 'date'], error: 'bad date' },
|
|
283
|
+
]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('a nested object spec inside an each spec extends the address past the index', () => {
|
|
287
|
+
const result = validateEntry(
|
|
288
|
+
{ each: { garagingAddress: { city: fail('city required') } } },
|
|
289
|
+
[{ garagingAddress: { city: undefined } }],
|
|
290
|
+
['vehicles'],
|
|
291
|
+
);
|
|
292
|
+
expect(result).toEqual([
|
|
293
|
+
{
|
|
294
|
+
path: ['vehicles', 0, 'garagingAddress', 'city'],
|
|
295
|
+
error: 'city required',
|
|
296
|
+
},
|
|
297
|
+
]);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('leaf semantics hold inside elements: validator arrays are first-error-wins per field', () => {
|
|
301
|
+
const result = validateEntry(
|
|
302
|
+
{ each: { name: [pass, fail('second'), fail('third')] } },
|
|
303
|
+
[{ name: 'x' }],
|
|
304
|
+
['drivers'],
|
|
305
|
+
);
|
|
306
|
+
expect(result).toEqual([
|
|
307
|
+
{ path: ['drivers', 0, 'name'], error: 'second' },
|
|
308
|
+
]);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('a whole-list leaf validator on the list field still runs against the list value', () => {
|
|
312
|
+
// Leaf forms stay legal on structural fields — a validator (array) on a
|
|
313
|
+
// list field validates the list as a value, no `each` involved.
|
|
314
|
+
const spy = mock((val: unknown[]) =>
|
|
315
|
+
val.length > 0 ? null : 'at least one driver',
|
|
316
|
+
);
|
|
317
|
+
expect(validateEntry([spy], [], ['drivers'])).toEqual([
|
|
318
|
+
{ path: ['drivers'], error: 'at least one driver' },
|
|
319
|
+
]);
|
|
320
|
+
expect(spy).toHaveBeenCalledWith([]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('a spec without `each` on a list value (untyped JS) is skipped, mirroring the null-child skip', () => {
|
|
324
|
+
expect(
|
|
325
|
+
validateEntry({ name: fail('x') }, [{ name: undefined }], ['drivers']),
|
|
326
|
+
).toEqual([]);
|
|
327
|
+
});
|
|
215
328
|
});
|
|
@@ -2,10 +2,10 @@ import type { PathStep } from '../path/types';
|
|
|
2
2
|
|
|
3
3
|
// The runtime walk over a constraints object, kept separate from the hook so
|
|
4
4
|
// its semantics are unit-testable without React. The grammar is recursive
|
|
5
|
-
// (nested object specs
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// (nested object specs, list `each` specs), so the walk is a tree walk: it
|
|
6
|
+
// accumulates a `PathStep[]` address as it descends, and the hook's
|
|
7
|
+
// structured `{path, error}[]` error model (`FormErrors<T>`) consumes those
|
|
8
|
+
// addresses directly.
|
|
9
9
|
|
|
10
10
|
export type ValidationError = {
|
|
11
11
|
readonly path: readonly PathStep[];
|
|
@@ -47,18 +47,22 @@ type ConstraintSpec = {
|
|
|
47
47
|
// An empty array passes, mirroring its refinement (`Exclude<F, never>`).
|
|
48
48
|
//
|
|
49
49
|
// Structural semantics — interpretation is directed by the value model:
|
|
50
|
-
// - value is an array ⇒ the spec is a `{ each: … }` list constraint
|
|
51
|
-
//
|
|
52
|
-
//
|
|
50
|
+
// - value is an array ⇒ the spec is a `{ each: … }` list constraint: the
|
|
51
|
+
// element spec runs against EVERY element, and each element's failures gain
|
|
52
|
+
// the numeric index step (`['drivers', 3, 'name']`) — the same step
|
|
53
|
+
// semantics as `read()`/`Path`, so `errorAt` and the bindings look them up
|
|
54
|
+
// like any other path. Elements fail independently; an empty list has
|
|
55
|
+
// nothing to walk and passes.
|
|
53
56
|
// - value is an object ⇒ the spec is a nested `Validations`: recurse into
|
|
54
57
|
// each constrained key, extending the path. (An object field owning a key
|
|
55
58
|
// literally named `each` lands here — the value directs, so its spec is a
|
|
56
59
|
// nested spec like any other.)
|
|
57
60
|
// - value is absent (null/undefined) ⇒ SKIP: a nested spec on an absent
|
|
58
|
-
// section validates nothing
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
61
|
+
// section validates nothing, and an `each` spec on an absent list has no
|
|
62
|
+
// elements to walk (decided with the type spike — the type level refines
|
|
63
|
+
// only the present branch of a nullable section/list, and this is the
|
|
64
|
+
// honest runtime mirror). A "required section" or "required list" is a
|
|
65
|
+
// LEAF validator on the field instead. A non-object value (unreachable through the typed
|
|
62
66
|
// grammar) has nothing to walk either, mirroring read()'s dead-step
|
|
63
67
|
// semantics.
|
|
64
68
|
// Predicate (not inline checks) because `Array.isArray`'s `arg is any[]`
|
|
@@ -92,10 +96,17 @@ export const validateEntry = (
|
|
|
92
96
|
if (value == null || typeof value !== 'object') return [];
|
|
93
97
|
|
|
94
98
|
if (Array.isArray(value)) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
// Unreachable through the typed grammar (a list field's only structural
|
|
100
|
+
// form is `{ each: … }`), so a missing `each` can only come from untyped
|
|
101
|
+
// JS — nothing to run, mirroring the null-child skip below.
|
|
102
|
+
const each = entry.each;
|
|
103
|
+
if (each == null) return [];
|
|
104
|
+
|
|
105
|
+
const errors: ValidationError[] = [];
|
|
106
|
+
(value as unknown[]).forEach((element, index) => {
|
|
107
|
+
errors.push(...validateEntry(each, element, [...path, index]));
|
|
108
|
+
});
|
|
109
|
+
return errors;
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
const errors: ValidationError[] = [];
|