@structuralists/scaffolding 0.10.2 → 0.12.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/eslint.config.mjs +3 -3
- 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 +195 -41
- package/src/forms/elements/Input/index.tsx +2 -0
- package/src/forms/elements/Input/types.ts +2 -1
- package/src/forms/plan.md +146 -29
- package/src/forms/state/bindings/SingleSelectForForm.tsx +45 -0
- package/src/forms/state/bindings/TextInputForForm.tsx +45 -0
- package/src/forms/state/path/path.test.ts +71 -1
- package/src/forms/state/path/path.ts +50 -0
- package/src/forms/state/useFormState/FormDebugger.test.tsx +4 -3
- package/src/forms/state/useFormState/FormDebugger.tsx +1 -0
- package/src/forms/state/useFormState/deriveErrors.test.ts +94 -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/errorAt.ts +8 -12
- 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 +35 -4
- package/src/forms/state/useFormState/useFieldBinding.test.tsx +165 -0
- package/src/forms/state/useFormState/useFieldBinding.ts +71 -0
- package/src/forms/state/useFormState/useFormDebugger.test.tsx +1 -0
- package/src/forms/state/useFormState/useFormState.stories.tsx +190 -5
- package/src/forms/state/useFormState/useFormState.test-d.ts +516 -1
- package/src/forms/state/useFormState/useFormState.test.tsx +2 -2
- package/src/forms/state/useFormState/useFormState.ts +12 -3
- package/src/forms/state/validations/types.ts +77 -17
- package/src/forms/state/validations/walk.test.ts +159 -19
- package/src/forms/state/validations/walk.ts +86 -25
- package/tokens.css +55 -0
package/src/forms/plan.md
CHANGED
|
@@ -28,17 +28,35 @@ 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>/`,
|
|
35
36
|
the state layer in `src/forms/state/{useFormState,validations,validators,path}/`.
|
|
36
37
|
5. **Item 6 — field binding** (`getFormFieldPropsAt` + wrappers). Needs the
|
|
37
|
-
error model and the split; `Path`/`ValueAt` already validated.
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
error model and the split; `Path`/`ValueAt` already validated. ✅ *done* —
|
|
39
|
+
`getFormFieldPropsAt` on the hook result (value / typed onChange /
|
|
40
|
+
policy-aware errorMessage / onBlur), `write()` as the immutable mirror of
|
|
41
|
+
`read()` in `state/path/path.ts`, touched tracking as real state, and the
|
|
42
|
+
wrapper-style element shorthands prototyped (`state/bindings/`:
|
|
43
|
+
`TextInputForForm`, `SingleSelectForForm`). Probe ratchet held (see the
|
|
44
|
+
baseline note under the recursion budget).
|
|
45
|
+
6. **Type spike, then items 2/3/4** — the recursion risk zone. ← *current*
|
|
46
|
+
Throwaway `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
|
|
40
47
|
any runtime work; three outcomes (works / slow / intractable) each with a
|
|
41
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.
|
|
42
60
|
|
|
43
61
|
## Goal
|
|
44
62
|
|
|
@@ -120,30 +138,55 @@ useFormState({
|
|
|
120
138
|
|
|
121
139
|
## Refinement through the grammar
|
|
122
140
|
|
|
123
|
-
`Refine`
|
|
141
|
+
`Refine` is recursive, mirroring the grammar. As shipped (phase 2, with both
|
|
142
|
+
type-spike adjustments):
|
|
124
143
|
|
|
125
144
|
```ts
|
|
126
|
-
type ExcludedOf<V> = V extends Refinement<infer X> ? X : never;
|
|
127
145
|
// MemberExcludes<C>: the per-member-sound union of a validator tuple's
|
|
128
146
|
// excludes — it and SoundExcludedOf landed with phase 1 in state/validations/types.ts.
|
|
129
147
|
|
|
130
|
-
type RefineField<F, C> =
|
|
131
|
-
|
|
132
|
-
: C extends readonly unknown[]
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
148
|
+
type RefineField<F, C> = C extends Refinement<infer Excluded>
|
|
149
|
+
? Exclude<F, Excluded> // single marked validator
|
|
150
|
+
: C extends readonly unknown[]
|
|
151
|
+
? Exclude<F, MemberExcludes<C>> // validator array: union of per-member-sound excludes
|
|
152
|
+
: C extends object // structural: interrogate F (the value model) FIRST
|
|
153
|
+
? F extends FormValueList
|
|
154
|
+
? C extends { readonly each: infer E }
|
|
155
|
+
? Array<RefineObject<F[number], E>> // per-element
|
|
156
|
+
: F
|
|
157
|
+
: F extends FormValuesObject
|
|
158
|
+
? RefineObject<F, C> // nested spec
|
|
159
|
+
: F
|
|
160
|
+
: F; // bare fn / no marker
|
|
138
161
|
|
|
139
162
|
type RefineObject<T, V> = { [K in keyof T]: K extends keyof V ? RefineField<T[K], V[K]> : T[K] };
|
|
163
|
+
|
|
164
|
+
// The identity gate — NOT optional (spike adjustment 2): with constraints
|
|
165
|
+
// omitted, the default V = Validations<T> would distribute through the walk
|
|
166
|
+
// and hand back unions of structurally-identical mapped copies of every
|
|
167
|
+
// section — assignable to T but not identity-equal (mangled hover types,
|
|
168
|
+
// fails toEqualTypeOf). Validations<T> extends V exactly when V is the
|
|
169
|
+
// default (or empty/fully-widened — no markers survive anyway).
|
|
170
|
+
type Refine<T extends FormValuesObject, V extends Validations<T>> =
|
|
171
|
+
Validations<T> extends V ? T : RefineObject<T, V>;
|
|
140
172
|
```
|
|
141
173
|
|
|
142
|
-
Branch-order constraints
|
|
143
|
-
in:
|
|
174
|
+
Branch-order constraints baked in (baseline + spike):
|
|
144
175
|
|
|
145
176
|
- **Check `Refinement` before `object`** — validator functions are objects.
|
|
146
|
-
- **
|
|
177
|
+
- **The structural arms branch on the value model (`F`) before the
|
|
178
|
+
constraint's shape** (spike adjustment 1): asking `C extends { each }`
|
|
179
|
+
first would strand an object field that legitimately owns a key named
|
|
180
|
+
`each` — its nested spec would match the `each` arm, fail the list test,
|
|
181
|
+
and fall back unrefined. Pinned by the `audit` disambiguation probe in
|
|
182
|
+
`useFormState.test-d.ts`.
|
|
183
|
+
- **A bare function** (no marker) falls through: on a scalar field to the
|
|
184
|
+
final `F` arm; on a structural field into `RefineObject<F, C>` with
|
|
185
|
+
`keyof C` empty — an identity map, structurally unchanged, as a
|
|
186
|
+
whole-value validator should be.
|
|
187
|
+
- Nested occurrences need no identity gate: a concrete literal `V` has only
|
|
188
|
+
the keys actually written, so uncovered fields take the `T[K]` arm
|
|
189
|
+
verbatim at every level.
|
|
147
190
|
- The runtime cast in `submit()` stays the same single honest cast: valid ⇒
|
|
148
191
|
every constrained node passed, at every depth, which is what the recursive
|
|
149
192
|
`Refine` now encodes.
|
|
@@ -187,11 +230,46 @@ multi-second check time:
|
|
|
187
230
|
- post-step-3 (error-model swap: `Path<T>`-typed `FormError`, `errorAt`,
|
|
188
231
|
plus probes): check 0.79 s, 113,724 instantiations, 52,294 types
|
|
189
232
|
(+4.4% instantiations over pre-step-3 HEAD)
|
|
233
|
+
- post-item-5 split (0.10.1): check 0.81 s, 112,981 instantiations,
|
|
234
|
+
52,504 types
|
|
235
|
+
- post-item-6 (field binding: `getFormFieldPropsAt`, `FieldBinding`
|
|
236
|
+
wrappers, plus deep-path probes at `InsuranceQuoteForm` scale): check
|
|
237
|
+
0.82 s, 125,022 instantiations, 54,959 types (+10.7% instantiations over
|
|
238
|
+
post-item-5 — the `ValueAt` instantiations at each binding call site are
|
|
239
|
+
real but cheap; check time flat)
|
|
240
|
+
- type spike (full recursive grammar, chunky + stress probes, throwaway
|
|
241
|
+
worktree — not merged): 163,262 instantiations / 65,036 types / 0.86s
|
|
242
|
+
check — 1.31× post-item-6; verdict works, no mitigation tricks required.
|
|
243
|
+
The real phases should land well under that: the spike carried a stress
|
|
244
|
+
tier (~110-leaf mega form with a large spec inlined twice, 12-level
|
|
245
|
+
object ladder, 4-deep `each` ladder) the phases don't.
|
|
246
|
+
- post-phase-2 (recursive grammar + recursive `Refine` with identity gate,
|
|
247
|
+
ported chunky/boundary/negative probe suite, recursive walk): check
|
|
248
|
+
0.87 s, 133,956 instantiations, 57,762 types (+7.1% instantiations over
|
|
249
|
+
post-item-6; matches the spike's equivalent non-stress milestone at
|
|
250
|
+
134,441 almost exactly)
|
|
190
251
|
|
|
191
252
|
## Runtime consequences (can't be dodged)
|
|
192
253
|
|
|
193
|
-
- **The validator walk becomes a tree walk.**
|
|
194
|
-
`state/
|
|
254
|
+
- **The validator walk becomes a tree walk.** ✅ *Landed with phase 2*
|
|
255
|
+
(`validateEntry` in `state/validations/walk.ts` recurses, accumulating the
|
|
256
|
+
`PathStep[]` address; deliberately parallel to `read()`, not shared —
|
|
257
|
+
the walk descends a *constraints* tree, `read()` a *path*). Interpretation
|
|
258
|
+
of a structural spec is directed by the VALUE at the path, mirroring the
|
|
259
|
+
type level (see "Disambiguation").
|
|
260
|
+
- **Absent sections: a nested spec on an absent/null section is skipped.**
|
|
261
|
+
*Decided with the type spike.* The type level commits to this implicitly —
|
|
262
|
+
`F` distributes naked through the structural grammar arms, so a spec on
|
|
263
|
+
`UsAddress | undefined` refines only the present branch and the
|
|
264
|
+
nullability survives around the refined interior — and the runtime walk
|
|
265
|
+
mirrors it: nothing to walk when the section is absent (same for `each`
|
|
266
|
+
over a null list). A **required section** is expressed as a *leaf*
|
|
267
|
+
validator on the section field (`mailingAddress: notEmpty(…)`), which
|
|
268
|
+
stays legal on structural fields — with the recorded caveat that leaf and
|
|
269
|
+
structural forms are mutually exclusive per key (the "whole-value +
|
|
270
|
+
structural constraints on the same key" open decision below). Never
|
|
271
|
+
"help" the grammar arms with `NonNullable<F>` wrappers — that breaks the
|
|
272
|
+
distribution and blows the recursion stack (TS2589).
|
|
195
273
|
- **`FormErrors` can no longer be `Partial<Record<keyof T, string>>`.**
|
|
196
274
|
✅ *Landed with working-order step 3* (on the flat baseline, single-key
|
|
197
275
|
paths). Decided: errors become a plain list of structured entries,
|
|
@@ -231,13 +309,29 @@ tests, story updates where visible, probe ratchet.
|
|
|
231
309
|
distributes over a naked constraint parameter, which made union-typed
|
|
232
310
|
*single* constraints (`cond ? v1 : v2`) sound too (union of per-branch
|
|
233
311
|
refinements, pinned in `state/validations/types.test-d.ts`).
|
|
234
|
-
2. **Nested object specs + recursive `Refine`.** The risk phase —
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
312
|
+
2. **Nested object specs + recursive `Refine`.** The risk phase — de-risked
|
|
313
|
+
by the type spike. ✅ *done* — full recursive grammar
|
|
314
|
+
(`FieldConstraint`/`ListConstraint`/`Validations` in
|
|
315
|
+
state/validations/types.ts) and recursive `Refine` with both spike
|
|
316
|
+
adjustments (value-model-first `RefineField` branch order; the identity
|
|
317
|
+
gate); recursive `validateEntry` walk with absent-section skip; the
|
|
318
|
+
spike's chunky/boundary/soundness/negative probe suite ported into
|
|
319
|
+
`useFormState.test-d.ts` (the error model needed no change, as
|
|
320
|
+
predicted — nested errors just carry longer paths). **The `each` TYPE
|
|
321
|
+
arm landed here too** (the spike proved the grammar whole, and carving
|
|
322
|
+
it out of `RefineField` would have been artificial), but the runtime
|
|
323
|
+
walk for it is phase 3 — until then an `each` constraint on a present
|
|
324
|
+
list THROWS from the walk (pinned in walk.test.ts) rather than silently
|
|
325
|
+
not validating; on a null list it skips, which already matches phase-3
|
|
326
|
+
semantics.
|
|
327
|
+
3. **List `each` specs — runtime.** The walk visits every element; error
|
|
328
|
+
paths carry the numeric step (`['drivers', 3, 'name']`), replacing the
|
|
329
|
+
phase-2 throw. (The type level — refined element flowing through
|
|
330
|
+
`Array<...>` — already landed with phase 2.) Two probes the spike says
|
|
331
|
+
to keep pinned are already in the phase-2 suite: the `audit`-style
|
|
332
|
+
"object field literally named `each`" disambiguation probe, and the
|
|
333
|
+
`each: <bare validator>` negative (rejected by TypeScript's weak-type
|
|
334
|
+
check — an obscure checker rule worth a canary).
|
|
241
335
|
4. **Composition hardening.** Deep mixed fixtures (list-in-object-in-list),
|
|
242
336
|
`perField` still the entry point for pre-built specs, docs
|
|
243
337
|
(forms/CLAUDE.md) updated to the new grammar.
|
|
@@ -275,7 +369,29 @@ Blast radius that was handled when this landed:
|
|
|
275
369
|
Debugger is a designated bridge layer). Explicitly allowed for in the new
|
|
276
370
|
boundaries rules.
|
|
277
371
|
|
|
278
|
-
## 6. Field binding: `getFormFieldPropsAt(path)` + element shorthands
|
|
372
|
+
## 6. Field binding: `getFormFieldPropsAt(path)` + element shorthands ✅ done
|
|
373
|
+
|
|
374
|
+
Landed as specced below; deltas and decisions:
|
|
375
|
+
|
|
376
|
+
- `write()` in `state/path/path.ts` is the immutable-update mirror of
|
|
377
|
+
`read()` — clones only the spine, dead-step semantics mirror read()
|
|
378
|
+
returning undefined (writing through an absent section is an
|
|
379
|
+
identity-preserving no-op; materialize the section first).
|
|
380
|
+
- Touched is real state (`useFieldBinding`), a `Path<T>[]` compared with
|
|
381
|
+
`pathsEqual` — the same structural representation as `FormErrors`. It
|
|
382
|
+
also shows up in the Debugger snapshot.
|
|
383
|
+
- The error-display policy (touched-or-submitAttempted) lives inside
|
|
384
|
+
`errorMessage`, nowhere else — see "Field binding" in forms/CLAUDE.md.
|
|
385
|
+
- **Element-shorthand style: the wrapper style is the prototype**
|
|
386
|
+
(`TextInputForForm`, `SingleSelectForForm` in `src/forms/state/bindings/`,
|
|
387
|
+
the second sanctioned state→elements bridge after the Debugger). The
|
|
388
|
+
wrappers declare what they display/emit via `FieldBinding<Display, Emit>`
|
|
389
|
+
and plain structural assignability rejects wrong-shaped bindings — no
|
|
390
|
+
generics at the use site. Union-typed props on the elements themselves
|
|
391
|
+
remain the fallback if the parallel component set gets heavy; judge after
|
|
392
|
+
the wrapper set grows past these two.
|
|
393
|
+
|
|
394
|
+
Original spec follows.
|
|
279
395
|
|
|
280
396
|
Today every field is wired by hand in JSX: read `values.x`, spread-update via
|
|
281
397
|
`onValueChanges`, look up the error, gate it on `submitAttempted`. That's
|
|
@@ -427,8 +543,9 @@ Possibly a hybrid: identity-matching as the default heuristic, owned ops as
|
|
|
427
543
|
the guaranteed path. **Research topic for when we get here** — record
|
|
428
544
|
findings in the learning map before committing to either.
|
|
429
545
|
|
|
430
|
-
Sequencing: visited tracking
|
|
431
|
-
path
|
|
546
|
+
Sequencing: visited tracking ✅ *landed with item 6* (its `onBlur` is the
|
|
547
|
+
write path; `touched: Path<T>[]` in `useFieldBinding`, structural equality
|
|
548
|
+
via `pathsEqual`). Keys + array-sync matter from phase 3 (list `each` specs) onward —
|
|
432
549
|
error paths and per-element meta both need element identity once lists are
|
|
433
550
|
editable.
|
|
434
551
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Field } from '../../elements/Field';
|
|
2
|
+
import { SingleSelect } from '../../elements/Select';
|
|
3
|
+
import type { SelectOption, SelectSize } from '../../elements/Select';
|
|
4
|
+
import type { FieldBinding } from '../useFormState/types';
|
|
5
|
+
|
|
6
|
+
export type SingleSelectForFormProps<T extends string> = {
|
|
7
|
+
// Displays T|null|undefined, emits T — a FormFieldProps<V> fits when the
|
|
8
|
+
// path's value type sits between them, so the options' literal union and
|
|
9
|
+
// the field's type must line up or the binding fails to compile.
|
|
10
|
+
formFieldProps: FieldBinding<T | null | undefined, T>;
|
|
11
|
+
options: SelectOption<T>[];
|
|
12
|
+
label: string;
|
|
13
|
+
hint?: string;
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
size?: SelectSize;
|
|
16
|
+
isDisabled?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// The form-aware flavor of the single select. A select has no meaningful
|
|
20
|
+
// blur moment — committing an option IS the completed interaction — so the
|
|
21
|
+
// commit marks the field touched alongside the write.
|
|
22
|
+
export const SingleSelectForForm = <T extends string>(
|
|
23
|
+
props: SingleSelectForFormProps<T>,
|
|
24
|
+
) => {
|
|
25
|
+
const { formFieldProps, options, label, hint, placeholder, size, isDisabled } =
|
|
26
|
+
props;
|
|
27
|
+
const { value, onChange, errorMessage, onBlur } = formFieldProps;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Field label={label} hint={hint} error={errorMessage}>
|
|
31
|
+
<SingleSelect
|
|
32
|
+
options={options}
|
|
33
|
+
value={value ?? null}
|
|
34
|
+
onChange={(next) => {
|
|
35
|
+
onChange(next);
|
|
36
|
+
onBlur();
|
|
37
|
+
}}
|
|
38
|
+
size={size}
|
|
39
|
+
isDisabled={isDisabled}
|
|
40
|
+
placeholder={placeholder}
|
|
41
|
+
ariaLabel={label}
|
|
42
|
+
/>
|
|
43
|
+
</Field>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Field } from '../../elements/Field';
|
|
2
|
+
import { Input } from '../../elements/Input';
|
|
3
|
+
import type { InputProps } from '../../elements/Input';
|
|
4
|
+
import type { FieldBinding } from '../useFormState/types';
|
|
5
|
+
|
|
6
|
+
export type TextInputForFormProps = {
|
|
7
|
+
// The bundle from `getFormFieldPropsAt(path)`. The binding shape declares
|
|
8
|
+
// what this element can do — display string-ish, emit string — so a
|
|
9
|
+
// FormFieldProps<V> only fits when V is a text-shaped field
|
|
10
|
+
// (string / string|null / string|undefined / both). A number- or
|
|
11
|
+
// boolean-typed path is a compile error right here.
|
|
12
|
+
formFieldProps: FieldBinding<string | null | undefined, string>;
|
|
13
|
+
label: string;
|
|
14
|
+
hint?: string;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
id?: string;
|
|
17
|
+
type?: InputProps['type'];
|
|
18
|
+
size?: InputProps['size'];
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// The form-aware flavor of the text input: one prop wires value, change,
|
|
23
|
+
// error display, and touched tracking. Composes Field (label/hint/error
|
|
24
|
+
// presentation) around Input; the error-display policy already happened
|
|
25
|
+
// inside `errorMessage` — this component just renders what it's given.
|
|
26
|
+
export const TextInputForForm = (props: TextInputForFormProps) => {
|
|
27
|
+
const { formFieldProps, label, hint, placeholder, id, type, size, disabled } =
|
|
28
|
+
props;
|
|
29
|
+
const { value, onChange, errorMessage, onBlur } = formFieldProps;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Field label={label} hint={hint} error={errorMessage} htmlFor={id}>
|
|
33
|
+
<Input
|
|
34
|
+
id={id}
|
|
35
|
+
type={type}
|
|
36
|
+
size={size}
|
|
37
|
+
value={value ?? ''}
|
|
38
|
+
onChange={(e) => onChange(e.target.value)}
|
|
39
|
+
onBlur={onBlur}
|
|
40
|
+
placeholder={placeholder}
|
|
41
|
+
disabled={disabled}
|
|
42
|
+
/>
|
|
43
|
+
</Field>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from 'bun:test';
|
|
2
|
-
import { path, read } from './path';
|
|
2
|
+
import { path, pathsEqual, read, write } from './path';
|
|
3
3
|
|
|
4
4
|
// These tests pin read()'s runtime semantics to exactly what the type level
|
|
5
5
|
// (ValueAt) promises — see the union-semantics comments in ./types.ts and
|
|
@@ -95,6 +95,65 @@ describe('read step failures', () => {
|
|
|
95
95
|
).toBeUndefined();
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
test('write replaces the value at a deep path without mutating the original', () => {
|
|
99
|
+
const next = write(filled, ['address', 'city'], 'Paris') as Form;
|
|
100
|
+
|
|
101
|
+
expect(next.address?.city).toBe('Paris');
|
|
102
|
+
expect(filled.address?.city).toBe('London');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('write clones only the spine — untouched siblings keep their identity', () => {
|
|
106
|
+
const next = write(filled, ['a', 'b', 'c'], 'deeper') as Form;
|
|
107
|
+
|
|
108
|
+
// The spine to the written leaf is new at every level …
|
|
109
|
+
expect(next).not.toBe(filled);
|
|
110
|
+
expect(next.a).not.toBe(filled.a);
|
|
111
|
+
expect(next.a?.b).not.toBe(filled.a?.b);
|
|
112
|
+
// … while every branch off the spine is the same object.
|
|
113
|
+
expect(next.address).toBe(filled.address);
|
|
114
|
+
expect(next.entries).toBe(filled.entries);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('write into a list element clones the array and that element only', () => {
|
|
118
|
+
const twoEntries: Form = {
|
|
119
|
+
...filled,
|
|
120
|
+
entries: [
|
|
121
|
+
{ title: 'first', qty: 1 },
|
|
122
|
+
{ title: 'second', qty: 2 },
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
const next = write(twoEntries, ['entries', 1, 'qty'], 5) as Form;
|
|
126
|
+
|
|
127
|
+
expect(next.entries?.[1]).toEqual({ title: 'second', qty: 5 });
|
|
128
|
+
expect(next.entries).not.toBe(twoEntries.entries);
|
|
129
|
+
expect(next.entries?.[0]).toBe(twoEntries.entries?.[0]);
|
|
130
|
+
expect(twoEntries.entries?.[1].qty).toBe(2);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('write with an empty path replaces the root', () => {
|
|
134
|
+
expect(write(filled, [], { name: 'Eve' })).toEqual({ name: 'Eve' });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('write through a dead ancestor is an identity-preserving no-op', () => {
|
|
138
|
+
// Mirrors read() returning undefined for a dead step: you cannot edit a
|
|
139
|
+
// field of an absent section — materialize the section first.
|
|
140
|
+
expect(write(empty, ['address', 'city'], 'Paris')).toBe(empty);
|
|
141
|
+
expect(write(empty, ['entries', 0, 'title'], 'x')).toBe(empty);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('write on a shape mismatch or out-of-bounds index is a no-op', () => {
|
|
145
|
+
// Numeric step on a non-array, key step on a scalar, index past the end
|
|
146
|
+
// (appending is list manipulation, not a path write).
|
|
147
|
+
expect(write(filled, ['name', 'length'], 3)).toBe(filled);
|
|
148
|
+
expect(write(filled, ['entries', 5, 'title'], 'x')).toBe(filled);
|
|
149
|
+
const list = [1, 2];
|
|
150
|
+
expect(write(list, ['key'], 'x')).toBe(list);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('writing the value already present is an identity-preserving no-op', () => {
|
|
154
|
+
expect(write(filled, ['address', 'city'], 'London')).toBe(filled);
|
|
155
|
+
});
|
|
156
|
+
|
|
98
157
|
test('rejecting narrow predicate returns undefined; accepting one passes through', () => {
|
|
99
158
|
const isString = (val: unknown): val is string => typeof val === 'string';
|
|
100
159
|
const narrowed = path<Form>()
|
|
@@ -110,3 +169,14 @@ describe('read step failures', () => {
|
|
|
110
169
|
expect(read(filled, rejecting)).toBeUndefined();
|
|
111
170
|
});
|
|
112
171
|
});
|
|
172
|
+
|
|
173
|
+
describe('pathsEqual', () => {
|
|
174
|
+
test('structural equality over steps — same steps equal, no prefix matching', () => {
|
|
175
|
+
expect(pathsEqual(['a', 0, 'b'], ['a', 0, 'b'])).toBe(true);
|
|
176
|
+
expect(pathsEqual([], [])).toBe(true);
|
|
177
|
+
expect(pathsEqual(['a'], ['a', 'b'])).toBe(false);
|
|
178
|
+
expect(pathsEqual(['a', 0], ['a', 1])).toBe(false);
|
|
179
|
+
// A numeric step never equals its string spelling.
|
|
180
|
+
expect(pathsEqual(['a', 0], ['a', '0'])).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -22,6 +22,15 @@ const makeCursor = <T>(steps: readonly CursorStep[]): Cursor<T> => ({
|
|
|
22
22
|
|
|
23
23
|
export const path = <T>(): Cursor<T> => makeCursor<T>([]);
|
|
24
24
|
|
|
25
|
+
// Structural path equality: same steps, same order, no prefix matching.
|
|
26
|
+
// The one sanctioned way to compare paths — `errorAt` and touched tracking
|
|
27
|
+
// both build on it, so "do these address the same field?" has exactly one
|
|
28
|
+
// definition.
|
|
29
|
+
export const pathsEqual = (
|
|
30
|
+
a: readonly PathStep[],
|
|
31
|
+
b: readonly PathStep[],
|
|
32
|
+
): boolean => a.length === b.length && a.every((step, index) => step === b[index]);
|
|
33
|
+
|
|
25
34
|
// Walk steps against a value. Returns undefined if any step fails — a dead
|
|
26
35
|
// value (null or undefined) encountered mid-path, missing key, out-of-bounds
|
|
27
36
|
// index, or a narrow predicate that rejects the current value. Always
|
|
@@ -51,3 +60,44 @@ export const read = (root: unknown, steps: readonly CursorStep[]): unknown => {
|
|
|
51
60
|
|
|
52
61
|
return cursor;
|
|
53
62
|
};
|
|
63
|
+
|
|
64
|
+
// The immutable-update mirror of read(): returns a new root with `value`
|
|
65
|
+
// placed at `steps`, cloning only the containers along the path — untouched
|
|
66
|
+
// siblings keep their identity. Key-only steps (a `Path<T>` value), not
|
|
67
|
+
// CursorStep — there is no meaningful way to write "through" a narrow
|
|
68
|
+
// predicate.
|
|
69
|
+
//
|
|
70
|
+
// Dead-step semantics mirror read() returning undefined: a step that hits a
|
|
71
|
+
// null/undefined container, a shape mismatch (key step on a non-object,
|
|
72
|
+
// numeric step on a non-array), or an out-of-bounds index makes the whole
|
|
73
|
+
// write a no-op that returns `root` by identity — you can't edit a field of
|
|
74
|
+
// an absent section; materialize the section first. (Appending/splicing is
|
|
75
|
+
// list-manipulation territory, deliberately not a path write.) Writing a
|
|
76
|
+
// value that is already there (Object.is) is also an identity-preserving
|
|
77
|
+
// no-op, so state setters can bail out of re-rendering.
|
|
78
|
+
export const write = (
|
|
79
|
+
root: unknown,
|
|
80
|
+
steps: readonly PathStep[],
|
|
81
|
+
value: unknown,
|
|
82
|
+
): unknown => {
|
|
83
|
+
if (steps.length === 0) return value;
|
|
84
|
+
const [head, ...rest] = steps;
|
|
85
|
+
|
|
86
|
+
if (typeof head === 'number') {
|
|
87
|
+
if (!Array.isArray(root)) return root;
|
|
88
|
+
if (head < 0 || head >= root.length) return root;
|
|
89
|
+
const child = write(root[head], rest, value);
|
|
90
|
+
if (Object.is(child, root[head])) return root;
|
|
91
|
+
const clone = root.slice();
|
|
92
|
+
clone[head] = child;
|
|
93
|
+
return clone;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (root === null || typeof root !== 'object' || Array.isArray(root)) {
|
|
97
|
+
return root;
|
|
98
|
+
}
|
|
99
|
+
const record = root as Record<string, unknown>;
|
|
100
|
+
const child = write(record[head], rest, value);
|
|
101
|
+
if (Object.is(child, record[head])) return root;
|
|
102
|
+
return { ...record, [head]: child };
|
|
103
|
+
};
|
|
@@ -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,97 @@ describe('deriveFormErrors', () => {
|
|
|
74
74
|
expect(deriveFormErrors(emptyForm, constraints)).toEqual([]);
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
// The recursive grammar: nested object specs walk the value tree and
|
|
79
|
+
// address failures with real multi-step paths. (List `each` specs are
|
|
80
|
+
// type-level only until plan phase 3 — the walk throws on one; pinned in
|
|
81
|
+
// walk.test.ts.)
|
|
82
|
+
|
|
83
|
+
type ProfileForm = {
|
|
84
|
+
email: string | undefined;
|
|
85
|
+
homeAddress: {
|
|
86
|
+
city: string | undefined;
|
|
87
|
+
postalCode: string | undefined;
|
|
88
|
+
};
|
|
89
|
+
mailingAddress: { city: string | undefined } | undefined;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const emptyProfile: ProfileForm = {
|
|
93
|
+
email: undefined,
|
|
94
|
+
homeAddress: { city: undefined, postalCode: undefined },
|
|
95
|
+
mailingAddress: undefined,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
describe('deriveFormErrors — nested object specs', () => {
|
|
99
|
+
test('a failing nested leaf contributes an entry addressed by its full path', () => {
|
|
100
|
+
const errors = deriveFormErrors(emptyProfile, {
|
|
101
|
+
homeAddress: { city: notEmpty('city') },
|
|
102
|
+
});
|
|
103
|
+
expect(errors).toEqual([
|
|
104
|
+
{ path: ['homeAddress', 'city'], error: "'city' cannot be empty" },
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('sibling fields fail independently — one entry per failing node', () => {
|
|
109
|
+
const errors = deriveFormErrors(emptyProfile, {
|
|
110
|
+
email: notEmpty('email'),
|
|
111
|
+
homeAddress: {
|
|
112
|
+
city: notEmpty('city'),
|
|
113
|
+
postalCode: notEmpty('postalCode'),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
expect(errors).toEqual([
|
|
117
|
+
{ path: ['email'], error: "'email' cannot be empty" },
|
|
118
|
+
{ path: ['homeAddress', 'city'], error: "'city' cannot be empty" },
|
|
119
|
+
{
|
|
120
|
+
path: ['homeAddress', 'postalCode'],
|
|
121
|
+
error: "'postalCode' cannot be empty",
|
|
122
|
+
},
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('passing nested leaves contribute nothing', () => {
|
|
127
|
+
const errors = deriveFormErrors(
|
|
128
|
+
{
|
|
129
|
+
...emptyProfile,
|
|
130
|
+
homeAddress: { city: 'SF', postalCode: '94110' },
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
homeAddress: {
|
|
134
|
+
city: notEmpty('city'),
|
|
135
|
+
postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
expect(errors).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a nested spec on an absent section is skipped', () => {
|
|
143
|
+
// Decided with the phase-2 type spike: the honest runtime mirror of the
|
|
144
|
+
// type level (which refines only the present branch of a nullable
|
|
145
|
+
// section) is to validate nothing when the section is absent.
|
|
146
|
+
const errors = deriveFormErrors(emptyProfile, {
|
|
147
|
+
mailingAddress: { city: notEmpty('city') },
|
|
148
|
+
});
|
|
149
|
+
expect(errors).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('the same nested spec fires once the section is present', () => {
|
|
153
|
+
const errors = deriveFormErrors(
|
|
154
|
+
{ ...emptyProfile, mailingAddress: { city: undefined } },
|
|
155
|
+
{ mailingAddress: { city: notEmpty('city') } },
|
|
156
|
+
);
|
|
157
|
+
expect(errors).toEqual([
|
|
158
|
+
{ path: ['mailingAddress', 'city'], error: "'city' cannot be empty" },
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('a required section is a leaf validator on the section field', () => {
|
|
163
|
+
const errors = deriveFormErrors(emptyProfile, {
|
|
164
|
+
mailingAddress: notEmpty('mailingAddress'),
|
|
165
|
+
});
|
|
166
|
+
expect(errors).toEqual([
|
|
167
|
+
{ path: ['mailingAddress'], error: "'mailingAddress' cannot be empty" },
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -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;
|