@structuralists/scaffolding 0.10.2 → 0.11.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/forms/CLAUDE.md +91 -15
- package/src/forms/elements/Input/index.tsx +2 -0
- package/src/forms/elements/Input/types.ts +2 -1
- package/src/forms/plan.md +42 -6
- 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.tsx +1 -0
- package/src/forms/state/useFormState/errorAt.ts +8 -12
- package/src/forms/state/useFormState/types.ts +33 -2
- 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 +163 -0
- package/src/forms/state/useFormState/useFormState.test-d.ts +80 -1
- package/src/forms/state/useFormState/useFormState.ts +12 -3
package/eslint.config.mjs
CHANGED
|
@@ -132,10 +132,10 @@ export default [{
|
|
|
132
132
|
"Files inside a primitive folder must not import their own index barrel; import the neighbor directly.",
|
|
133
133
|
},
|
|
134
134
|
// Presentational primitives (components and form elements) must
|
|
135
|
-
// not reach into the form-state layer. The sanctioned
|
|
135
|
+
// not reach into the form-state layer. The sanctioned bridges run
|
|
136
136
|
// the other way: state's FormDebugger imports the JsonTable barrel
|
|
137
|
-
// (dev tooling), and the
|
|
138
|
-
//
|
|
137
|
+
// (dev tooling), and the form-aware wrappers live on the state
|
|
138
|
+
// side (state/bindings/). State stories may also use element barrels.
|
|
139
139
|
{
|
|
140
140
|
from: [
|
|
141
141
|
{ type: 'primitive' },
|
package/package.json
CHANGED
package/src/forms/CLAUDE.md
CHANGED
|
@@ -10,10 +10,11 @@ The forms umbrella, split into two named subtrees:
|
|
|
10
10
|
- `state/` — the strongly-typed React form state hook and its machinery.
|
|
11
11
|
Everything below this heading is about the state layer.
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
`state/useFormState/FormDebugger.tsx` imports the `JsonTable`
|
|
15
|
-
`src/components/Json
|
|
16
|
-
|
|
13
|
+
Two sanctioned state→elements-side bridges exist, both on the state side:
|
|
14
|
+
the Debugger (`state/useFormState/FormDebugger.tsx` imports the `JsonTable`
|
|
15
|
+
barrel from `src/components/Json/`, dev tooling) and the form-aware element
|
|
16
|
+
wrappers (`state/bindings/` — see "Field binding" below). State files
|
|
17
|
+
importing an element go through its barrel like any external importer.
|
|
17
18
|
|
|
18
19
|
The headline feature of the state layer is **validation that propagates type
|
|
19
20
|
refinements to the submit handler** — passing the right constraints means the
|
|
@@ -201,11 +202,74 @@ errorAt(errors, ['email']); // string | undefined
|
|
|
201
202
|
```
|
|
202
203
|
|
|
203
204
|
`errorAt` (state/useFormState/errorAt.ts) matches by exact structural step
|
|
204
|
-
equality (no prefix matching); with
|
|
205
|
-
most one entry per path, and if
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
build on.
|
|
205
|
+
equality (`pathsEqual` in state/path/path.ts — no prefix matching); with
|
|
206
|
+
first-error-wins validation there is at most one entry per path, and if
|
|
207
|
+
collect-all ever lands the first entry stays the one shown. The
|
|
208
|
+
path-addressed model is what the recursive grammar (errors just carry
|
|
209
|
+
longer paths) and `getFormFieldPropsAt`'s `errorMessage` build on. `errorAt`
|
|
210
|
+
is raw truth (no display gating); `errorMessage` is the display-policy-aware
|
|
211
|
+
reading (see "Field binding").
|
|
212
|
+
|
|
213
|
+
## Field binding: `getFormFieldPropsAt(path)` + `state/bindings/`
|
|
214
|
+
|
|
215
|
+
One expression wires a field to the form:
|
|
216
|
+
|
|
217
|
+
```tsx
|
|
218
|
+
<TextInputForForm label="City"
|
|
219
|
+
formFieldProps={getFormFieldPropsAt(['homeAddress', 'city'])} />
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`getFormFieldPropsAt` is a member of the hook's return value, typed by the
|
|
223
|
+
`state/path/` machinery:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
// on FormHelpers<T>:
|
|
227
|
+
getFormFieldPropsAt<P extends Path<T>>(path: P): FormFieldProps<ValueAt<T, P>>;
|
|
228
|
+
|
|
229
|
+
type FormFieldProps<V> = FieldBinding<V>;
|
|
230
|
+
type FieldBinding<Display, Emit = Display> = {
|
|
231
|
+
value: Display; // read(values, path)
|
|
232
|
+
onChange: (val: Emit) => void; // immutable write(values, path, val)
|
|
233
|
+
errorMessage: string | undefined; // display-policy-aware (below)
|
|
234
|
+
onBlur: () => void; // feeds touched tracking
|
|
235
|
+
};
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The pieces, and where each one's semantics live:
|
|
239
|
+
|
|
240
|
+
- **Granular path writes** — `write()` in `state/path/path.ts`, the
|
|
241
|
+
immutable-update mirror of `read()`: clones only the spine to the written
|
|
242
|
+
leaf, so untouched siblings keep identity. Dead-step semantics mirror
|
|
243
|
+
read() returning undefined: writing through an absent/null ancestor, a
|
|
244
|
+
shape mismatch, or an out-of-bounds index is an identity-preserving no-op
|
|
245
|
+
— you can't edit a field of an absent section; materialize the section
|
|
246
|
+
first. (Appending/splicing lists is item-8 territory, not a path write.)
|
|
247
|
+
`onValueChanges` (whole-value replacement) still exists but is no longer
|
|
248
|
+
the only write path.
|
|
249
|
+
- **Touched tracking** — real state in `useFieldBinding`: a `Path<T>[]`
|
|
250
|
+
compared with `pathsEqual`, fed by `onBlur`, visible in the Debugger
|
|
251
|
+
snapshot. Commit-style elements (selects) call `onBlur` on commit — the
|
|
252
|
+
commit IS their blur moment.
|
|
253
|
+
- **THE error-display policy** lives inside `errorMessage` and nowhere
|
|
254
|
+
else: show a field's error once the field is touched OR a submit has been
|
|
255
|
+
attempted. Elements render what they're given and stay policy-free. A UI
|
|
256
|
+
wanting a different policy (e.g. always-live) reads `errors`/`errorAt`
|
|
257
|
+
directly.
|
|
258
|
+
|
|
259
|
+
### The element wrappers (`state/bindings/`)
|
|
260
|
+
|
|
261
|
+
The **wrapper style** is the prototyped shorthand (per the plan's deciding
|
|
262
|
+
criterion — simplest for agents to work with): `TextInputForForm` and
|
|
263
|
+
`SingleSelectForForm` compose `Field` (label/hint/error presentation)
|
|
264
|
+
around the element and accept the bundle as one `formFieldProps` prop.
|
|
265
|
+
The wrapper declares what it can display and emit via
|
|
266
|
+
`FieldBinding<Display, Emit>` — e.g. the text input takes
|
|
267
|
+
`FieldBinding<string | null | undefined, string>` — and a
|
|
268
|
+
`FormFieldProps<V>` is assignable exactly when `Emit ⊆ V ⊆ Display`. That
|
|
269
|
+
one structural check is the end-to-end type safety: binding a number- or
|
|
270
|
+
boolean-typed path (or a literal-union field, for free text) to a
|
|
271
|
+
text-shaped element fails to compile at the `formFieldProps` prop, with no
|
|
272
|
+
generics at the use site.
|
|
209
273
|
|
|
210
274
|
## Union policy — what form state may hold
|
|
211
275
|
|
|
@@ -309,10 +373,11 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
309
373
|
|
|
310
374
|
- `state/useFormState/` — the hook + the value-model types (`FormValueSimple`,
|
|
311
375
|
`FormValuesObject`, `FormValueList`). Hook surface: `{ values,
|
|
312
|
-
onValueChanges, errors, isValid, submitAttempted, submit,
|
|
376
|
+
onValueChanges, errors, isValid, submitAttempted, submit,
|
|
377
|
+
getFormFieldPropsAt, Debugger }`.
|
|
313
378
|
`useFormState.ts` itself is deliberately **plumbing only**: it holds the
|
|
314
|
-
values `useState` (a bare `useState
|
|
315
|
-
|
|
379
|
+
values `useState` (a bare `useState`; the granular path writes layer on
|
|
380
|
+
top of its setter via `useFieldBinding`), links up the
|
|
316
381
|
modules below, and recomposes their outputs into `FormHelpers<T>`. Each
|
|
317
382
|
slice lives in its own file so it is independently comprehensible and
|
|
318
383
|
unit-testable at its own boundary:
|
|
@@ -330,6 +395,12 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
330
395
|
the correlation. It also hosts the cast-free `FlatConstraintEntry`
|
|
331
396
|
assignment that polices grammar growth (see the `state/validations/` bullet).
|
|
332
397
|
- `errorAt.ts` — the typed error lookup.
|
|
398
|
+
- `useFieldBinding.ts` — field binding: owns the touched list and builds
|
|
399
|
+
`getFormFieldPropsAt` (see "Field binding" above — the error-display
|
|
400
|
+
policy lives here). Two documented honest casts correlate `read()`/
|
|
401
|
+
`write()` results with `ValueAt<T, P>`/`T` for generic `T`; both are
|
|
402
|
+
truths the runtime upholds by construction, same family as `errorAt`'s
|
|
403
|
+
path widening.
|
|
333
404
|
- `useFormSubmit.ts` — submit orchestration: owns `submitAttempted`
|
|
334
405
|
(lets UIs gate error display) and the validity gate in front of
|
|
335
406
|
`onSubmit`; its `submit()` performs the one honest *refinement* cast
|
|
@@ -348,9 +419,11 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
348
419
|
subscribes (`useSyncExternalStore`), so an unused Debugger costs
|
|
349
420
|
~nothing. `inspectable.ts` converts arrays/Sets to shapes `JsonTable`
|
|
350
421
|
can render.
|
|
351
|
-
- `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`,
|
|
352
|
-
|
|
353
|
-
|
|
422
|
+
- `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`,
|
|
423
|
+
`read()`), plus `write()` (the immutable-update mirror of `read()` behind
|
|
424
|
+
granular field writes) and `pathsEqual` (the one definition of structural
|
|
425
|
+
path equality — `errorAt` and touched tracking both use it). Coupled to
|
|
426
|
+
the form value model intentionally.
|
|
354
427
|
Union handling is governed by the "Union policy" section above; the
|
|
355
428
|
policy's type-level guts (`IsDisallowedFormUnion`, `UnionPolicyCheck`,
|
|
356
429
|
`DisallowedFormUnion`) live with the value model in `state/useFormState/types.ts`.
|
|
@@ -367,6 +440,9 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
367
440
|
function). Keep it cast-free. The one widening cast inside `validateEntry`
|
|
368
441
|
mirrors `allOf`'s part-call: honest contravariant widening of a single,
|
|
369
442
|
already-normalized validator.
|
|
443
|
+
- `state/bindings/` — the form-aware element wrappers (`TextInputForForm`,
|
|
444
|
+
`SingleSelectForForm`), the second sanctioned state→elements bridge (see
|
|
445
|
+
"Field binding" above). Element imports go through the element barrels.
|
|
370
446
|
- `state/validators/` — the standard-library validators (`notEmpty`, `minLength`,
|
|
371
447
|
`matches`, `min`) plus `allOf`, which composes validators on one field and
|
|
372
448
|
carries the union of the parts' refinements. `allOf` derives its input type
|
|
@@ -14,6 +14,7 @@ export const Input = (props: InputProps) => {
|
|
|
14
14
|
value,
|
|
15
15
|
defaultValue,
|
|
16
16
|
onChange,
|
|
17
|
+
onBlur,
|
|
17
18
|
placeholder,
|
|
18
19
|
required,
|
|
19
20
|
autoFocus,
|
|
@@ -29,6 +30,7 @@ export const Input = (props: InputProps) => {
|
|
|
29
30
|
value={value}
|
|
30
31
|
defaultValue={defaultValue}
|
|
31
32
|
onChange={onChange}
|
|
33
|
+
onBlur={onBlur}
|
|
32
34
|
placeholder={placeholder}
|
|
33
35
|
required={required}
|
|
34
36
|
autoFocus={autoFocus}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChangeEventHandler } from 'react';
|
|
1
|
+
import type { ChangeEventHandler, FocusEventHandler } from 'react';
|
|
2
2
|
|
|
3
3
|
export type InputSize = 'small' | 'medium';
|
|
4
4
|
export type InputType = 'text' | 'email' | 'password';
|
|
@@ -9,6 +9,7 @@ export type InputProps = {
|
|
|
9
9
|
value?: string;
|
|
10
10
|
defaultValue?: string;
|
|
11
11
|
onChange?: ChangeEventHandler<HTMLInputElement>;
|
|
12
|
+
onBlur?: FocusEventHandler<HTMLInputElement>;
|
|
12
13
|
placeholder?: string;
|
|
13
14
|
required?: boolean;
|
|
14
15
|
autoFocus?: boolean;
|
package/src/forms/plan.md
CHANGED
|
@@ -34,9 +34,15 @@ TS wall can't strand finished work behind it.
|
|
|
34
34
|
`src/forms/` umbrella: elements in `src/forms/elements/<Component>/`,
|
|
35
35
|
the state layer in `src/forms/state/{useFormState,validations,validators,path}/`.
|
|
36
36
|
5. **Item 6 — field binding** (`getFormFieldPropsAt` + wrappers). Needs the
|
|
37
|
-
error model and the split; `Path`/`ValueAt` already validated.
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
error model and the split; `Path`/`ValueAt` already validated. ✅ *done* —
|
|
38
|
+
`getFormFieldPropsAt` on the hook result (value / typed onChange /
|
|
39
|
+
policy-aware errorMessage / onBlur), `write()` as the immutable mirror of
|
|
40
|
+
`read()` in `state/path/path.ts`, touched tracking as real state, and the
|
|
41
|
+
wrapper-style element shorthands prototyped (`state/bindings/`:
|
|
42
|
+
`TextInputForForm`, `SingleSelectForForm`). Probe ratchet held (see the
|
|
43
|
+
baseline note under the recursion budget).
|
|
44
|
+
6. **Type spike, then items 2/3/4** — the recursion risk zone. ← *current*
|
|
45
|
+
Throwaway `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
|
|
40
46
|
any runtime work; three outcomes (works / slow / intractable) each with a
|
|
41
47
|
known response. Worst case, everything above still shipped.
|
|
42
48
|
|
|
@@ -187,6 +193,13 @@ multi-second check time:
|
|
|
187
193
|
- post-step-3 (error-model swap: `Path<T>`-typed `FormError`, `errorAt`,
|
|
188
194
|
plus probes): check 0.79 s, 113,724 instantiations, 52,294 types
|
|
189
195
|
(+4.4% instantiations over pre-step-3 HEAD)
|
|
196
|
+
- post-item-5 split (0.10.1): check 0.81 s, 112,981 instantiations,
|
|
197
|
+
52,504 types
|
|
198
|
+
- post-item-6 (field binding: `getFormFieldPropsAt`, `FieldBinding`
|
|
199
|
+
wrappers, plus deep-path probes at `InsuranceQuoteForm` scale): check
|
|
200
|
+
0.82 s, 125,022 instantiations, 54,959 types (+10.7% instantiations over
|
|
201
|
+
post-item-5 — the `ValueAt` instantiations at each binding call site are
|
|
202
|
+
real but cheap; check time flat)
|
|
190
203
|
|
|
191
204
|
## Runtime consequences (can't be dodged)
|
|
192
205
|
|
|
@@ -275,7 +288,29 @@ Blast radius that was handled when this landed:
|
|
|
275
288
|
Debugger is a designated bridge layer). Explicitly allowed for in the new
|
|
276
289
|
boundaries rules.
|
|
277
290
|
|
|
278
|
-
## 6. Field binding: `getFormFieldPropsAt(path)` + element shorthands
|
|
291
|
+
## 6. Field binding: `getFormFieldPropsAt(path)` + element shorthands ✅ done
|
|
292
|
+
|
|
293
|
+
Landed as specced below; deltas and decisions:
|
|
294
|
+
|
|
295
|
+
- `write()` in `state/path/path.ts` is the immutable-update mirror of
|
|
296
|
+
`read()` — clones only the spine, dead-step semantics mirror read()
|
|
297
|
+
returning undefined (writing through an absent section is an
|
|
298
|
+
identity-preserving no-op; materialize the section first).
|
|
299
|
+
- Touched is real state (`useFieldBinding`), a `Path<T>[]` compared with
|
|
300
|
+
`pathsEqual` — the same structural representation as `FormErrors`. It
|
|
301
|
+
also shows up in the Debugger snapshot.
|
|
302
|
+
- The error-display policy (touched-or-submitAttempted) lives inside
|
|
303
|
+
`errorMessage`, nowhere else — see "Field binding" in forms/CLAUDE.md.
|
|
304
|
+
- **Element-shorthand style: the wrapper style is the prototype**
|
|
305
|
+
(`TextInputForForm`, `SingleSelectForForm` in `src/forms/state/bindings/`,
|
|
306
|
+
the second sanctioned state→elements bridge after the Debugger). The
|
|
307
|
+
wrappers declare what they display/emit via `FieldBinding<Display, Emit>`
|
|
308
|
+
and plain structural assignability rejects wrong-shaped bindings — no
|
|
309
|
+
generics at the use site. Union-typed props on the elements themselves
|
|
310
|
+
remain the fallback if the parallel component set gets heavy; judge after
|
|
311
|
+
the wrapper set grows past these two.
|
|
312
|
+
|
|
313
|
+
Original spec follows.
|
|
279
314
|
|
|
280
315
|
Today every field is wired by hand in JSX: read `values.x`, spread-update via
|
|
281
316
|
`onValueChanges`, look up the error, gate it on `submitAttempted`. That's
|
|
@@ -427,8 +462,9 @@ Possibly a hybrid: identity-matching as the default heuristic, owned ops as
|
|
|
427
462
|
the guaranteed path. **Research topic for when we get here** — record
|
|
428
463
|
findings in the learning map before committing to either.
|
|
429
464
|
|
|
430
|
-
Sequencing: visited tracking
|
|
431
|
-
path
|
|
465
|
+
Sequencing: visited tracking ✅ *landed with item 6* (its `onBlur` is the
|
|
466
|
+
write path; `touched: Path<T>[]` in `useFieldBinding`, structural equality
|
|
467
|
+
via `pathsEqual`). Keys + array-sync matter from phase 3 (list `each` specs) onward —
|
|
432
468
|
error paths and per-element meta both need element identity once lists are
|
|
433
469
|
editable.
|
|
434
470
|
|
|
@@ -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
|
+
};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import { pathsEqual } from '../path/path';
|
|
1
2
|
import type { Path, PathStep } from '../path/types';
|
|
2
3
|
import type { FormErrors, FormValuesObject } from './types';
|
|
3
4
|
|
|
4
5
|
// Typed lookup into the structured `{ path, error }[]` error list — the
|
|
5
|
-
// sanctioned way to read one field's error. Path equality is structural
|
|
6
|
-
// same steps, same order, no prefix matching. With
|
|
7
|
-
// validation there is at most one entry per path today;
|
|
8
|
-
// ever land, the first entry stays the one shown.
|
|
6
|
+
// sanctioned way to read one field's error. Path equality is structural
|
|
7
|
+
// (`pathsEqual`): same steps, same order, no prefix matching. With
|
|
8
|
+
// first-error-wins validation there is at most one entry per path today;
|
|
9
|
+
// should collect-all ever land, the first entry stays the one shown.
|
|
9
10
|
export const errorAt = <T extends FormValuesObject>(
|
|
10
11
|
errors: FormErrors<T>,
|
|
11
12
|
path: Path<T>,
|
|
@@ -14,14 +15,9 @@ export const errorAt = <T extends FormValuesObject>(
|
|
|
14
15
|
// prove it for an unresolved T. Same honest widening as `Cursor.at`.
|
|
15
16
|
const steps = path as readonly PathStep[];
|
|
16
17
|
|
|
17
|
-
const match = errors.find((candidate) =>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return (
|
|
21
|
-
candidateSteps.length === steps.length &&
|
|
22
|
-
candidateSteps.every((step, index) => step === steps[index])
|
|
23
|
-
);
|
|
24
|
-
});
|
|
18
|
+
const match = errors.find((candidate) =>
|
|
19
|
+
pathsEqual(candidate.path as readonly PathStep[], steps),
|
|
20
|
+
);
|
|
25
21
|
|
|
26
22
|
return match?.error;
|
|
27
23
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Type-only imports; FormDebugger.tsx and path/types.ts import value types
|
|
2
2
|
// from this file in turn, but the cycles never exist at runtime.
|
|
3
3
|
import type { FormDebuggerComponent } from './FormDebugger';
|
|
4
|
-
import type { Path } from '../path/types';
|
|
4
|
+
import type { Path, ValueAt } from '../path/types';
|
|
5
5
|
|
|
6
6
|
export type FormValueSimple = string | number | bigint | boolean | string[] | Set<string> | undefined | null;
|
|
7
7
|
|
|
@@ -90,6 +90,28 @@ export type FormError<T extends FormValuesObject> = {
|
|
|
90
90
|
|
|
91
91
|
export type FormErrors<T extends FormValuesObject> = readonly FormError<T>[];
|
|
92
92
|
|
|
93
|
+
// What a form-aware element declares it needs from a field binding: it
|
|
94
|
+
// *displays* `Display` and *emits* `Emit`. A `FormFieldProps<V>` is
|
|
95
|
+
// assignable exactly when V sits between them (`Emit ⊆ V ⊆ Display`), which
|
|
96
|
+
// is how "a number-typed path bound to a text-shaped element" becomes a
|
|
97
|
+
// compile error at the `formFieldProps` prop — no generics needed on the
|
|
98
|
+
// wrapper, structural assignability does the checking.
|
|
99
|
+
export type FieldBinding<Display, Emit = Display> = {
|
|
100
|
+
value: Display;
|
|
101
|
+
onChange: (val: Emit) => void;
|
|
102
|
+
// Already display-policy-aware (see `useFieldBinding`): undefined until
|
|
103
|
+
// the field's error should be SHOWN, so elements render what they're
|
|
104
|
+
// given and stay policy-free.
|
|
105
|
+
errorMessage: string | undefined;
|
|
106
|
+
// Feeds touched tracking; wire it to the element's blur (or, for
|
|
107
|
+
// commit-style elements like selects, to the commit).
|
|
108
|
+
onBlur: () => void;
|
|
109
|
+
// room to grow: name/id derivation, disabled, ...
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// The bundle `getFormFieldPropsAt(path)` returns for the field at that path.
|
|
113
|
+
export type FormFieldProps<V> = FieldBinding<V>;
|
|
114
|
+
|
|
93
115
|
// What the hook publishes (via `useFormDebugger`) to its Debugger after
|
|
94
116
|
// every commit. Snapshots are
|
|
95
117
|
// replaced whole (never mutated) so `useSyncExternalStore` consumers can
|
|
@@ -99,11 +121,14 @@ export type FormDebugSnapshot<T extends FormValuesObject> = {
|
|
|
99
121
|
errors: FormErrors<T>;
|
|
100
122
|
isValid: boolean;
|
|
101
123
|
submitAttempted: boolean;
|
|
124
|
+
touched: readonly Path<T>[];
|
|
102
125
|
};
|
|
103
126
|
|
|
104
127
|
export type FormHelpers<T extends FormValuesObject> = {
|
|
105
128
|
values: T;
|
|
106
|
-
//
|
|
129
|
+
// Whole-value replacement. No longer the only write path (field-level
|
|
130
|
+
// writes go through `getFormFieldPropsAt(path).onChange`); whether this
|
|
131
|
+
// survives long-term is a separate decision.
|
|
107
132
|
onValueChanges: (val: T | ((prev: T) => T)) => void;
|
|
108
133
|
// Live-derived from the current values on every render; UIs that only want
|
|
109
134
|
// errors after a submit attempt gate on `submitAttempted`.
|
|
@@ -111,6 +136,12 @@ export type FormHelpers<T extends FormValuesObject> = {
|
|
|
111
136
|
isValid: boolean;
|
|
112
137
|
submitAttempted: boolean;
|
|
113
138
|
submit: () => void;
|
|
139
|
+
// One expression wires a field: value, typed onChange (an immutable write
|
|
140
|
+
// at the path), display-policy-aware errorMessage, and onBlur (touched
|
|
141
|
+
// tracking). `ValueAt<T, P>` types value/onChange end-to-end, so binding a
|
|
142
|
+
// wrong-shaped path to an element fails to compile at the element's
|
|
143
|
+
// `formFieldProps` prop.
|
|
144
|
+
getFormFieldPropsAt: <P extends Path<T>>(path: P) => FormFieldProps<ValueAt<T, P>>;
|
|
114
145
|
// Dev-time introspection overlay bound to this form instance: a fixed
|
|
115
146
|
// trigger that opens a window showing the form's live internal state.
|
|
116
147
|
// Render it anywhere (it portals to <body>); omit it and nothing mounts.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { act, renderHook } from '@testing-library/react';
|
|
3
|
+
import { useFieldBinding } from './useFieldBinding';
|
|
4
|
+
import { useFormState } from './useFormState';
|
|
5
|
+
import { notEmpty } from '../validators/validators';
|
|
6
|
+
|
|
7
|
+
// Field binding exercised through useFormState — the composition consumers
|
|
8
|
+
// see. Pins the three behaviors item 6 added: granular path writes (the
|
|
9
|
+
// immutable mirror of read()), touched tracking fed by onBlur, and the
|
|
10
|
+
// error-display policy living inside errorMessage.
|
|
11
|
+
|
|
12
|
+
type Address = { city: string | undefined; zip: string | undefined };
|
|
13
|
+
|
|
14
|
+
type ProfileForm = {
|
|
15
|
+
email: string | undefined;
|
|
16
|
+
homeAddress: Address;
|
|
17
|
+
mailingAddress: Address | undefined;
|
|
18
|
+
pets: Array<{ name: string | undefined }>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const initialValues: ProfileForm = {
|
|
22
|
+
email: undefined,
|
|
23
|
+
homeAddress: { city: undefined, zip: undefined },
|
|
24
|
+
mailingAddress: undefined,
|
|
25
|
+
pets: [{ name: 'Rex' }, { name: 'Milou' }],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const setup = () =>
|
|
29
|
+
renderHook(() =>
|
|
30
|
+
useFormState({
|
|
31
|
+
initialValues,
|
|
32
|
+
constraints: { email: notEmpty('email') },
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
describe('getFormFieldPropsAt — value and writes', () => {
|
|
37
|
+
test('reads the value at a deep path', () => {
|
|
38
|
+
const { result } = setup();
|
|
39
|
+
expect(result.current.getFormFieldPropsAt(['pets', 0, 'name']).value).toBe(
|
|
40
|
+
'Rex',
|
|
41
|
+
);
|
|
42
|
+
expect(
|
|
43
|
+
result.current.getFormFieldPropsAt(['homeAddress', 'city']).value,
|
|
44
|
+
).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a path through an absent section reads undefined and writes as a no-op', () => {
|
|
48
|
+
const { result } = setup();
|
|
49
|
+
const field = result.current.getFormFieldPropsAt(['mailingAddress', 'city']);
|
|
50
|
+
expect(field.value).toBeUndefined();
|
|
51
|
+
|
|
52
|
+
act(() => {
|
|
53
|
+
field.onChange('Paris');
|
|
54
|
+
});
|
|
55
|
+
// The write mirrored read()'s dead-step semantics: nothing changed.
|
|
56
|
+
expect(result.current.values).toBe(initialValues);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('onChange writes at the path immutably, preserving sibling identity', () => {
|
|
60
|
+
const { result } = setup();
|
|
61
|
+
|
|
62
|
+
act(() => {
|
|
63
|
+
result.current.getFormFieldPropsAt(['homeAddress', 'city']).onChange('Paris');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(result.current.values.homeAddress.city).toBe('Paris');
|
|
67
|
+
// Untouched branches keep their identity — only the spine was cloned.
|
|
68
|
+
expect(result.current.values.pets).toBe(initialValues.pets);
|
|
69
|
+
expect(result.current.values).not.toBe(initialValues);
|
|
70
|
+
expect(initialValues.homeAddress.city).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('onChange into a list element rewrites only that element', () => {
|
|
74
|
+
const { result } = setup();
|
|
75
|
+
|
|
76
|
+
act(() => {
|
|
77
|
+
result.current.getFormFieldPropsAt(['pets', 1, 'name']).onChange('Snowy');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(result.current.values.pets[1].name).toBe('Snowy');
|
|
81
|
+
expect(result.current.values.pets[0]).toBe(initialValues.pets[0]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('getFormFieldPropsAt — error-display policy', () => {
|
|
86
|
+
test('an untouched field shows no error before a submit attempt', () => {
|
|
87
|
+
const { result } = setup();
|
|
88
|
+
// The error exists in the raw list …
|
|
89
|
+
expect(result.current.errors).toEqual([
|
|
90
|
+
{ path: ['email'], error: "'email' cannot be empty" },
|
|
91
|
+
]);
|
|
92
|
+
// … but the binding withholds it until touched or submit-attempted.
|
|
93
|
+
expect(
|
|
94
|
+
result.current.getFormFieldPropsAt(['email']).errorMessage,
|
|
95
|
+
).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('onBlur marks the field touched and unlocks its error — only its own', () => {
|
|
99
|
+
const { result } = setup();
|
|
100
|
+
|
|
101
|
+
act(() => {
|
|
102
|
+
result.current.getFormFieldPropsAt(['email']).onBlur();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result.current.getFormFieldPropsAt(['email']).errorMessage).toBe(
|
|
106
|
+
"'email' cannot be empty",
|
|
107
|
+
);
|
|
108
|
+
// Another field stays untouched: same policy, independent state.
|
|
109
|
+
expect(
|
|
110
|
+
result.current.getFormFieldPropsAt(['homeAddress', 'city']).errorMessage,
|
|
111
|
+
).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a submit attempt unlocks every field’s error', () => {
|
|
115
|
+
const { result } = setup();
|
|
116
|
+
|
|
117
|
+
act(() => {
|
|
118
|
+
result.current.submit();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(result.current.getFormFieldPropsAt(['email']).errorMessage).toBe(
|
|
122
|
+
"'email' cannot be empty",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a touched field’s error clears once the value is fixed', () => {
|
|
127
|
+
const { result } = setup();
|
|
128
|
+
|
|
129
|
+
act(() => {
|
|
130
|
+
result.current.getFormFieldPropsAt(['email']).onBlur();
|
|
131
|
+
});
|
|
132
|
+
act(() => {
|
|
133
|
+
result.current.getFormFieldPropsAt(['email']).onChange('a@b.co');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(
|
|
137
|
+
result.current.getFormFieldPropsAt(['email']).errorMessage,
|
|
138
|
+
).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('repeat blurs on the same path keep the touched list stable', () => {
|
|
142
|
+
// At the useFieldBinding boundary, where the touched list is returned.
|
|
143
|
+
const { result } = renderHook(() =>
|
|
144
|
+
useFieldBinding({
|
|
145
|
+
values: initialValues,
|
|
146
|
+
onValueChanges: () => {},
|
|
147
|
+
errors: [],
|
|
148
|
+
submitAttempted: false,
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
act(() => {
|
|
153
|
+
result.current.getFormFieldPropsAt(['email']).onBlur();
|
|
154
|
+
});
|
|
155
|
+
const touchedAfterFirst = result.current.touched;
|
|
156
|
+
expect(touchedAfterFirst).toEqual([['email']]);
|
|
157
|
+
|
|
158
|
+
act(() => {
|
|
159
|
+
result.current.getFormFieldPropsAt(['email']).onBlur();
|
|
160
|
+
});
|
|
161
|
+
// Re-blurring an already-touched path is a state no-op — the setter
|
|
162
|
+
// returns the same array, so nothing downstream sees a change.
|
|
163
|
+
expect(result.current.touched).toBe(touchedAfterFirst);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { errorAt } from './errorAt';
|
|
3
|
+
import type { FormErrors, FormFieldProps, FormValuesObject } from './types';
|
|
4
|
+
import { pathsEqual, read, write } from '../path/path';
|
|
5
|
+
import type { CursorStep, Path, PathStep, ValueAt } from '../path/types';
|
|
6
|
+
|
|
7
|
+
export type UseFieldBindingArgs<T extends FormValuesObject> = {
|
|
8
|
+
values: T;
|
|
9
|
+
onValueChanges: (val: T | ((prev: T) => T)) => void;
|
|
10
|
+
errors: FormErrors<T>;
|
|
11
|
+
submitAttempted: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Field binding: owns per-field touched state and builds
|
|
15
|
+
// `getFormFieldPropsAt`, the one-expression wiring for a field (see
|
|
16
|
+
// FormHelpers in ./types.ts).
|
|
17
|
+
//
|
|
18
|
+
// THE error-display policy lives here, in `errorMessage`, and nowhere else:
|
|
19
|
+
// a field's error is shown once the field has been touched (blurred at
|
|
20
|
+
// least once) OR a submit has been attempted. Elements render the message
|
|
21
|
+
// they are given and stay policy-free; anything wanting a different policy
|
|
22
|
+
// (e.g. always-live display) reads `errors`/`errorAt` directly instead.
|
|
23
|
+
export const useFieldBinding = <T extends FormValuesObject>(
|
|
24
|
+
args: UseFieldBindingArgs<T>,
|
|
25
|
+
) => {
|
|
26
|
+
const { values, onValueChanges, errors, submitAttempted } = args;
|
|
27
|
+
|
|
28
|
+
// Same representation as FormErrors: a plain list of typed paths compared
|
|
29
|
+
// structurally (pathsEqual). At form scale a linear scan is fine.
|
|
30
|
+
const [touched, setTouched] = useState<readonly Path<T>[]>([]);
|
|
31
|
+
|
|
32
|
+
const getFormFieldPropsAt = <P extends Path<T>>(
|
|
33
|
+
path: P,
|
|
34
|
+
): FormFieldProps<ValueAt<T, P>> => {
|
|
35
|
+
// Path<T> is always a PathStep tuple; the conditional type just can't
|
|
36
|
+
// prove it for an unresolved T. Same honest widening as `Cursor.at`.
|
|
37
|
+
const steps = path as readonly PathStep[];
|
|
38
|
+
const keySteps: readonly CursorStep[] = steps.map((key) => ({
|
|
39
|
+
kind: 'key',
|
|
40
|
+
key,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
const isTouched = touched.some((candidate) =>
|
|
44
|
+
pathsEqual(candidate as readonly PathStep[], steps),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
// read() resolves exactly what ValueAt<T, P> promises — including the
|
|
49
|
+
// `| undefined` picked up through nullable ancestors — TS just can't
|
|
50
|
+
// correlate them for generic T. Honest cast, documented in the cast
|
|
51
|
+
// doctrine (src/forms/CLAUDE.md).
|
|
52
|
+
value: read(values, keySteps) as ValueAt<T, P>,
|
|
53
|
+
// write() replaces the value at the path and clones only the spine —
|
|
54
|
+
// the result is the same T shape. Same honest correlation as `value`.
|
|
55
|
+
onChange: (val) =>
|
|
56
|
+
onValueChanges((prev) => write(prev, steps, val) as T),
|
|
57
|
+
errorMessage:
|
|
58
|
+
submitAttempted || isTouched ? errorAt(errors, path) : undefined,
|
|
59
|
+
onBlur: () =>
|
|
60
|
+
setTouched((prev) =>
|
|
61
|
+
prev.some((candidate) =>
|
|
62
|
+
pathsEqual(candidate as readonly PathStep[], steps),
|
|
63
|
+
)
|
|
64
|
+
? prev
|
|
65
|
+
: [...prev, path],
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return { touched, getFormFieldPropsAt };
|
|
71
|
+
};
|
|
@@ -9,6 +9,9 @@ import { Field } from '../../elements/Field';
|
|
|
9
9
|
import { Input } from '../../elements/Input';
|
|
10
10
|
import { Button } from '../../elements/Button';
|
|
11
11
|
import { SingleSelect } from '../../elements/Select';
|
|
12
|
+
import type { SelectOption } from '../../elements/Select';
|
|
13
|
+
import { SingleSelectForForm } from '../bindings/SingleSelectForForm';
|
|
14
|
+
import { TextInputForForm } from '../bindings/TextInputForForm';
|
|
12
15
|
|
|
13
16
|
const meta: Meta = {
|
|
14
17
|
title: 'Forms/useFormState',
|
|
@@ -43,6 +46,10 @@ const ROLE_OPTIONS = [
|
|
|
43
46
|
{ value: 'manager', label: 'Manager' },
|
|
44
47
|
];
|
|
45
48
|
|
|
49
|
+
// Deliberately hand-wired — the contrast case for the FieldBinding story
|
|
50
|
+
// below: every field spells out its own read, spread-update, error lookup,
|
|
51
|
+
// and submit gating. `getFormFieldPropsAt` + the ForForm wrappers collapse
|
|
52
|
+
// those four decisions into one expression per field.
|
|
46
53
|
const SignupDemo = () => {
|
|
47
54
|
const [submitted, setSubmitted] = useState<SubmittedValues | null>(null);
|
|
48
55
|
|
|
@@ -259,6 +266,162 @@ export const LiveValidity: Story = {
|
|
|
259
266
|
},
|
|
260
267
|
};
|
|
261
268
|
|
|
269
|
+
type CoverageType = 'liability' | 'comprehensive';
|
|
270
|
+
|
|
271
|
+
type QuoteFormValues = {
|
|
272
|
+
email: string | undefined;
|
|
273
|
+
coverageType: CoverageType | null;
|
|
274
|
+
homeAddress: {
|
|
275
|
+
city: string | undefined;
|
|
276
|
+
postalCode: string | undefined;
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// What onSubmit receives: notEmpty strips undefined from email and null
|
|
281
|
+
// from coverageType. The setSubmitted(vals) call below compiling is the
|
|
282
|
+
// proof that refinement still flows end-to-end when fields are wired
|
|
283
|
+
// through bindings.
|
|
284
|
+
type SubmittedQuote = {
|
|
285
|
+
email: string;
|
|
286
|
+
coverageType: CoverageType;
|
|
287
|
+
homeAddress: QuoteFormValues['homeAddress'];
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const COVERAGE_OPTIONS: SelectOption<CoverageType>[] = [
|
|
291
|
+
{ value: 'liability', label: 'Liability' },
|
|
292
|
+
{ value: 'comprehensive', label: 'Comprehensive' },
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
// The item-6 target: one expression wires a field. getFormFieldPropsAt
|
|
296
|
+
// bundles value + typed onChange (an immutable write at the path) +
|
|
297
|
+
// display-policy-aware errorMessage + onBlur, and the ForForm wrappers
|
|
298
|
+
// take the bundle as a single prop. Note the deep paths into homeAddress —
|
|
299
|
+
// no hand-spread updates anywhere.
|
|
300
|
+
const FieldBindingDemo = () => {
|
|
301
|
+
const [submitted, setSubmitted] = useState<SubmittedQuote | null>(null);
|
|
302
|
+
|
|
303
|
+
const { getFormFieldPropsAt, submit } = useFormState({
|
|
304
|
+
initialValues: {
|
|
305
|
+
email: undefined,
|
|
306
|
+
coverageType: null,
|
|
307
|
+
homeAddress: { city: undefined, postalCode: undefined },
|
|
308
|
+
} as QuoteFormValues,
|
|
309
|
+
constraints: {
|
|
310
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
311
|
+
coverageType: notEmpty('coverageType'),
|
|
312
|
+
},
|
|
313
|
+
onSubmit: (vals) => setSubmitted(vals),
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<form
|
|
318
|
+
style={{ maxWidth: 420, display: 'grid', gap: 16 }}
|
|
319
|
+
onSubmit={(e) => {
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
submit();
|
|
322
|
+
}}
|
|
323
|
+
>
|
|
324
|
+
<TextInputForForm
|
|
325
|
+
label="Email"
|
|
326
|
+
type="email"
|
|
327
|
+
placeholder="you@example.com"
|
|
328
|
+
hint="Blur the empty field to see touched-gated errors"
|
|
329
|
+
formFieldProps={getFormFieldPropsAt(['email'])}
|
|
330
|
+
/>
|
|
331
|
+
|
|
332
|
+
<SingleSelectForForm
|
|
333
|
+
label="Coverage"
|
|
334
|
+
options={COVERAGE_OPTIONS}
|
|
335
|
+
placeholder="Pick a coverage"
|
|
336
|
+
formFieldProps={getFormFieldPropsAt(['coverageType'])}
|
|
337
|
+
/>
|
|
338
|
+
|
|
339
|
+
<TextInputForForm
|
|
340
|
+
label="City"
|
|
341
|
+
formFieldProps={getFormFieldPropsAt(['homeAddress', 'city'])}
|
|
342
|
+
/>
|
|
343
|
+
|
|
344
|
+
<TextInputForForm
|
|
345
|
+
label="Postal code"
|
|
346
|
+
formFieldProps={getFormFieldPropsAt(['homeAddress', 'postalCode'])}
|
|
347
|
+
/>
|
|
348
|
+
|
|
349
|
+
<div>
|
|
350
|
+
<Button type="submit" variant="primary">
|
|
351
|
+
Get quote
|
|
352
|
+
</Button>
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
{submitted && (
|
|
356
|
+
<pre
|
|
357
|
+
style={{
|
|
358
|
+
background: 'var(--ui-surface-muted, #f4f4f4)',
|
|
359
|
+
padding: 12,
|
|
360
|
+
borderRadius: 6,
|
|
361
|
+
fontSize: 12,
|
|
362
|
+
margin: 0,
|
|
363
|
+
}}
|
|
364
|
+
>
|
|
365
|
+
{`onSubmit received (narrowed type):\n${JSON.stringify(submitted, null, 2)}`}
|
|
366
|
+
</pre>
|
|
367
|
+
)}
|
|
368
|
+
</form>
|
|
369
|
+
);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
export const FieldBinding: Story = {
|
|
373
|
+
render: () => (
|
|
374
|
+
<div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
|
|
375
|
+
<FieldBindingDemo />
|
|
376
|
+
</div>
|
|
377
|
+
),
|
|
378
|
+
// Walks the binding flow: touched/blur gating of errorMessage (before any
|
|
379
|
+
// submit attempt), deep-path writes through the wrappers, submit-attempt
|
|
380
|
+
// gating for untouched fields, and the narrowed payload reaching onSubmit.
|
|
381
|
+
play: async ({ canvasElement }) => {
|
|
382
|
+
const canvas = within(canvasElement);
|
|
383
|
+
const body = within(canvasElement.ownerDocument.body);
|
|
384
|
+
|
|
385
|
+
// Nothing shown initially: the email error exists in the raw list, but
|
|
386
|
+
// errorMessage withholds it until the field is touched.
|
|
387
|
+
await expect(
|
|
388
|
+
canvas.queryByText("'email' cannot be empty"),
|
|
389
|
+
).not.toBeInTheDocument();
|
|
390
|
+
|
|
391
|
+
// Blurring the empty email field marks it touched — its error appears
|
|
392
|
+
// without any submit attempt, and only its own.
|
|
393
|
+
await userEvent.click(canvas.getByLabelText(/^Email/));
|
|
394
|
+
await userEvent.tab();
|
|
395
|
+
await expect(canvas.getByText("'email' cannot be empty")).toBeInTheDocument();
|
|
396
|
+
await expect(
|
|
397
|
+
canvas.queryByText("'coverageType' cannot be empty"),
|
|
398
|
+
).not.toBeInTheDocument();
|
|
399
|
+
|
|
400
|
+
// Deep-path writes flow through the wrapper onChange.
|
|
401
|
+
await userEvent.type(canvas.getByLabelText(/^City/), 'Lyon');
|
|
402
|
+
await expect(canvas.getByLabelText(/^City/)).toHaveValue('Lyon');
|
|
403
|
+
|
|
404
|
+
// A submit attempt unlocks the untouched coverage field's error too.
|
|
405
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Get quote' }));
|
|
406
|
+
await expect(
|
|
407
|
+
canvas.getByText("'coverageType' cannot be empty"),
|
|
408
|
+
).toBeInTheDocument();
|
|
409
|
+
|
|
410
|
+
// Fix both fields; committing a select option counts as its touch.
|
|
411
|
+
await userEvent.type(canvas.getByLabelText(/^Email/), 'will@example.com');
|
|
412
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Coverage' }));
|
|
413
|
+
// The option list is portaled — query the document, not the canvas.
|
|
414
|
+
await userEvent.click(await body.findByRole('option', { name: 'Liability' }));
|
|
415
|
+
await expect(
|
|
416
|
+
canvas.queryByText("'coverageType' cannot be empty"),
|
|
417
|
+
).not.toBeInTheDocument();
|
|
418
|
+
|
|
419
|
+
// Valid submit delivers the narrowed payload.
|
|
420
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Get quote' }));
|
|
421
|
+
await expect(canvas.getByText(/onSubmit received/)).toBeInTheDocument();
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
|
|
262
425
|
type DebuggerDemoValues = {
|
|
263
426
|
email: string | undefined;
|
|
264
427
|
nickname: string | undefined;
|
|
@@ -2,9 +2,11 @@ import { describe, it, expectTypeOf } from 'vitest';
|
|
|
2
2
|
import { useFormState } from './useFormState';
|
|
3
3
|
import { errorAt } from './errorAt';
|
|
4
4
|
import { allOf, matches, min, minLength, notEmpty } from '../validators/validators';
|
|
5
|
-
import type { FormErrors } from './types';
|
|
5
|
+
import type { FormErrors, FormFieldProps } from './types';
|
|
6
6
|
import type { Refine, Validations } from '../validations/types';
|
|
7
7
|
import type { Path, ValueAt } from '../path/types';
|
|
8
|
+
import type { TextInputForFormProps } from '../bindings/TextInputForForm';
|
|
9
|
+
import type { SingleSelectForFormProps } from '../bindings/SingleSelectForForm';
|
|
8
10
|
|
|
9
11
|
// These tests exercise the headline feature end-to-end at the hook boundary:
|
|
10
12
|
// the type `onSubmit` receives must be the *refined* form type. The two
|
|
@@ -425,6 +427,83 @@ describe('useFormState narrowing at realistic scale', () => {
|
|
|
425
427
|
errorAt(errors, ['email', 'domain']);
|
|
426
428
|
});
|
|
427
429
|
|
|
430
|
+
it('getFormFieldPropsAt infers FormFieldProps<ValueAt> at deep paths, inline at the call site', () => {
|
|
431
|
+
const form = useFormState({
|
|
432
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
433
|
+
constraints: { email: notEmpty('email') },
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Flat and deep paths: value/onChange typed by ValueAt<T, P>.
|
|
437
|
+
expectTypeOf(form.getFormFieldPropsAt(['email'])).toEqualTypeOf<
|
|
438
|
+
FormFieldProps<string | undefined>
|
|
439
|
+
>();
|
|
440
|
+
expectTypeOf(
|
|
441
|
+
form.getFormFieldPropsAt(['homeAddress', 'city']),
|
|
442
|
+
).toEqualTypeOf<FormFieldProps<string | undefined>>();
|
|
443
|
+
expectTypeOf(
|
|
444
|
+
form.getFormFieldPropsAt(['drivers', 0, 'incidents', 1, 'claimAmountUsd']),
|
|
445
|
+
).toEqualTypeOf<FormFieldProps<number | null>>();
|
|
446
|
+
expectTypeOf(
|
|
447
|
+
form.getFormFieldPropsAt(['vehicles', 0, 'garagingAddress', 'postalCode']),
|
|
448
|
+
).toEqualTypeOf<FormFieldProps<string | undefined>>();
|
|
449
|
+
|
|
450
|
+
// Nullable-object semantics (PR #17) flow through the binding: stepping
|
|
451
|
+
// through a dead ancestor adds `| undefined`, stopping AT a nullable
|
|
452
|
+
// field keeps its exact type.
|
|
453
|
+
expectTypeOf(
|
|
454
|
+
form.getFormFieldPropsAt(['coApplicant', 'sharesResidence']),
|
|
455
|
+
).toEqualTypeOf<FormFieldProps<boolean | undefined>>();
|
|
456
|
+
expectTypeOf(
|
|
457
|
+
form.getFormFieldPropsAt(['pastPolicies', 0, 'activeUntil']),
|
|
458
|
+
).toEqualTypeOf<FormFieldProps<string | null | undefined>>();
|
|
459
|
+
expectTypeOf(form.getFormFieldPropsAt(['employer'])).toEqualTypeOf<
|
|
460
|
+
FormFieldProps<string | null>
|
|
461
|
+
>();
|
|
462
|
+
|
|
463
|
+
// Only Path<T> is admitted.
|
|
464
|
+
// @ts-expect-error 'emial' is not a field of the form
|
|
465
|
+
form.getFormFieldPropsAt(['emial']);
|
|
466
|
+
// @ts-expect-error no paths exist below a scalar leaf
|
|
467
|
+
form.getFormFieldPropsAt(['email', 'domain']);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('element wrappers accept only shape-compatible bindings', () => {
|
|
471
|
+
const form = useFormState({ initialValues: {} as InsuranceQuoteForm });
|
|
472
|
+
|
|
473
|
+
type TextBinding = TextInputForFormProps['formFieldProps'];
|
|
474
|
+
|
|
475
|
+
// Text-shaped fields bind: V must sit between the wrapper's emit type
|
|
476
|
+
// (string) and display type (string | null | undefined).
|
|
477
|
+
expectTypeOf(
|
|
478
|
+
form.getFormFieldPropsAt(['homeAddress', 'city']),
|
|
479
|
+
).toMatchTypeOf<TextBinding>();
|
|
480
|
+
expectTypeOf(form.getFormFieldPropsAt(['employer'])).toMatchTypeOf<TextBinding>();
|
|
481
|
+
expectTypeOf(
|
|
482
|
+
form.getFormFieldPropsAt(['pastPolicies', 0, 'activeUntil']),
|
|
483
|
+
).toMatchTypeOf<TextBinding>();
|
|
484
|
+
|
|
485
|
+
// Wrong-shaped bindings fail at the formFieldProps prop.
|
|
486
|
+
// @ts-expect-error a number-typed path cannot bind to a text element
|
|
487
|
+
const numberIntoText: TextBinding = form.getFormFieldPropsAt(['deductibleUsd']);
|
|
488
|
+
// @ts-expect-error a boolean-typed path cannot bind to a text element
|
|
489
|
+
const booleanIntoText: TextBinding = form.getFormFieldPropsAt(['agreedToTerms']);
|
|
490
|
+
|
|
491
|
+
// The select wrapper lines its options' literal union up with the field:
|
|
492
|
+
// emitting a wider type than the field holds is rejected.
|
|
493
|
+
type CoverageBinding = SingleSelectForFormProps<
|
|
494
|
+
'liability' | 'comprehensive'
|
|
495
|
+
>['formFieldProps'];
|
|
496
|
+
expectTypeOf<
|
|
497
|
+
FormFieldProps<'liability' | 'comprehensive' | undefined>
|
|
498
|
+
>().toMatchTypeOf<CoverageBinding>();
|
|
499
|
+
// @ts-expect-error a plain-string field would accept values outside the options
|
|
500
|
+
const stringIntoSelect: CoverageBinding = form.getFormFieldPropsAt(['coverageType']);
|
|
501
|
+
|
|
502
|
+
void numberIntoText;
|
|
503
|
+
void booleanIntoText;
|
|
504
|
+
void stringIntoSelect;
|
|
505
|
+
});
|
|
506
|
+
|
|
428
507
|
it('paths through optional sections and nullable lists resolve, at scale', () => {
|
|
429
508
|
// The latent hole this pins: Path admitted these paths all along, but
|
|
430
509
|
// ValueAt resolved them to `never` because `keyof (Obj | undefined)` is
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
FormValuesObject,
|
|
6
6
|
UnionPolicyCheck,
|
|
7
7
|
} from './types';
|
|
8
|
+
import { useFieldBinding } from './useFieldBinding';
|
|
8
9
|
import { useFormDebugger } from './useFormDebugger';
|
|
9
10
|
import { useFormSubmit } from './useFormSubmit';
|
|
10
11
|
import type { Refine, Validations } from '../validations/types';
|
|
@@ -30,8 +31,8 @@ type Args<T extends FormValuesObject, V extends Validations<T>> = {
|
|
|
30
31
|
// Plumbing only: each slice of form state lives in its own pure function or
|
|
31
32
|
// focused hook, and this hook just links them up and recomposes their
|
|
32
33
|
// outputs into the `FormHelpers<T>` surface. Values state is the one slice
|
|
33
|
-
// kept inline —
|
|
34
|
-
//
|
|
34
|
+
// kept inline — a bare `useState`; the granular path writes layer on top of
|
|
35
|
+
// its setter (`useFieldBinding` funnels `write()` results through it).
|
|
35
36
|
export const useFormState = <
|
|
36
37
|
T extends FormValuesObject,
|
|
37
38
|
const V extends Validations<T> = Validations<T>,
|
|
@@ -53,8 +54,15 @@ export const useFormState = <
|
|
|
53
54
|
onSubmit,
|
|
54
55
|
});
|
|
55
56
|
|
|
57
|
+
const { touched, getFormFieldPropsAt } = useFieldBinding({
|
|
58
|
+
values,
|
|
59
|
+
onValueChanges,
|
|
60
|
+
errors,
|
|
61
|
+
submitAttempted,
|
|
62
|
+
});
|
|
63
|
+
|
|
56
64
|
const Debugger = useFormDebugger({
|
|
57
|
-
snapshot: { values, errors, isValid, submitAttempted },
|
|
65
|
+
snapshot: { values, errors, isValid, submitAttempted, touched },
|
|
58
66
|
});
|
|
59
67
|
|
|
60
68
|
return {
|
|
@@ -64,6 +72,7 @@ export const useFormState = <
|
|
|
64
72
|
isValid,
|
|
65
73
|
submitAttempted,
|
|
66
74
|
submit,
|
|
75
|
+
getFormFieldPropsAt,
|
|
67
76
|
Debugger,
|
|
68
77
|
};
|
|
69
78
|
};
|