@structuralists/scaffolding 0.11.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/components/Json/JsonTable/JsonLeafNode.tsx +20 -17
- package/src/components/Json/JsonTable/JsonTable.stories.tsx +87 -0
- package/src/components/Json/JsonTable/index.tsx +13 -6
- package/src/components/Json/JsonTable/styles.module.css +20 -0
- package/src/components/Json/JsonTable/types.ts +3 -5
- package/src/forms/CLAUDE.md +110 -26
- package/src/forms/plan.md +115 -23
- package/src/forms/state/useFormState/FormDebugger.test.tsx +4 -3
- package/src/forms/state/useFormState/deriveErrors.test.ts +129 -0
- package/src/forms/state/useFormState/deriveErrors.ts +10 -10
- package/src/forms/state/useFormState/errorAt.test.ts +3 -3
- package/src/forms/state/useFormState/inspectable.test.ts +9 -9
- package/src/forms/state/useFormState/inspectable.ts +5 -7
- package/src/forms/state/useFormState/types.ts +2 -2
- package/src/forms/state/useFormState/useFieldBinding.test.tsx +34 -0
- package/src/forms/state/useFormState/useFormState.stories.tsx +214 -10
- package/src/forms/state/useFormState/useFormState.test-d.ts +436 -0
- package/src/forms/state/useFormState/useFormState.test.tsx +2 -2
- package/src/forms/state/validations/types.ts +79 -17
- package/src/forms/state/validations/walk.test.ts +272 -19
- package/src/forms/state/validations/walk.ts +97 -25
- package/tokens.css +55 -0
package/src/forms/plan.md
CHANGED
|
@@ -28,7 +28,8 @@ TS wall can't strand finished work behind it.
|
|
|
28
28
|
(state/useFormState/errorAt.ts) — the smallest surface that keeps stories
|
|
29
29
|
readable; item 6 folds the same lookup into `getFormFieldPropsAt`'s
|
|
30
30
|
`errorMessage`. The Debugger needed no change (`toInspectable`
|
|
31
|
-
index-
|
|
31
|
+
index-keyed arrays at the time; it now passes them through, since
|
|
32
|
+
`JsonTable` renders arrays natively).
|
|
32
33
|
4. **Item 5 — elements/state split.** Pure churn, no type risk; do it before
|
|
33
34
|
item 6 so the wrappers land in their final home once. ✅ *done* — one
|
|
34
35
|
`src/forms/` umbrella: elements in `src/forms/elements/<Component>/`,
|
|
@@ -45,6 +46,18 @@ TS wall can't strand finished work behind it.
|
|
|
45
46
|
Throwaway `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
|
|
46
47
|
any runtime work; three outcomes (works / slow / intractable) each with a
|
|
47
48
|
known response. Worst case, everything above still shipped.
|
|
49
|
+
- **Spike: ✅ done — verdict WORKS, decisively.** The full recursive
|
|
50
|
+
grammar (nested specs, `each`, arrays at every level, recursive
|
|
51
|
+
`Refine`) plus a stress tier (~110-leaf form, 12-level object ladder,
|
|
52
|
+
4-deep `each` ladder) cost **1.31× instantiations, check time flat** —
|
|
53
|
+
nowhere near the ~10×/multi-second failure thresholds. No depth caps,
|
|
54
|
+
lazy-recursion or boxing tricks needed. Two binding grammar
|
|
55
|
+
adjustments came out of it, both folded into the sketches below:
|
|
56
|
+
value-model-first branch order in `RefineField`, and the `Refine`
|
|
57
|
+
identity gate for the default-`V` case.
|
|
58
|
+
- **Phase 2 (nested object specs + recursive `Refine`): ✅ done** — see
|
|
59
|
+
"Phases" below.
|
|
60
|
+
- **Phase 3 (list `each` runtime): ✅ done** — see "Phases" below.
|
|
48
61
|
|
|
49
62
|
## Goal
|
|
50
63
|
|
|
@@ -126,30 +139,55 @@ useFormState({
|
|
|
126
139
|
|
|
127
140
|
## Refinement through the grammar
|
|
128
141
|
|
|
129
|
-
`Refine`
|
|
142
|
+
`Refine` is recursive, mirroring the grammar. As shipped (phase 2, with both
|
|
143
|
+
type-spike adjustments):
|
|
130
144
|
|
|
131
145
|
```ts
|
|
132
|
-
type ExcludedOf<V> = V extends Refinement<infer X> ? X : never;
|
|
133
146
|
// MemberExcludes<C>: the per-member-sound union of a validator tuple's
|
|
134
147
|
// excludes — it and SoundExcludedOf landed with phase 1 in state/validations/types.ts.
|
|
135
148
|
|
|
136
|
-
type RefineField<F, C> =
|
|
137
|
-
|
|
138
|
-
: C extends readonly unknown[]
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
type RefineField<F, C> = C extends Refinement<infer Excluded>
|
|
150
|
+
? Exclude<F, Excluded> // single marked validator
|
|
151
|
+
: C extends readonly unknown[]
|
|
152
|
+
? Exclude<F, MemberExcludes<C>> // validator array: union of per-member-sound excludes
|
|
153
|
+
: C extends object // structural: interrogate F (the value model) FIRST
|
|
154
|
+
? F extends FormValueList
|
|
155
|
+
? C extends { readonly each: infer E }
|
|
156
|
+
? Array<RefineObject<F[number], E>> // per-element
|
|
157
|
+
: F
|
|
158
|
+
: F extends FormValuesObject
|
|
159
|
+
? RefineObject<F, C> // nested spec
|
|
160
|
+
: F
|
|
161
|
+
: F; // bare fn / no marker
|
|
144
162
|
|
|
145
163
|
type RefineObject<T, V> = { [K in keyof T]: K extends keyof V ? RefineField<T[K], V[K]> : T[K] };
|
|
164
|
+
|
|
165
|
+
// The identity gate — NOT optional (spike adjustment 2): with constraints
|
|
166
|
+
// omitted, the default V = Validations<T> would distribute through the walk
|
|
167
|
+
// and hand back unions of structurally-identical mapped copies of every
|
|
168
|
+
// section — assignable to T but not identity-equal (mangled hover types,
|
|
169
|
+
// fails toEqualTypeOf). Validations<T> extends V exactly when V is the
|
|
170
|
+
// default (or empty/fully-widened — no markers survive anyway).
|
|
171
|
+
type Refine<T extends FormValuesObject, V extends Validations<T>> =
|
|
172
|
+
Validations<T> extends V ? T : RefineObject<T, V>;
|
|
146
173
|
```
|
|
147
174
|
|
|
148
|
-
Branch-order constraints
|
|
149
|
-
in:
|
|
175
|
+
Branch-order constraints baked in (baseline + spike):
|
|
150
176
|
|
|
151
177
|
- **Check `Refinement` before `object`** — validator functions are objects.
|
|
152
|
-
- **
|
|
178
|
+
- **The structural arms branch on the value model (`F`) before the
|
|
179
|
+
constraint's shape** (spike adjustment 1): asking `C extends { each }`
|
|
180
|
+
first would strand an object field that legitimately owns a key named
|
|
181
|
+
`each` — its nested spec would match the `each` arm, fail the list test,
|
|
182
|
+
and fall back unrefined. Pinned by the `audit` disambiguation probe in
|
|
183
|
+
`useFormState.test-d.ts`.
|
|
184
|
+
- **A bare function** (no marker) falls through: on a scalar field to the
|
|
185
|
+
final `F` arm; on a structural field into `RefineObject<F, C>` with
|
|
186
|
+
`keyof C` empty — an identity map, structurally unchanged, as a
|
|
187
|
+
whole-value validator should be.
|
|
188
|
+
- Nested occurrences need no identity gate: a concrete literal `V` has only
|
|
189
|
+
the keys actually written, so uncovered fields take the `T[K]` arm
|
|
190
|
+
verbatim at every level.
|
|
153
191
|
- The runtime cast in `submit()` stays the same single honest cast: valid ⇒
|
|
154
192
|
every constrained node passed, at every depth, which is what the recursive
|
|
155
193
|
`Refine` now encodes.
|
|
@@ -200,11 +238,44 @@ multi-second check time:
|
|
|
200
238
|
0.82 s, 125,022 instantiations, 54,959 types (+10.7% instantiations over
|
|
201
239
|
post-item-5 — the `ValueAt` instantiations at each binding call site are
|
|
202
240
|
real but cheap; check time flat)
|
|
241
|
+
- type spike (full recursive grammar, chunky + stress probes, throwaway
|
|
242
|
+
worktree — not merged): 163,262 instantiations / 65,036 types / 0.86s
|
|
243
|
+
check — 1.31× post-item-6; verdict works, no mitigation tricks required.
|
|
244
|
+
The real phases should land well under that: the spike carried a stress
|
|
245
|
+
tier (~110-leaf mega form with a large spec inlined twice, 12-level
|
|
246
|
+
object ladder, 4-deep `each` ladder) the phases don't.
|
|
247
|
+
- post-phase-2 (recursive grammar + recursive `Refine` with identity gate,
|
|
248
|
+
ported chunky/boundary/negative probe suite, recursive walk): check
|
|
249
|
+
0.87 s, 133,956 instantiations, 57,762 types (+7.1% instantiations over
|
|
250
|
+
post-item-6; matches the spike's equivalent non-stress milestone at
|
|
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)
|
|
203
257
|
|
|
204
258
|
## Runtime consequences (can't be dodged)
|
|
205
259
|
|
|
206
|
-
- **The validator walk becomes a tree walk.**
|
|
207
|
-
`state/
|
|
260
|
+
- **The validator walk becomes a tree walk.** ✅ *Landed with phase 2*
|
|
261
|
+
(`validateEntry` in `state/validations/walk.ts` recurses, accumulating the
|
|
262
|
+
`PathStep[]` address; deliberately parallel to `read()`, not shared —
|
|
263
|
+
the walk descends a *constraints* tree, `read()` a *path*). Interpretation
|
|
264
|
+
of a structural spec is directed by the VALUE at the path, mirroring the
|
|
265
|
+
type level (see "Disambiguation").
|
|
266
|
+
- **Absent sections: a nested spec on an absent/null section is skipped.**
|
|
267
|
+
*Decided with the type spike.* The type level commits to this implicitly —
|
|
268
|
+
`F` distributes naked through the structural grammar arms, so a spec on
|
|
269
|
+
`UsAddress | undefined` refines only the present branch and the
|
|
270
|
+
nullability survives around the refined interior — and the runtime walk
|
|
271
|
+
mirrors it: nothing to walk when the section is absent (same for `each`
|
|
272
|
+
over a null list). A **required section** is expressed as a *leaf*
|
|
273
|
+
validator on the section field (`mailingAddress: notEmpty(…)`), which
|
|
274
|
+
stays legal on structural fields — with the recorded caveat that leaf and
|
|
275
|
+
structural forms are mutually exclusive per key (the "whole-value +
|
|
276
|
+
structural constraints on the same key" open decision below). Never
|
|
277
|
+
"help" the grammar arms with `NonNullable<F>` wrappers — that breaks the
|
|
278
|
+
distribution and blows the recursion stack (TS2589).
|
|
208
279
|
- **`FormErrors` can no longer be `Partial<Record<keyof T, string>>`.**
|
|
209
280
|
✅ *Landed with working-order step 3* (on the flat baseline, single-key
|
|
210
281
|
paths). Decided: errors become a plain list of structured entries,
|
|
@@ -244,13 +315,34 @@ tests, story updates where visible, probe ratchet.
|
|
|
244
315
|
distributes over a naked constraint parameter, which made union-typed
|
|
245
316
|
*single* constraints (`cond ? v1 : v2`) sound too (union of per-branch
|
|
246
317
|
refinements, pinned in `state/validations/types.test-d.ts`).
|
|
247
|
-
2. **Nested object specs + recursive `Refine`.** The risk phase —
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
318
|
+
2. **Nested object specs + recursive `Refine`.** The risk phase — de-risked
|
|
319
|
+
by the type spike. ✅ *done* — full recursive grammar
|
|
320
|
+
(`FieldConstraint`/`ListConstraint`/`Validations` in
|
|
321
|
+
state/validations/types.ts) and recursive `Refine` with both spike
|
|
322
|
+
adjustments (value-model-first `RefineField` branch order; the identity
|
|
323
|
+
gate); recursive `validateEntry` walk with absent-section skip; the
|
|
324
|
+
spike's chunky/boundary/soundness/negative probe suite ported into
|
|
325
|
+
`useFormState.test-d.ts` (the error model needed no change, as
|
|
326
|
+
predicted — nested errors just carry longer paths). **The `each` TYPE
|
|
327
|
+
arm landed here too** (the spike proved the grammar whole, and carving
|
|
328
|
+
it out of `RefineField` would have been artificial), but the runtime
|
|
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
|
|
343
|
+
"object field literally named `each`" disambiguation probe, and the
|
|
344
|
+
`each: <bare validator>` negative (rejected by TypeScript's weak-type
|
|
345
|
+
check — an obscure checker rule worth a canary).
|
|
254
346
|
4. **Composition hardening.** Deep mixed fixtures (list-in-object-in-list),
|
|
255
347
|
`perField` still the entry point for pre-built specs, docs
|
|
256
348
|
(forms/CLAUDE.md) updated to the new grammar.
|
|
@@ -44,7 +44,8 @@ describe('FormDebugger', () => {
|
|
|
44
44
|
|
|
45
45
|
expect(screen.getByText('isValid')).toBeTruthy();
|
|
46
46
|
expect(screen.getByText('submitAttempted')).toBeTruthy();
|
|
47
|
-
|
|
47
|
+
// String leaves render with surrounding quotes (Chrome-inspector style).
|
|
48
|
+
expect(screen.getByText('"\'nickname\' cannot be empty"')).toBeTruthy();
|
|
48
49
|
});
|
|
49
50
|
|
|
50
51
|
test('the open window live-updates as the form changes', () => {
|
|
@@ -57,8 +58,8 @@ describe('FormDebugger', () => {
|
|
|
57
58
|
|
|
58
59
|
// The window survived the form re-render (stable component identity —
|
|
59
60
|
// a remount would have reset it to closed) and shows the new state.
|
|
60
|
-
expect(screen.getByText('will')).toBeTruthy();
|
|
61
|
-
expect(screen.queryByText("'nickname' cannot be empty")).toBeNull();
|
|
61
|
+
expect(screen.getByText('"will"')).toBeTruthy();
|
|
62
|
+
expect(screen.queryByText('"\'nickname\' cannot be empty"')).toBeNull();
|
|
62
63
|
});
|
|
63
64
|
|
|
64
65
|
test('clicking the trigger again closes the window', () => {
|
|
@@ -74,3 +74,132 @@ describe('deriveFormErrors', () => {
|
|
|
74
74
|
expect(deriveFormErrors(emptyForm, constraints)).toEqual([]);
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
|
+
|
|
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.
|
|
84
|
+
|
|
85
|
+
type ProfileForm = {
|
|
86
|
+
email: string | undefined;
|
|
87
|
+
homeAddress: {
|
|
88
|
+
city: string | undefined;
|
|
89
|
+
postalCode: string | undefined;
|
|
90
|
+
};
|
|
91
|
+
mailingAddress: { city: string | undefined } | undefined;
|
|
92
|
+
pets: Array<{ name: string | undefined }>;
|
|
93
|
+
pastPolicies: Array<{ insurer: string | undefined }> | null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const emptyProfile: ProfileForm = {
|
|
97
|
+
email: undefined,
|
|
98
|
+
homeAddress: { city: undefined, postalCode: undefined },
|
|
99
|
+
mailingAddress: undefined,
|
|
100
|
+
pets: [],
|
|
101
|
+
pastPolicies: null,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
describe('deriveFormErrors — nested object specs', () => {
|
|
105
|
+
test('a failing nested leaf contributes an entry addressed by its full path', () => {
|
|
106
|
+
const errors = deriveFormErrors(emptyProfile, {
|
|
107
|
+
homeAddress: { city: notEmpty('city') },
|
|
108
|
+
});
|
|
109
|
+
expect(errors).toEqual([
|
|
110
|
+
{ path: ['homeAddress', 'city'], error: "'city' cannot be empty" },
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('sibling fields fail independently — one entry per failing node', () => {
|
|
115
|
+
const errors = deriveFormErrors(emptyProfile, {
|
|
116
|
+
email: notEmpty('email'),
|
|
117
|
+
homeAddress: {
|
|
118
|
+
city: notEmpty('city'),
|
|
119
|
+
postalCode: notEmpty('postalCode'),
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
expect(errors).toEqual([
|
|
123
|
+
{ path: ['email'], error: "'email' cannot be empty" },
|
|
124
|
+
{ path: ['homeAddress', 'city'], error: "'city' cannot be empty" },
|
|
125
|
+
{
|
|
126
|
+
path: ['homeAddress', 'postalCode'],
|
|
127
|
+
error: "'postalCode' cannot be empty",
|
|
128
|
+
},
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('passing nested leaves contribute nothing', () => {
|
|
133
|
+
const errors = deriveFormErrors(
|
|
134
|
+
{
|
|
135
|
+
...emptyProfile,
|
|
136
|
+
homeAddress: { city: 'SF', postalCode: '94110' },
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
homeAddress: {
|
|
140
|
+
city: notEmpty('city'),
|
|
141
|
+
postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
expect(errors).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('a nested spec on an absent section is skipped', () => {
|
|
149
|
+
// Decided with the phase-2 type spike: the honest runtime mirror of the
|
|
150
|
+
// type level (which refines only the present branch of a nullable
|
|
151
|
+
// section) is to validate nothing when the section is absent.
|
|
152
|
+
const errors = deriveFormErrors(emptyProfile, {
|
|
153
|
+
mailingAddress: { city: notEmpty('city') },
|
|
154
|
+
});
|
|
155
|
+
expect(errors).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('the same nested spec fires once the section is present', () => {
|
|
159
|
+
const errors = deriveFormErrors(
|
|
160
|
+
{ ...emptyProfile, mailingAddress: { city: undefined } },
|
|
161
|
+
{ mailingAddress: { city: notEmpty('city') } },
|
|
162
|
+
);
|
|
163
|
+
expect(errors).toEqual([
|
|
164
|
+
{ path: ['mailingAddress', 'city'], error: "'city' cannot be empty" },
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a required section is a leaf validator on the section field', () => {
|
|
169
|
+
const errors = deriveFormErrors(emptyProfile, {
|
|
170
|
+
mailingAddress: notEmpty('mailingAddress'),
|
|
171
|
+
});
|
|
172
|
+
expect(errors).toEqual([
|
|
173
|
+
{ path: ['mailingAddress'], error: "'mailingAddress' cannot be empty" },
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
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
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Path } from '../path/types';
|
|
2
2
|
import type { Validations } from '../validations/types';
|
|
3
3
|
import { validateEntry } from '../validations/walk';
|
|
4
|
-
import type {
|
|
4
|
+
import type { ConstraintEntry } from '../validations/walk';
|
|
5
5
|
import type { FormError, FormValuesObject } from './types';
|
|
6
6
|
|
|
7
7
|
// The pure half of the hook's error model: current values + constraints in,
|
|
@@ -19,16 +19,16 @@ export const deriveFormErrors = <T extends FormValuesObject>(
|
|
|
19
19
|
for (const key of Object.keys(constraints) as (keyof T & string)[]) {
|
|
20
20
|
// No cast: if the grammar grows a constraint form the walk doesn't
|
|
21
21
|
// understand, this assignment is the compile error that says so.
|
|
22
|
-
const entry:
|
|
22
|
+
const entry: ConstraintEntry | undefined = constraints[key];
|
|
23
23
|
if (entry == null) continue;
|
|
24
|
-
const failure
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
for (const failure of validateEntry(entry, values[key], [key])) {
|
|
25
|
+
// The walk extends the address it was handed only along keys of specs
|
|
26
|
+
// type-checked against T's subtree, so every returned path is a valid
|
|
27
|
+
// Path<T>. TS can't compute Path<T> for an unresolved generic T to
|
|
28
|
+
// see the correlation, so it needs the same honest widening as the
|
|
29
|
+
// keys cast above.
|
|
30
|
+
errors.push({ path: failure.path as Path<T>, error: failure.error });
|
|
31
|
+
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
return errors;
|
|
@@ -2,9 +2,9 @@ import { describe, test, expect } from 'bun:test';
|
|
|
2
2
|
import { errorAt } from './errorAt';
|
|
3
3
|
import type { FormErrors } from './types';
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
+
// 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 };
|
|
@@ -11,15 +11,15 @@ describe('toInspectable', () => {
|
|
|
11
11
|
expect(toInspectable(true)).toBe(true);
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
test('
|
|
15
|
-
expect(toInspectable(['a', 'b'])).toEqual(
|
|
14
|
+
test('keeps arrays as arrays (JsonTable renders them natively)', () => {
|
|
15
|
+
expect(toInspectable(['a', 'b'])).toEqual(['a', 'b']);
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
test('
|
|
19
|
-
expect(toInspectable([{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
test('walks array elements recursively (Sets may hide inside)', () => {
|
|
19
|
+
expect(toInspectable([{ roles: new Set(['admin']) }, { name: 'bo' }])).toEqual([
|
|
20
|
+
{ roles: 'Set(1) { "admin" }' },
|
|
21
|
+
{ name: 'bo' },
|
|
22
|
+
]);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
test('renders Sets as a descriptive string leaf', () => {
|
|
@@ -35,8 +35,8 @@ describe('toInspectable', () => {
|
|
|
35
35
|
};
|
|
36
36
|
expect(toInspectable(form)).toEqual({
|
|
37
37
|
email: 'a@b.co',
|
|
38
|
-
address: { city: undefined, tags:
|
|
39
|
-
drivers: {
|
|
38
|
+
address: { city: undefined, tags: ['home'] },
|
|
39
|
+
drivers: [{ name: 'ada', incidents: [] }],
|
|
40
40
|
});
|
|
41
41
|
});
|
|
42
42
|
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Converts a form-state snapshot into a shape JsonTable renders without
|
|
2
|
-
// throwing. JsonTable
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// throwing. JsonTable recurses into plain objects and arrays; everything
|
|
3
|
+
// else falls to the leaf renderer, and a Set there would throw as a React
|
|
4
|
+
// child. A debugger must render *any* legal form state, so:
|
|
5
5
|
//
|
|
6
|
-
// - arrays → index-keyed plain objects ({ 0: ..., 1: ... }), recursively
|
|
7
6
|
// - Sets → a descriptive string leaf: `Set(2) { "a", "b" }`
|
|
7
|
+
// - arrays → walked recursively (a Set may hide inside), kept as arrays
|
|
8
8
|
// - objects → walked recursively
|
|
9
9
|
// - leaves → passed through untouched
|
|
10
10
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -20,9 +20,7 @@ export const toInspectable = (value: unknown): unknown => {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
if (Array.isArray(value)) {
|
|
23
|
-
return
|
|
24
|
-
value.map((element, index) => [index, toInspectable(element)]),
|
|
25
|
-
);
|
|
23
|
+
return value.map(toInspectable);
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
if (isPlainObject(value)) {
|
|
@@ -78,8 +78,8 @@ export type UnionPolicyCheck<T> = HasDisallowedUnion<T> extends true
|
|
|
78
78
|
: unknown;
|
|
79
79
|
|
|
80
80
|
// Structured error model: one entry per failing constrained node, addressed
|
|
81
|
-
// by a typed path
|
|
82
|
-
//
|
|
81
|
+
// by a typed path as deep as the node (root leaves get single-key paths,
|
|
82
|
+
// nested-spec leaves get the full address). Deliberately a plain list — at form
|
|
83
83
|
// scale a linear scan is fine, and serialized string keys ('drivers.0.name')
|
|
84
84
|
// are never exposed. Read a field's error with `errorAt` (./errorAt.ts),
|
|
85
85
|
// never by hand-assembled keys.
|
|
@@ -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(() =>
|