@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
|
@@ -2,9 +2,9 @@ import { describe, test, expect } from 'bun:test';
|
|
|
2
2
|
import { errorAt } from './errorAt';
|
|
3
3
|
import type { FormErrors } from './types';
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// errorAt's equality must be exact over multi-step and numeric-step paths —
|
|
6
|
+
// the recursive grammar produces real multi-step addresses (nested specs
|
|
7
|
+
// today; numeric steps arrive with runtime `each` in plan phase 3).
|
|
8
8
|
type Form = {
|
|
9
9
|
email: string | undefined;
|
|
10
10
|
address: { city: string | undefined };
|
|
@@ -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
|
};
|
|
@@ -11,15 +11,15 @@ describe('toInspectable', () => {
|
|
|
11
11
|
expect(toInspectable(true)).toBe(true);
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
test('
|
|
15
|
-
expect(toInspectable(['a', 'b'])).toEqual(
|
|
14
|
+
test('keeps arrays as arrays (JsonTable renders them natively)', () => {
|
|
15
|
+
expect(toInspectable(['a', 'b'])).toEqual(['a', 'b']);
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
test('
|
|
19
|
-
expect(toInspectable([{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
test('walks array elements recursively (Sets may hide inside)', () => {
|
|
19
|
+
expect(toInspectable([{ roles: new Set(['admin']) }, { name: 'bo' }])).toEqual([
|
|
20
|
+
{ roles: 'Set(1) { "admin" }' },
|
|
21
|
+
{ name: 'bo' },
|
|
22
|
+
]);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
test('renders Sets as a descriptive string leaf', () => {
|
|
@@ -35,8 +35,8 @@ describe('toInspectable', () => {
|
|
|
35
35
|
};
|
|
36
36
|
expect(toInspectable(form)).toEqual({
|
|
37
37
|
email: 'a@b.co',
|
|
38
|
-
address: { city: undefined, tags:
|
|
39
|
-
drivers: {
|
|
38
|
+
address: { city: undefined, tags: ['home'] },
|
|
39
|
+
drivers: [{ name: 'ada', incidents: [] }],
|
|
40
40
|
});
|
|
41
41
|
});
|
|
42
42
|
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Converts a form-state snapshot into a shape JsonTable renders without
|
|
2
|
-
// throwing. JsonTable
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// throwing. JsonTable recurses into plain objects and arrays; everything
|
|
3
|
+
// else falls to the leaf renderer, and a Set there would throw as a React
|
|
4
|
+
// child. A debugger must render *any* legal form state, so:
|
|
5
5
|
//
|
|
6
|
-
// - arrays → index-keyed plain objects ({ 0: ..., 1: ... }), recursively
|
|
7
6
|
// - Sets → a descriptive string leaf: `Set(2) { "a", "b" }`
|
|
7
|
+
// - arrays → walked recursively (a Set may hide inside), kept as arrays
|
|
8
8
|
// - objects → walked recursively
|
|
9
9
|
// - leaves → passed through untouched
|
|
10
10
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -20,9 +20,7 @@ export const toInspectable = (value: unknown): unknown => {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
if (Array.isArray(value)) {
|
|
23
|
-
return
|
|
24
|
-
value.map((element, index) => [index, toInspectable(element)]),
|
|
25
|
-
);
|
|
23
|
+
return value.map(toInspectable);
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
if (isPlainObject(value)) {
|
|
@@ -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
|
|
|
@@ -78,8 +78,8 @@ export type UnionPolicyCheck<T> = HasDisallowedUnion<T> extends true
|
|
|
78
78
|
: unknown;
|
|
79
79
|
|
|
80
80
|
// Structured error model: one entry per failing constrained node, addressed
|
|
81
|
-
// by a typed path
|
|
82
|
-
//
|
|
81
|
+
// by a typed path as deep as the node (root leaves get single-key paths,
|
|
82
|
+
// nested-spec leaves get the full address). Deliberately a plain list — at form
|
|
83
83
|
// scale a linear scan is fine, and serialized string keys ('drivers.0.name')
|
|
84
84
|
// are never exposed. Read a field's error with `errorAt` (./errorAt.ts),
|
|
85
85
|
// never by hand-assembled keys.
|
|
@@ -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,181 @@ 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, and the NESTED spec on homeAddress refines its
|
|
282
|
+
// constrained leaves in place. The setSubmitted(vals) call below compiling
|
|
283
|
+
// is the proof that refinement still flows end-to-end when fields are wired
|
|
284
|
+
// through bindings.
|
|
285
|
+
type SubmittedQuote = {
|
|
286
|
+
email: string;
|
|
287
|
+
coverageType: CoverageType;
|
|
288
|
+
homeAddress: { city: string; postalCode: string };
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const COVERAGE_OPTIONS: SelectOption<CoverageType>[] = [
|
|
292
|
+
{ value: 'liability', label: 'Liability' },
|
|
293
|
+
{ value: 'comprehensive', label: 'Comprehensive' },
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
// The item-6 target: one expression wires a field. getFormFieldPropsAt
|
|
297
|
+
// bundles value + typed onChange (an immutable write at the path) +
|
|
298
|
+
// display-policy-aware errorMessage + onBlur, and the ForForm wrappers
|
|
299
|
+
// take the bundle as a single prop. Note the deep paths into homeAddress —
|
|
300
|
+
// no hand-spread updates anywhere.
|
|
301
|
+
const FieldBindingDemo = () => {
|
|
302
|
+
const [submitted, setSubmitted] = useState<SubmittedQuote | null>(null);
|
|
303
|
+
|
|
304
|
+
const { getFormFieldPropsAt, submit } = useFormState({
|
|
305
|
+
initialValues: {
|
|
306
|
+
email: undefined,
|
|
307
|
+
coverageType: null,
|
|
308
|
+
homeAddress: { city: undefined, postalCode: undefined },
|
|
309
|
+
} as QuoteFormValues,
|
|
310
|
+
constraints: {
|
|
311
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
312
|
+
coverageType: notEmpty('coverageType'),
|
|
313
|
+
// A nested spec: errors inside homeAddress get real multi-step
|
|
314
|
+
// addresses (['homeAddress', 'postalCode']), which is exactly what the
|
|
315
|
+
// deep-path bindings below read via errorMessage.
|
|
316
|
+
homeAddress: {
|
|
317
|
+
city: notEmpty('city'),
|
|
318
|
+
postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
onSubmit: (vals) => setSubmitted(vals),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
<form
|
|
326
|
+
style={{ maxWidth: 420, display: 'grid', gap: 16 }}
|
|
327
|
+
onSubmit={(e) => {
|
|
328
|
+
e.preventDefault();
|
|
329
|
+
submit();
|
|
330
|
+
}}
|
|
331
|
+
>
|
|
332
|
+
<TextInputForForm
|
|
333
|
+
label="Email"
|
|
334
|
+
type="email"
|
|
335
|
+
placeholder="you@example.com"
|
|
336
|
+
hint="Blur the empty field to see touched-gated errors"
|
|
337
|
+
formFieldProps={getFormFieldPropsAt(['email'])}
|
|
338
|
+
/>
|
|
339
|
+
|
|
340
|
+
<SingleSelectForForm
|
|
341
|
+
label="Coverage"
|
|
342
|
+
options={COVERAGE_OPTIONS}
|
|
343
|
+
placeholder="Pick a coverage"
|
|
344
|
+
formFieldProps={getFormFieldPropsAt(['coverageType'])}
|
|
345
|
+
/>
|
|
346
|
+
|
|
347
|
+
<TextInputForForm
|
|
348
|
+
label="City"
|
|
349
|
+
formFieldProps={getFormFieldPropsAt(['homeAddress', 'city'])}
|
|
350
|
+
/>
|
|
351
|
+
|
|
352
|
+
<TextInputForForm
|
|
353
|
+
label="Postal code"
|
|
354
|
+
formFieldProps={getFormFieldPropsAt(['homeAddress', 'postalCode'])}
|
|
355
|
+
/>
|
|
356
|
+
|
|
357
|
+
<div>
|
|
358
|
+
<Button type="submit" variant="primary">
|
|
359
|
+
Get quote
|
|
360
|
+
</Button>
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
{submitted && (
|
|
364
|
+
<pre
|
|
365
|
+
style={{
|
|
366
|
+
background: 'var(--ui-surface-muted, #f4f4f4)',
|
|
367
|
+
padding: 12,
|
|
368
|
+
borderRadius: 6,
|
|
369
|
+
fontSize: 12,
|
|
370
|
+
margin: 0,
|
|
371
|
+
}}
|
|
372
|
+
>
|
|
373
|
+
{`onSubmit received (narrowed type):\n${JSON.stringify(submitted, null, 2)}`}
|
|
374
|
+
</pre>
|
|
375
|
+
)}
|
|
376
|
+
</form>
|
|
377
|
+
);
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
export const FieldBinding: Story = {
|
|
381
|
+
render: () => (
|
|
382
|
+
<div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
|
|
383
|
+
<FieldBindingDemo />
|
|
384
|
+
</div>
|
|
385
|
+
),
|
|
386
|
+
// Walks the binding flow: touched/blur gating of errorMessage (before any
|
|
387
|
+
// submit attempt), deep-path writes through the wrappers, submit-attempt
|
|
388
|
+
// gating for untouched fields, and the narrowed payload reaching onSubmit.
|
|
389
|
+
play: async ({ canvasElement }) => {
|
|
390
|
+
const canvas = within(canvasElement);
|
|
391
|
+
const body = within(canvasElement.ownerDocument.body);
|
|
392
|
+
|
|
393
|
+
// Nothing shown initially: the email error exists in the raw list, but
|
|
394
|
+
// errorMessage withholds it until the field is touched.
|
|
395
|
+
await expect(
|
|
396
|
+
canvas.queryByText("'email' cannot be empty"),
|
|
397
|
+
).not.toBeInTheDocument();
|
|
398
|
+
|
|
399
|
+
// Blurring the empty email field marks it touched — its error appears
|
|
400
|
+
// without any submit attempt, and only its own.
|
|
401
|
+
await userEvent.click(canvas.getByLabelText(/^Email/));
|
|
402
|
+
await userEvent.tab();
|
|
403
|
+
await expect(canvas.getByText("'email' cannot be empty")).toBeInTheDocument();
|
|
404
|
+
await expect(
|
|
405
|
+
canvas.queryByText("'coverageType' cannot be empty"),
|
|
406
|
+
).not.toBeInTheDocument();
|
|
407
|
+
|
|
408
|
+
// Deep-path writes flow through the wrapper onChange.
|
|
409
|
+
await userEvent.type(canvas.getByLabelText(/^City/), 'Lyon');
|
|
410
|
+
await expect(canvas.getByLabelText(/^City/)).toHaveValue('Lyon');
|
|
411
|
+
|
|
412
|
+
// A submit attempt unlocks the untouched fields' errors too — including
|
|
413
|
+
// the NESTED postalCode error, addressed ['homeAddress', 'postalCode']
|
|
414
|
+
// by the recursive walk and surfaced by its deep-path binding.
|
|
415
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Get quote' }));
|
|
416
|
+
await expect(
|
|
417
|
+
canvas.getByText("'coverageType' cannot be empty"),
|
|
418
|
+
).toBeInTheDocument();
|
|
419
|
+
await expect(
|
|
420
|
+
canvas.getByText("'postalCode' cannot be empty"),
|
|
421
|
+
).toBeInTheDocument();
|
|
422
|
+
// The touched City field passed its nested constraint — no error.
|
|
423
|
+
await expect(canvas.queryByText("'city' cannot be empty")).not.toBeInTheDocument();
|
|
424
|
+
|
|
425
|
+
// Fix the remaining fields; committing a select option counts as its touch.
|
|
426
|
+
await userEvent.type(canvas.getByLabelText(/^Postal code/), '69001');
|
|
427
|
+
await expect(
|
|
428
|
+
canvas.queryByText("'postalCode' cannot be empty"),
|
|
429
|
+
).not.toBeInTheDocument();
|
|
430
|
+
await userEvent.type(canvas.getByLabelText(/^Email/), 'will@example.com');
|
|
431
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Coverage' }));
|
|
432
|
+
// The option list is portaled — query the document, not the canvas.
|
|
433
|
+
await userEvent.click(await body.findByRole('option', { name: 'Liability' }));
|
|
434
|
+
await expect(
|
|
435
|
+
canvas.queryByText("'coverageType' cannot be empty"),
|
|
436
|
+
).not.toBeInTheDocument();
|
|
437
|
+
|
|
438
|
+
// Valid submit delivers the narrowed payload.
|
|
439
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Get quote' }));
|
|
440
|
+
await expect(canvas.getByText(/onSubmit received/)).toBeInTheDocument();
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
|
|
262
444
|
type DebuggerDemoValues = {
|
|
263
445
|
email: string | undefined;
|
|
264
446
|
nickname: string | undefined;
|
|
@@ -387,24 +569,27 @@ export const WithDebugger: Story = {
|
|
|
387
569
|
|
|
388
570
|
// Open: live state, including errors the form itself isn't showing yet
|
|
389
571
|
// (its display is submit-gated; the debugger sees the raw truth).
|
|
572
|
+
// String leaves render with surrounding quotes (Chrome-inspector style).
|
|
390
573
|
await userEvent.click(body.getByRole('button', { name: 'signup form' }));
|
|
391
574
|
await expect(body.getByText('isValid')).toBeInTheDocument();
|
|
392
|
-
await expect(body.getByText("'email' cannot be empty")).toBeInTheDocument();
|
|
393
575
|
await expect(
|
|
394
|
-
body.getByText("'
|
|
576
|
+
body.getByText('"\'email\' cannot be empty"'),
|
|
577
|
+
).toBeInTheDocument();
|
|
578
|
+
await expect(
|
|
579
|
+
body.getByText('"\'nickname\' cannot be empty"'),
|
|
395
580
|
).toBeInTheDocument();
|
|
396
581
|
|
|
397
582
|
// Live update while open: value appears, its error entry drops out.
|
|
398
583
|
await userEvent.type(canvas.getByLabelText(/^Nickname/), 'will');
|
|
399
|
-
await expect(body.getByText('will')).toBeInTheDocument();
|
|
584
|
+
await expect(body.getByText('"will"')).toBeInTheDocument();
|
|
400
585
|
await expect(
|
|
401
|
-
body.queryByText("'nickname' cannot be empty"),
|
|
586
|
+
body.queryByText('"\'nickname\' cannot be empty"'),
|
|
402
587
|
).not.toBeInTheDocument();
|
|
403
588
|
|
|
404
589
|
// List values render in the window (index-keyed).
|
|
405
590
|
await userEvent.type(canvas.getByLabelText(/^Tags/), 'typescript');
|
|
406
591
|
await userEvent.click(canvas.getByRole('button', { name: 'Add' }));
|
|
407
|
-
await expect(body.getByText('typescript')).toBeInTheDocument();
|
|
592
|
+
await expect(body.getByText('"typescript"')).toBeInTheDocument();
|
|
408
593
|
|
|
409
594
|
// Close: window unmounts, trigger stays.
|
|
410
595
|
await userEvent.click(body.getByRole('button', { name: 'signup form' }));
|