@structuralists/scaffolding 0.9.0 → 0.10.1
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/package.json +1 -1
- package/src/forms/CLAUDE.md +65 -14
- package/src/forms/plan.md +23 -7
- package/src/forms/useFormState/FormDebugger.tsx +2 -1
- package/src/forms/useFormState/deriveErrors.test.ts +76 -0
- package/src/forms/useFormState/deriveErrors.ts +35 -0
- package/src/forms/useFormState/errorAt.test.ts +54 -0
- package/src/forms/useFormState/errorAt.ts +27 -0
- package/src/forms/useFormState/types.ts +17 -6
- package/src/forms/useFormState/useFormDebugger.test.tsx +57 -0
- package/src/forms/useFormState/useFormDebugger.ts +38 -0
- package/src/forms/useFormState/useFormState.stories.tsx +29 -9
- package/src/forms/useFormState/useFormState.test-d.ts +25 -0
- package/src/forms/useFormState/useFormState.test.tsx +25 -10
- package/src/forms/useFormState/useFormState.ts +20 -53
- package/src/forms/useFormState/useFormSubmit.test.tsx +86 -0
- package/src/forms/useFormState/useFormSubmit.ts +38 -0
- package/src/forms/validations/walk.ts +6 -4
package/package.json
CHANGED
package/src/forms/CLAUDE.md
CHANGED
|
@@ -168,6 +168,30 @@ useFormState({
|
|
|
168
168
|
});
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
+
## The error model
|
|
172
|
+
|
|
173
|
+
Validation failures surface as a structured list, not a keyed record:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
type FormError<T> = { path: Path<T>; error: string }; // single-key paths on the flat grammar
|
|
177
|
+
type FormErrors<T> = readonly FormError<T>[]; // isValid === (errors.length === 0)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Read one field's message with the typed accessor, never by hand-assembled
|
|
181
|
+
keys — serialized path strings (`'drivers.0.name'`) are deliberately not
|
|
182
|
+
exposed:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
errorAt(errors, ['email']); // string | undefined
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`errorAt` (useFormState/errorAt.ts) matches by exact structural step
|
|
189
|
+
equality (no prefix matching); with first-error-wins validation there is at
|
|
190
|
+
most one entry per path, and if collect-all ever lands the first entry stays
|
|
191
|
+
the one shown. The path-addressed model is what the recursive grammar
|
|
192
|
+
(errors just carry longer paths) and `getFormFieldPropsAt`'s `errorMessage`
|
|
193
|
+
build on.
|
|
194
|
+
|
|
171
195
|
## Union policy — what form state may hold
|
|
172
196
|
|
|
173
197
|
The path machinery is load-bearing (structured `{path, error}[]` errors and
|
|
@@ -270,19 +294,45 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
270
294
|
|
|
271
295
|
- `useFormState/` — the hook + the value-model types (`FormValueSimple`,
|
|
272
296
|
`FormValuesObject`, `FormValueList`). Hook surface: `{ values,
|
|
273
|
-
onValueChanges, errors, isValid, submitAttempted, submit, Debugger }
|
|
274
|
-
`
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
297
|
+
onValueChanges, errors, isValid, submitAttempted, submit, Debugger }`.
|
|
298
|
+
`useFormState.ts` itself is deliberately **plumbing only**: it holds the
|
|
299
|
+
values `useState` (a bare `useState` today; granular path-based setters
|
|
300
|
+
are what would earn values state a module of its own), links up the
|
|
301
|
+
modules below, and recomposes their outputs into `FormHelpers<T>`. Each
|
|
302
|
+
slice lives in its own file so it is independently comprehensible and
|
|
303
|
+
unit-testable at its own boundary:
|
|
304
|
+
- `types.ts` — the value model, the union-policy gate
|
|
305
|
+
(`UnionPolicyCheck` and friends), the `FormError`/`FormErrors` model,
|
|
306
|
+
`FormDebugSnapshot`, `FormHelpers`.
|
|
307
|
+
- `deriveErrors.ts` — `deriveFormErrors(values, constraints)`, the pure
|
|
308
|
+
(React-free) derivation of the structured `FormErrors<T>` list (see
|
|
309
|
+
"The error model"), re-run every render (validators are pure and
|
|
310
|
+
cheap); `isValid` is its emptiness, derived inline in the hook. Its
|
|
311
|
+
loop delegates per-entry semantics to `validations/walk.ts` and
|
|
312
|
+
carries two documented honest widenings: the `Object.keys` cast, and
|
|
313
|
+
`failure.path as Path<T>` — `[key]` is a valid single-key `Path<T>`,
|
|
314
|
+
but TS cannot compute `Path<T>` for an unresolved generic `T` to see
|
|
315
|
+
the correlation. It also hosts the cast-free `FlatConstraintEntry`
|
|
316
|
+
assignment that polices grammar growth (see the `validations/` bullet).
|
|
317
|
+
- `errorAt.ts` — the typed error lookup.
|
|
318
|
+
- `useFormSubmit.ts` — submit orchestration: owns `submitAttempted`
|
|
319
|
+
(lets UIs gate error display) and the validity gate in front of
|
|
320
|
+
`onSubmit`; its `submit()` performs the one honest *refinement* cast
|
|
321
|
+
to `Refine<T, V>` — earned because the validators just passed at
|
|
322
|
+
runtime. `V` is not inferable from `onSubmit`'s parameter position,
|
|
323
|
+
so `useFormState` applies `<T, V>` explicitly.
|
|
324
|
+
- `useFormDebugger.ts` — debugger plumbing: creates one `snapshotStore`
|
|
325
|
+
+ one Debugger component per hook instance (lazy ref — the component's
|
|
326
|
+
identity must stay stable across renders or it would remount, and lose
|
|
327
|
+
its open/closed state, on every keystroke) and publishes the
|
|
328
|
+
`FormDebugSnapshot` after every commit.
|
|
329
|
+
- `FormDebugger.tsx` / `snapshotStore.ts` / `inspectable.ts` — the
|
|
330
|
+
`Debugger` itself: a per-instance dev-time overlay (fixed trigger,
|
|
331
|
+
bottom-right, portaled to `<body>`) that opens a live `JsonTable` view
|
|
332
|
+
of the form's internal state. Only an *open* debugger window
|
|
333
|
+
subscribes (`useSyncExternalStore`), so an unused Debugger costs
|
|
334
|
+
~nothing. `inspectable.ts` converts arrays/Sets to shapes `JsonTable`
|
|
335
|
+
can render.
|
|
286
336
|
- `path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
|
|
287
337
|
Will be used for granular setters and for surfacing per-field
|
|
288
338
|
errors/touched state. Coupled to the form value model intentionally.
|
|
@@ -294,7 +344,8 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
294
344
|
hook delegates to (`validateEntry`). `Validations<T>` accepts bare
|
|
295
345
|
`(val) => string | null` functions too — they simply narrow nothing.
|
|
296
346
|
The walk's entry type (`FlatConstraintEntry`) is how the compiler polices
|
|
297
|
-
the
|
|
347
|
+
the error loop in `useFormState/deriveErrors.ts`: `constraints[key]` is
|
|
348
|
+
*assigned* to it, never cast,
|
|
298
349
|
so a grammar form the walk doesn't understand (a nested spec, an `each`)
|
|
299
350
|
is a compile error at the assignment — a cast there would silently accept
|
|
300
351
|
new grammar and misinterpret it at runtime (e.g. call an array as a
|
package/src/forms/plan.md
CHANGED
|
@@ -22,9 +22,15 @@ TS wall can't strand finished work behind it.
|
|
|
22
22
|
3. **Error-model swap** to `{path, error}[]` on the *flat* baseline (paths
|
|
23
23
|
are single-key paths). Doesn't depend on nested constraints; hard
|
|
24
24
|
prerequisite for item 6; makes phases 2–3 runtime-plumbing-free.
|
|
25
|
-
|
|
25
|
+
✅ *done* — `FormError<T>`/`FormErrors<T>` (`{ path: Path<T>; error }[]`
|
|
26
|
+
in useFormState/types.ts), `isValid` derived from emptiness, and the
|
|
27
|
+
interim consumer accessor is a standalone `errorAt(errors, path)`
|
|
28
|
+
(useFormState/errorAt.ts) — the smallest surface that keeps stories
|
|
29
|
+
readable; item 6 folds the same lookup into `getFormFieldPropsAt`'s
|
|
30
|
+
`errorMessage`. The Debugger needed no change (`toInspectable`
|
|
31
|
+
index-keys arrays).
|
|
26
32
|
4. **Item 5 — elements/state split.** Pure churn, no type risk; do it before
|
|
27
|
-
item 6 so the wrappers land in their final home once.
|
|
33
|
+
item 6 so the wrappers land in their final home once. ← *current*
|
|
28
34
|
5. **Item 6 — field binding** (`getFormFieldPropsAt` + wrappers). Needs the
|
|
29
35
|
error model and the split; `Path`/`ValueAt` already validated.
|
|
30
36
|
6. **Type spike, then items 2/3/4** — the recursion risk zone. Throwaway
|
|
@@ -174,13 +180,19 @@ multi-second check time:
|
|
|
174
180
|
- post-item-1 soundness fix (per-member-sound `MemberExcludes` in the array
|
|
175
181
|
arm and `allOf`, plus its probes): check 0.77 s, 107,612 instantiations,
|
|
176
182
|
48,507 types (+0.8%)
|
|
183
|
+
- pre-step-3 HEAD (union policy + validator arrays merged, PRs #17/#18):
|
|
184
|
+
check 0.78 s, 108,978 instantiations, 48,678 types
|
|
185
|
+
- post-step-3 (error-model swap: `Path<T>`-typed `FormError`, `errorAt`,
|
|
186
|
+
plus probes): check 0.79 s, 113,724 instantiations, 52,294 types
|
|
187
|
+
(+4.4% instantiations over pre-step-3 HEAD)
|
|
177
188
|
|
|
178
189
|
## Runtime consequences (can't be dodged)
|
|
179
190
|
|
|
180
191
|
- **The validator walk becomes a tree walk.** Recursion mirrors `read()` in
|
|
181
192
|
`path/path.ts`; share the traversal or keep them deliberately parallel.
|
|
182
193
|
- **`FormErrors` can no longer be `Partial<Record<keyof T, string>>`.**
|
|
183
|
-
|
|
194
|
+
✅ *Landed with working-order step 3* (on the flat baseline, single-key
|
|
195
|
+
paths). Decided: errors become a plain list of structured entries,
|
|
184
196
|
|
|
185
197
|
```ts
|
|
186
198
|
type FormError<T> = { path: Path<T>; error: string };
|
|
@@ -195,8 +207,11 @@ multi-second check time:
|
|
|
195
207
|
as an *internal* implementation detail — the concatenation scheme must be
|
|
196
208
|
fully encapsulated and opaque to everything outside it.
|
|
197
209
|
- **`errors` display wiring** in stories/components goes through a typed
|
|
198
|
-
accessor
|
|
199
|
-
|
|
210
|
+
accessor, never through hand-assembled keys. ✅ *Decided and landed with
|
|
211
|
+
step 3*: a standalone `errorAt(errors, path)` (useFormState/errorAt.ts) —
|
|
212
|
+
structural step-equality lookup, first entry wins. Chosen over a
|
|
213
|
+
cursor-based lookup as the smallest surface that keeps stories readable
|
|
214
|
+
on the flat grammar; item 6's `errorMessage` reuses it internally.
|
|
200
215
|
|
|
201
216
|
## Phases
|
|
202
217
|
|
|
@@ -215,8 +230,9 @@ tests, story updates where visible, probe ratchet.
|
|
|
215
230
|
*single* constraints (`cond ? v1 : v2`) sound too (union of per-branch
|
|
216
231
|
refinements, pinned in `validations/types.test-d.ts`).
|
|
217
232
|
2. **Nested object specs + recursive `Refine`.** The risk phase — the probe
|
|
218
|
-
ratchet matters most here.
|
|
219
|
-
|
|
233
|
+
ratchet matters most here. (The `{path, error}[]` error model already
|
|
234
|
+
landed in working-order step 3 — nested errors just carry longer paths;
|
|
235
|
+
no error plumbing changes in this phase.)
|
|
220
236
|
3. **List `each` specs.** Runtime walks every element; error paths carry the
|
|
221
237
|
numeric step (`['drivers', 3, 'name']`). Refined element type flows
|
|
222
238
|
through `Array<...>`.
|
|
@@ -16,7 +16,8 @@ export type FormDebuggerProps = {
|
|
|
16
16
|
export type FormDebuggerComponent = (props: FormDebuggerProps) => ReactElement;
|
|
17
17
|
|
|
18
18
|
// Builds the per-hook-instance Debugger component. Called once per
|
|
19
|
-
// `useFormState` instance (from
|
|
19
|
+
// `useFormState` instance (from `useFormDebugger`'s lazy ref) so the
|
|
20
|
+
// returned component's
|
|
20
21
|
// identity is stable across renders — a component recreated each render
|
|
21
22
|
// would remount, and lose its open/closed state, on every keystroke.
|
|
22
23
|
export const createFormDebugger = <T extends FormValuesObject>(
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { deriveFormErrors } from './deriveErrors';
|
|
3
|
+
import { matches, notEmpty } from '../validators/validators';
|
|
4
|
+
|
|
5
|
+
// Direct, React-free tests of the pure derivation: values + constraints in,
|
|
6
|
+
// structured `{ path, error }[]` out. Hook-level behavior (live re-derivation
|
|
7
|
+
// across renders, submit gating) stays in useFormState.test.tsx.
|
|
8
|
+
|
|
9
|
+
type SignupForm = {
|
|
10
|
+
email: string | undefined;
|
|
11
|
+
nickname: string | undefined;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const emptyForm: SignupForm = { email: undefined, nickname: undefined };
|
|
15
|
+
|
|
16
|
+
describe('deriveFormErrors', () => {
|
|
17
|
+
test('no constraints yields no errors', () => {
|
|
18
|
+
expect(deriveFormErrors(emptyForm, undefined)).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('an empty constraints object yields no errors', () => {
|
|
22
|
+
expect(deriveFormErrors(emptyForm, {})).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('a failing field contributes one path-addressed entry', () => {
|
|
26
|
+
const errors = deriveFormErrors(emptyForm, { email: notEmpty('email') });
|
|
27
|
+
expect(errors).toEqual([
|
|
28
|
+
{ path: ['email'], error: "'email' cannot be empty" },
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('a passing field contributes nothing', () => {
|
|
33
|
+
const errors = deriveFormErrors(
|
|
34
|
+
{ email: 'a@b.co', nickname: undefined },
|
|
35
|
+
{ email: notEmpty('email') },
|
|
36
|
+
);
|
|
37
|
+
expect(errors).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('every failing constrained field gets its own entry', () => {
|
|
41
|
+
const errors = deriveFormErrors(emptyForm, {
|
|
42
|
+
email: notEmpty('email'),
|
|
43
|
+
nickname: notEmpty('nickname'),
|
|
44
|
+
});
|
|
45
|
+
expect(errors).toEqual([
|
|
46
|
+
{ path: ['email'], error: "'email' cannot be empty" },
|
|
47
|
+
{ path: ['nickname'], error: "'nickname' cannot be empty" },
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a validator array runs in order with first-error-wins', () => {
|
|
52
|
+
const constraints = {
|
|
53
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
54
|
+
};
|
|
55
|
+
expect(deriveFormErrors(emptyForm, constraints)).toEqual([
|
|
56
|
+
{ path: ['email'], error: "'email' cannot be empty" },
|
|
57
|
+
]);
|
|
58
|
+
expect(
|
|
59
|
+
deriveFormErrors({ email: 'nope', nickname: undefined }, constraints),
|
|
60
|
+
).toEqual([{ path: ['email'], error: "'email' must be a valid email" }]);
|
|
61
|
+
expect(
|
|
62
|
+
deriveFormErrors({ email: 'a@b.co', nickname: undefined }, constraints),
|
|
63
|
+
).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('an empty validator array passes', () => {
|
|
67
|
+
expect(deriveFormErrors(emptyForm, { email: [] })).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a null constraint entry (possible from untyped JS) is skipped', () => {
|
|
71
|
+
const constraints = { email: null } as unknown as {
|
|
72
|
+
email: (val: string | undefined) => string | null;
|
|
73
|
+
};
|
|
74
|
+
expect(deriveFormErrors(emptyForm, constraints)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Path } from '../path/types';
|
|
2
|
+
import type { Validations } from '../validations/types';
|
|
3
|
+
import { validateEntry } from '../validations/walk';
|
|
4
|
+
import type { FlatConstraintEntry } from '../validations/walk';
|
|
5
|
+
import type { FormError, FormValuesObject } from './types';
|
|
6
|
+
|
|
7
|
+
// The pure half of the hook's error model: current values + constraints in,
|
|
8
|
+
// structured `{ path, error }[]` out. Validation is live-derived (validators
|
|
9
|
+
// are pure and cheap), so this runs every render — it must stay free of
|
|
10
|
+
// React and of any per-call state. `isValid` is just this list's emptiness;
|
|
11
|
+
// the hook derives it inline.
|
|
12
|
+
export const deriveFormErrors = <T extends FormValuesObject>(
|
|
13
|
+
values: T,
|
|
14
|
+
constraints: Validations<T> | undefined,
|
|
15
|
+
): FormError<T>[] => {
|
|
16
|
+
const errors: FormError<T>[] = [];
|
|
17
|
+
if (!constraints) return errors;
|
|
18
|
+
|
|
19
|
+
for (const key of Object.keys(constraints) as (keyof T & string)[]) {
|
|
20
|
+
// No cast: if the grammar grows a constraint form the walk doesn't
|
|
21
|
+
// understand, this assignment is the compile error that says so.
|
|
22
|
+
const entry: FlatConstraintEntry | undefined = constraints[key];
|
|
23
|
+
if (entry == null) continue;
|
|
24
|
+
const failure = validateEntry(entry, values[key], [key]);
|
|
25
|
+
if (failure == null) continue;
|
|
26
|
+
// The walk returns the address it was handed, and `[key]` — a key of
|
|
27
|
+
// a constraints object type-checked against T — is a valid single-key
|
|
28
|
+
// Path<T>. TS can't compute Path<T> for an unresolved generic T, so
|
|
29
|
+
// the correlation needs the same honest widening as the keys cast
|
|
30
|
+
// above.
|
|
31
|
+
errors.push({ path: failure.path as Path<T>, error: failure.error });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return errors;
|
|
35
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { errorAt } from './errorAt';
|
|
3
|
+
import type { FormErrors } from './types';
|
|
4
|
+
|
|
5
|
+
// The flat grammar only produces single-key paths today, but errorAt's
|
|
6
|
+
// equality must already be exact over multi-step and numeric-step paths —
|
|
7
|
+
// the recursive grammar (plan phases 2–3) reuses it unchanged.
|
|
8
|
+
type Form = {
|
|
9
|
+
email: string | undefined;
|
|
10
|
+
address: { city: string | undefined };
|
|
11
|
+
tags: Array<{ label: string | undefined }>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const errors: FormErrors<Form> = [
|
|
15
|
+
{ path: ['email'], error: "'email' cannot be empty" },
|
|
16
|
+
{ path: ['address', 'city'], error: "'city' cannot be empty" },
|
|
17
|
+
{ path: ['tags', 0, 'label'], error: "'label' cannot be empty" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe('errorAt', () => {
|
|
21
|
+
test('finds a single-key path', () => {
|
|
22
|
+
expect(errorAt(errors, ['email'])).toBe("'email' cannot be empty");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('finds a nested path by exact step equality', () => {
|
|
26
|
+
expect(errorAt(errors, ['address', 'city'])).toBe(
|
|
27
|
+
"'city' cannot be empty",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('numeric steps participate in equality', () => {
|
|
32
|
+
expect(errorAt(errors, ['tags', 0, 'label'])).toBe(
|
|
33
|
+
"'label' cannot be empty",
|
|
34
|
+
);
|
|
35
|
+
expect(errorAt(errors, ['tags', 1, 'label'])).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a prefix of an error path is not a match', () => {
|
|
39
|
+
expect(errorAt(errors, ['address'])).toBeUndefined();
|
|
40
|
+
expect(errorAt(errors, ['tags'])).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('returns undefined when the list has no entry for the path', () => {
|
|
44
|
+
expect(errorAt([], ['email'])).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('first entry wins when a path has several errors', () => {
|
|
48
|
+
const duplicated: FormErrors<Form> = [
|
|
49
|
+
{ path: ['email'], error: 'first' },
|
|
50
|
+
{ path: ['email'], error: 'second' },
|
|
51
|
+
];
|
|
52
|
+
expect(errorAt(duplicated, ['email'])).toBe('first');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Path, PathStep } from '../path/types';
|
|
2
|
+
import type { FormErrors, FormValuesObject } from './types';
|
|
3
|
+
|
|
4
|
+
// 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 first-error-wins
|
|
7
|
+
// validation there is at most one entry per path today; should collect-all
|
|
8
|
+
// ever land, the first entry stays the one shown.
|
|
9
|
+
export const errorAt = <T extends FormValuesObject>(
|
|
10
|
+
errors: FormErrors<T>,
|
|
11
|
+
path: Path<T>,
|
|
12
|
+
): string | undefined => {
|
|
13
|
+
// Path<T> is always a PathStep tuple; the conditional type just can't
|
|
14
|
+
// prove it for an unresolved T. Same honest widening as `Cursor.at`.
|
|
15
|
+
const steps = path as readonly PathStep[];
|
|
16
|
+
|
|
17
|
+
const match = errors.find((candidate) => {
|
|
18
|
+
const candidateSteps = candidate.path as readonly PathStep[];
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
candidateSteps.length === steps.length &&
|
|
22
|
+
candidateSteps.every((step, index) => step === steps[index])
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return match?.error;
|
|
27
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// Type-only
|
|
2
|
-
// turn, but the
|
|
1
|
+
// Type-only imports; FormDebugger.tsx and path/types.ts import value types
|
|
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
5
|
|
|
5
6
|
export type FormValueSimple = string | number | bigint | boolean | string[] | Set<string> | undefined | null;
|
|
6
7
|
|
|
@@ -76,11 +77,21 @@ export type UnionPolicyCheck<T> = HasDisallowedUnion<T> extends true
|
|
|
76
77
|
}
|
|
77
78
|
: unknown;
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// Structured error model: one entry per failing constrained node, addressed
|
|
81
|
+
// by a typed path (single-key paths on the flat grammar; deeper addresses
|
|
82
|
+
// arrive with the recursive grammar). Deliberately a plain list — at form
|
|
83
|
+
// scale a linear scan is fine, and serialized string keys ('drivers.0.name')
|
|
84
|
+
// are never exposed. Read a field's error with `errorAt` (./errorAt.ts),
|
|
85
|
+
// never by hand-assembled keys.
|
|
86
|
+
export type FormError<T extends FormValuesObject> = {
|
|
87
|
+
readonly path: Path<T>;
|
|
88
|
+
readonly error: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type FormErrors<T extends FormValuesObject> = readonly FormError<T>[];
|
|
82
92
|
|
|
83
|
-
// What the hook publishes to its Debugger after
|
|
93
|
+
// What the hook publishes (via `useFormDebugger`) to its Debugger after
|
|
94
|
+
// every commit. Snapshots are
|
|
84
95
|
// replaced whole (never mutated) so `useSyncExternalStore` consumers can
|
|
85
96
|
// rely on reference equality.
|
|
86
97
|
export type FormDebugSnapshot<T extends FormValuesObject> = {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { afterEach, describe, test, expect } from 'bun:test';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
|
|
5
|
+
// The Debugger portals into document.body, so leftovers from a previous
|
|
6
|
+
// test are visible to `screen` queries — clean up explicitly (bun:test has
|
|
7
|
+
// no global afterEach for RTL's auto-cleanup to hook).
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
import { useFormDebugger } from './useFormDebugger';
|
|
10
|
+
|
|
11
|
+
// The debugger-plumbing boundary in isolation: the hook must hand back a
|
|
12
|
+
// render-stable component wired to the latest published snapshot. The full
|
|
13
|
+
// overlay behavior (open/close, live form wiring) is covered in
|
|
14
|
+
// FormDebugger.test.tsx through useFormState.
|
|
15
|
+
|
|
16
|
+
const Host = () => {
|
|
17
|
+
const [count, setCount] = useState(0);
|
|
18
|
+
const Debugger = useFormDebugger({
|
|
19
|
+
snapshot: {
|
|
20
|
+
values: { count },
|
|
21
|
+
errors: [],
|
|
22
|
+
isValid: true,
|
|
23
|
+
submitAttempted: false,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div>
|
|
29
|
+
<button type="button" onClick={() => setCount((prev) => prev + 1)}>
|
|
30
|
+
inc
|
|
31
|
+
</button>
|
|
32
|
+
<Debugger label="dbg" />
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('useFormDebugger', () => {
|
|
38
|
+
test('returns a Debugger showing the current snapshot', () => {
|
|
39
|
+
render(<Host />);
|
|
40
|
+
fireEvent.click(screen.getByRole('button', { name: 'dbg' }));
|
|
41
|
+
|
|
42
|
+
expect(screen.getByText('count')).toBeTruthy();
|
|
43
|
+
expect(screen.getByText('0')).toBeTruthy();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the component identity survives re-renders and shows fresh snapshots', () => {
|
|
47
|
+
render(<Host />);
|
|
48
|
+
fireEvent.click(screen.getByRole('button', { name: 'dbg' }));
|
|
49
|
+
|
|
50
|
+
fireEvent.click(screen.getByRole('button', { name: 'inc' }));
|
|
51
|
+
|
|
52
|
+
// The window is still open (a remounted Debugger would have reset to
|
|
53
|
+
// closed) and reflects the snapshot published after the re-render.
|
|
54
|
+
expect(screen.getByText('1')).toBeTruthy();
|
|
55
|
+
expect(screen.queryByText('0')).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { createFormDebugger } from './FormDebugger';
|
|
3
|
+
import type { FormDebuggerComponent } from './FormDebugger';
|
|
4
|
+
import { createSnapshotStore } from './snapshotStore';
|
|
5
|
+
import type { SnapshotStore } from './snapshotStore';
|
|
6
|
+
import type { FormDebugSnapshot, FormValuesObject } from './types';
|
|
7
|
+
|
|
8
|
+
export type UseFormDebuggerArgs<T extends FormValuesObject> = {
|
|
9
|
+
snapshot: FormDebugSnapshot<T>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Debugger plumbing: one store + one component per hook instance, created
|
|
13
|
+
// lazily on first render. The component's identity must be stable across
|
|
14
|
+
// renders — recreated each render it would remount (and lose its
|
|
15
|
+
// open/closed state) on every keystroke.
|
|
16
|
+
export const useFormDebugger = <T extends FormValuesObject>(
|
|
17
|
+
args: UseFormDebuggerArgs<T>,
|
|
18
|
+
): FormDebuggerComponent => {
|
|
19
|
+
const { snapshot } = args;
|
|
20
|
+
|
|
21
|
+
const debugRef = useRef<{
|
|
22
|
+
store: SnapshotStore<FormDebugSnapshot<T>>;
|
|
23
|
+
Debugger: FormDebuggerComponent;
|
|
24
|
+
} | null>(null);
|
|
25
|
+
if (debugRef.current === null) {
|
|
26
|
+
const store = createSnapshotStore(snapshot);
|
|
27
|
+
debugRef.current = { store, Debugger: createFormDebugger(store) };
|
|
28
|
+
}
|
|
29
|
+
const { store, Debugger } = debugRef.current;
|
|
30
|
+
|
|
31
|
+
// Publish after every commit. With no debugger window subscribed this is a
|
|
32
|
+
// field write and an empty notify loop — effectively free.
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
store.publish(snapshot);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return Debugger;
|
|
38
|
+
};
|
|
@@ -2,6 +2,8 @@ import { useState } from 'react';
|
|
|
2
2
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
3
|
import { expect, userEvent, within } from 'storybook/test';
|
|
4
4
|
import { useFormState } from './useFormState';
|
|
5
|
+
import { errorAt } from './errorAt';
|
|
6
|
+
import type { FormErrors } from './types';
|
|
5
7
|
import { matches, minLength, notEmpty } from '../validators/validators';
|
|
6
8
|
import { Field } from '../../components/Forms/Field';
|
|
7
9
|
import { Input } from '../../components/Forms/Input';
|
|
@@ -62,7 +64,11 @@ const SignupDemo = () => {
|
|
|
62
64
|
onSubmit: (vals) => setSubmitted(vals),
|
|
63
65
|
});
|
|
64
66
|
|
|
65
|
-
|
|
67
|
+
// errors is a structured `{ path, error }[]` list; a field's message is
|
|
68
|
+
// read with the typed `errorAt` accessor, never a hand-assembled key.
|
|
69
|
+
const shownErrors: FormErrors<SignupFormValues> = submitAttempted
|
|
70
|
+
? errors
|
|
71
|
+
: [];
|
|
66
72
|
|
|
67
73
|
return (
|
|
68
74
|
<form
|
|
@@ -72,7 +78,11 @@ const SignupDemo = () => {
|
|
|
72
78
|
submit();
|
|
73
79
|
}}
|
|
74
80
|
>
|
|
75
|
-
<Field
|
|
81
|
+
<Field
|
|
82
|
+
label="Email"
|
|
83
|
+
error={errorAt(shownErrors, ['email'])}
|
|
84
|
+
htmlFor="signup-email"
|
|
85
|
+
>
|
|
76
86
|
<Input
|
|
77
87
|
id="signup-email"
|
|
78
88
|
type="email"
|
|
@@ -88,7 +98,7 @@ const SignupDemo = () => {
|
|
|
88
98
|
<Field
|
|
89
99
|
label="Display name"
|
|
90
100
|
hint="At least 3 characters"
|
|
91
|
-
error={shownErrors
|
|
101
|
+
error={errorAt(shownErrors, ['displayName'])}
|
|
92
102
|
htmlFor="signup-display-name"
|
|
93
103
|
>
|
|
94
104
|
<Input
|
|
@@ -101,7 +111,7 @@ const SignupDemo = () => {
|
|
|
101
111
|
/>
|
|
102
112
|
</Field>
|
|
103
113
|
|
|
104
|
-
<Field label="Role" error={shownErrors
|
|
114
|
+
<Field label="Role" error={errorAt(shownErrors, ['role'])}>
|
|
105
115
|
<SingleSelect
|
|
106
116
|
options={ROLE_OPTIONS}
|
|
107
117
|
value={values.role}
|
|
@@ -126,7 +136,7 @@ const SignupDemo = () => {
|
|
|
126
136
|
<Button type="submit" variant="primary">
|
|
127
137
|
Sign up
|
|
128
138
|
</Button>
|
|
129
|
-
{submitAttempted &&
|
|
139
|
+
{submitAttempted && errors.length > 0 && (
|
|
130
140
|
<span style={{ color: 'var(--ui-danger, #c33)', fontSize: 13 }}>
|
|
131
141
|
Fix the highlighted fields
|
|
132
142
|
</span>
|
|
@@ -209,7 +219,7 @@ const LiveValidityDemo = () => {
|
|
|
209
219
|
<Field
|
|
210
220
|
label="Nickname"
|
|
211
221
|
hint="Errors here are live — not gated on a submit attempt"
|
|
212
|
-
error={errors
|
|
222
|
+
error={errorAt(errors, ['nickname'])}
|
|
213
223
|
htmlFor="live-nickname"
|
|
214
224
|
>
|
|
215
225
|
<Input
|
|
@@ -274,7 +284,9 @@ const DebuggerDemo = () => {
|
|
|
274
284
|
},
|
|
275
285
|
});
|
|
276
286
|
|
|
277
|
-
const shownErrors = submitAttempted
|
|
287
|
+
const shownErrors: FormErrors<DebuggerDemoValues> = submitAttempted
|
|
288
|
+
? errors
|
|
289
|
+
: [];
|
|
278
290
|
|
|
279
291
|
return (
|
|
280
292
|
<form
|
|
@@ -284,7 +296,11 @@ const DebuggerDemo = () => {
|
|
|
284
296
|
submit();
|
|
285
297
|
}}
|
|
286
298
|
>
|
|
287
|
-
<Field
|
|
299
|
+
<Field
|
|
300
|
+
label="Email"
|
|
301
|
+
error={errorAt(shownErrors, ['email'])}
|
|
302
|
+
htmlFor="debug-email"
|
|
303
|
+
>
|
|
288
304
|
<Input
|
|
289
305
|
id="debug-email"
|
|
290
306
|
type="email"
|
|
@@ -297,7 +313,11 @@ const DebuggerDemo = () => {
|
|
|
297
313
|
/>
|
|
298
314
|
</Field>
|
|
299
315
|
|
|
300
|
-
<Field
|
|
316
|
+
<Field
|
|
317
|
+
label="Nickname"
|
|
318
|
+
error={errorAt(shownErrors, ['nickname'])}
|
|
319
|
+
htmlFor="debug-nickname"
|
|
320
|
+
>
|
|
301
321
|
<Input
|
|
302
322
|
id="debug-nickname"
|
|
303
323
|
value={values.nickname ?? ''}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, it, expectTypeOf } from 'vitest';
|
|
2
2
|
import { useFormState } from './useFormState';
|
|
3
|
+
import { errorAt } from './errorAt';
|
|
3
4
|
import { allOf, matches, min, minLength, notEmpty } from '../validators/validators';
|
|
5
|
+
import type { FormErrors } from './types';
|
|
4
6
|
import type { Refine, Validations } from '../validations/types';
|
|
5
7
|
import type { Path, ValueAt } from '../path/types';
|
|
6
8
|
|
|
@@ -400,6 +402,29 @@ describe('useFormState narrowing at realistic scale', () => {
|
|
|
400
402
|
>().toEqualTypeOf<boolean | undefined>();
|
|
401
403
|
});
|
|
402
404
|
|
|
405
|
+
it('errors are the structured {path, error}[] list, looked up via errorAt', () => {
|
|
406
|
+
const form = useFormState({ initialValues: {} as InsuranceQuoteForm });
|
|
407
|
+
expectTypeOf(form.errors).toEqualTypeOf<FormErrors<InsuranceQuoteForm>>();
|
|
408
|
+
|
|
409
|
+
// FormError paths are Path<T>-typed, so entries at any depth are legal
|
|
410
|
+
// values already — the recursive grammar reuses this model unchanged.
|
|
411
|
+
const errors: FormErrors<InsuranceQuoteForm> = [
|
|
412
|
+
{ path: ['email'], error: 'x' },
|
|
413
|
+
{ path: ['homeAddress', 'city'], error: 'x' },
|
|
414
|
+
{ path: ['drivers', 0, 'incidents', 1, 'date'], error: 'x' },
|
|
415
|
+
];
|
|
416
|
+
|
|
417
|
+
expectTypeOf(errorAt(errors, ['email'])).toEqualTypeOf<
|
|
418
|
+
string | undefined
|
|
419
|
+
>();
|
|
420
|
+
errorAt(errors, ['vehicles', 2, 'garagingAddress', 'postalCode']);
|
|
421
|
+
|
|
422
|
+
// @ts-expect-error 'emial' is not a field of the form
|
|
423
|
+
errorAt(errors, ['emial']);
|
|
424
|
+
// @ts-expect-error no paths exist below a scalar leaf
|
|
425
|
+
errorAt(errors, ['email', 'domain']);
|
|
426
|
+
});
|
|
427
|
+
|
|
403
428
|
it('paths through optional sections and nullable lists resolve, at scale', () => {
|
|
404
429
|
// The latent hole this pins: Path admitted these paths all along, but
|
|
405
430
|
// ValueAt resolved them to `never` because `keyof (Obj | undefined)` is
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect, mock } from 'bun:test';
|
|
2
2
|
import { act, renderHook } from '@testing-library/react';
|
|
3
3
|
import { useFormState } from './useFormState';
|
|
4
|
+
import { errorAt } from './errorAt';
|
|
4
5
|
import { allOf, matches, notEmpty } from '../validators/validators';
|
|
5
6
|
|
|
6
7
|
type SignupForm = {
|
|
@@ -39,13 +40,21 @@ describe('useFormState', () => {
|
|
|
39
40
|
constraints: { email: notEmpty('email') },
|
|
40
41
|
}),
|
|
41
42
|
);
|
|
42
|
-
|
|
43
|
+
// The full structured shape: one entry per failing field, addressed by
|
|
44
|
+
// a typed path (single-key on the flat grammar).
|
|
45
|
+
expect(result.current.errors).toEqual([
|
|
46
|
+
{ path: ['email'], error: "'email' cannot be empty" },
|
|
47
|
+
]);
|
|
48
|
+
expect(errorAt(result.current.errors, ['email'])).toBe(
|
|
49
|
+
"'email' cannot be empty",
|
|
50
|
+
);
|
|
43
51
|
expect(result.current.isValid).toBe(false);
|
|
44
52
|
|
|
45
53
|
act(() => {
|
|
46
54
|
result.current.onValueChanges((prev) => ({ ...prev, email: 'a@b.co' }));
|
|
47
55
|
});
|
|
48
|
-
expect(result.current.errors
|
|
56
|
+
expect(errorAt(result.current.errors, ['email'])).toBeUndefined();
|
|
57
|
+
expect(result.current.errors).toEqual([]);
|
|
49
58
|
expect(result.current.isValid).toBe(true);
|
|
50
59
|
});
|
|
51
60
|
|
|
@@ -56,13 +65,13 @@ describe('useFormState', () => {
|
|
|
56
65
|
constraints: { email: notEmpty('email') },
|
|
57
66
|
}),
|
|
58
67
|
);
|
|
59
|
-
expect(result.current.errors
|
|
68
|
+
expect(errorAt(result.current.errors, ['nickname'])).toBeUndefined();
|
|
60
69
|
});
|
|
61
70
|
|
|
62
71
|
test('a form without constraints is always valid', () => {
|
|
63
72
|
const { result } = renderHook(() => useFormState({ initialValues }));
|
|
64
73
|
expect(result.current.isValid).toBe(true);
|
|
65
|
-
expect(result.current.errors).toEqual(
|
|
74
|
+
expect(result.current.errors).toEqual([]);
|
|
66
75
|
});
|
|
67
76
|
|
|
68
77
|
test('submit on an invalid form marks the attempt and skips onSubmit', () => {
|
|
@@ -119,17 +128,21 @@ describe('useFormState', () => {
|
|
|
119
128
|
);
|
|
120
129
|
// Both validators would fail on undefined-adjacent input paths; the
|
|
121
130
|
// FIRST one's message surfaces.
|
|
122
|
-
expect(result.current.errors
|
|
131
|
+
expect(errorAt(result.current.errors, ['email'])).toBe(
|
|
132
|
+
"'email' cannot be empty",
|
|
133
|
+
);
|
|
123
134
|
|
|
124
135
|
act(() => {
|
|
125
136
|
result.current.onValueChanges((prev) => ({ ...prev, email: 'nope' }));
|
|
126
137
|
});
|
|
127
|
-
expect(result.current.errors
|
|
138
|
+
expect(errorAt(result.current.errors, ['email'])).toBe(
|
|
139
|
+
"'email' must be a valid email",
|
|
140
|
+
);
|
|
128
141
|
|
|
129
142
|
act(() => {
|
|
130
143
|
result.current.onValueChanges((prev) => ({ ...prev, email: 'a@b.co' }));
|
|
131
144
|
});
|
|
132
|
-
expect(result.current.errors
|
|
145
|
+
expect(errorAt(result.current.errors, ['email'])).toBeUndefined();
|
|
133
146
|
expect(result.current.isValid).toBe(true);
|
|
134
147
|
});
|
|
135
148
|
|
|
@@ -172,7 +185,9 @@ describe('useFormState', () => {
|
|
|
172
185
|
}),
|
|
173
186
|
);
|
|
174
187
|
expect(result.current.values.agreed).toBe(false);
|
|
175
|
-
expect(result.current.errors
|
|
188
|
+
expect(errorAt(result.current.errors, ['agreed'])).toBe(
|
|
189
|
+
'you must agree to the terms',
|
|
190
|
+
);
|
|
176
191
|
expect(result.current.isValid).toBe(false);
|
|
177
192
|
|
|
178
193
|
act(() => {
|
|
@@ -183,7 +198,7 @@ describe('useFormState', () => {
|
|
|
183
198
|
act(() => {
|
|
184
199
|
result.current.onValueChanges((prev) => ({ ...prev, agreed: true }));
|
|
185
200
|
});
|
|
186
|
-
expect(result.current.errors
|
|
201
|
+
expect(errorAt(result.current.errors, ['agreed'])).toBeUndefined();
|
|
187
202
|
expect(result.current.isValid).toBe(true);
|
|
188
203
|
|
|
189
204
|
act(() => {
|
|
@@ -200,7 +215,7 @@ describe('useFormState', () => {
|
|
|
200
215
|
const { result } = renderHook(() =>
|
|
201
216
|
useFormState({ initialValues, constraints }),
|
|
202
217
|
);
|
|
203
|
-
expect(result.current.errors).toEqual(
|
|
218
|
+
expect(result.current.errors).toEqual([]);
|
|
204
219
|
expect(result.current.isValid).toBe(true);
|
|
205
220
|
});
|
|
206
221
|
|
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import type { FormDebuggerComponent } from './FormDebugger';
|
|
4
|
-
import { createSnapshotStore } from './snapshotStore';
|
|
5
|
-
import type { SnapshotStore } from './snapshotStore';
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { deriveFormErrors } from './deriveErrors';
|
|
6
3
|
import type {
|
|
7
|
-
FormDebugSnapshot,
|
|
8
|
-
FormErrors,
|
|
9
4
|
FormHelpers,
|
|
10
5
|
FormValuesObject,
|
|
11
6
|
UnionPolicyCheck,
|
|
12
7
|
} from './types';
|
|
8
|
+
import { useFormDebugger } from './useFormDebugger';
|
|
9
|
+
import { useFormSubmit } from './useFormSubmit';
|
|
13
10
|
import type { Refine, Validations } from '../validations/types';
|
|
14
|
-
import { validateEntry } from '../validations/walk';
|
|
15
|
-
import type { FlatConstraintEntry } from '../validations/walk';
|
|
16
11
|
|
|
17
12
|
// `const V` freezes the inferred type of an inline `constraints` object —
|
|
18
13
|
// each validator's precise type and Refinement marker survive without any
|
|
@@ -32,6 +27,11 @@ type Args<T extends FormValuesObject, V extends Validations<T>> = {
|
|
|
32
27
|
onSubmit?: (values: Refine<T, V>) => void;
|
|
33
28
|
} & UnionPolicyCheck<T>;
|
|
34
29
|
|
|
30
|
+
// Plumbing only: each slice of form state lives in its own pure function or
|
|
31
|
+
// focused hook, and this hook just links them up and recomposes their
|
|
32
|
+
// outputs into the `FormHelpers<T>` surface. Values state is the one slice
|
|
33
|
+
// kept inline — today it is a bare `useState`, and the granular setters that
|
|
34
|
+
// would earn it a module of its own arrive with the path-based grammar.
|
|
35
35
|
export const useFormState = <
|
|
36
36
|
T extends FormValuesObject,
|
|
37
37
|
const V extends Validations<T> = Validations<T>,
|
|
@@ -41,54 +41,21 @@ export const useFormState = <
|
|
|
41
41
|
const { initialValues, constraints, onSubmit } = args;
|
|
42
42
|
|
|
43
43
|
const [values, onValueChanges] = useState<T>(initialValues);
|
|
44
|
-
const [submitAttempted, setSubmitAttempted] = useState(false);
|
|
45
44
|
|
|
46
|
-
const errors
|
|
47
|
-
|
|
48
|
-
for (const key of Object.keys(constraints) as (keyof T & string)[]) {
|
|
49
|
-
// No cast: if the grammar grows a constraint form the walk doesn't
|
|
50
|
-
// understand, this assignment is the compile error that says so.
|
|
51
|
-
const entry: FlatConstraintEntry | undefined = constraints[key];
|
|
52
|
-
if (entry == null) continue;
|
|
53
|
-
const failure = validateEntry(entry, values[key], [key]);
|
|
54
|
-
if (failure != null) errors[key] = failure.error;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
45
|
+
const errors = deriveFormErrors(values, constraints);
|
|
46
|
+
const isValid = errors.length === 0;
|
|
57
47
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const debugRef = useRef<{
|
|
65
|
-
store: SnapshotStore<FormDebugSnapshot<T>>;
|
|
66
|
-
Debugger: FormDebuggerComponent;
|
|
67
|
-
} | null>(null);
|
|
68
|
-
if (debugRef.current === null) {
|
|
69
|
-
const store = createSnapshotStore<FormDebugSnapshot<T>>({
|
|
70
|
-
values,
|
|
71
|
-
errors,
|
|
72
|
-
isValid,
|
|
73
|
-
submitAttempted,
|
|
74
|
-
});
|
|
75
|
-
debugRef.current = { store, Debugger: createFormDebugger(store) };
|
|
76
|
-
}
|
|
77
|
-
const { store, Debugger } = debugRef.current;
|
|
78
|
-
|
|
79
|
-
// Publish after every commit. With no debugger window subscribed this is a
|
|
80
|
-
// field write and an empty notify loop — effectively free.
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
store.publish({ values, errors, isValid, submitAttempted });
|
|
48
|
+
// Explicit type arguments: V rides on `onSubmit`'s parameter type, which
|
|
49
|
+
// is not an inference site (see useFormSubmit.ts).
|
|
50
|
+
const { submitAttempted, submit } = useFormSubmit<T, V>({
|
|
51
|
+
values,
|
|
52
|
+
isValid,
|
|
53
|
+
onSubmit,
|
|
83
54
|
});
|
|
84
55
|
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// Every constrained field's validator just passed at runtime — exactly
|
|
89
|
-
// the guarantee Refine<T, V> encodes.
|
|
90
|
-
onSubmit?.(values as Refine<T, V>);
|
|
91
|
-
};
|
|
56
|
+
const Debugger = useFormDebugger({
|
|
57
|
+
snapshot: { values, errors, isValid, submitAttempted },
|
|
58
|
+
});
|
|
92
59
|
|
|
93
60
|
return {
|
|
94
61
|
values,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from 'bun:test';
|
|
2
|
+
import { act, renderHook } from '@testing-library/react';
|
|
3
|
+
import { useFormSubmit } from './useFormSubmit';
|
|
4
|
+
|
|
5
|
+
// The submit-orchestration boundary in isolation: the attempt flag and the
|
|
6
|
+
// validity gate, driven by whatever `isValid` the caller derived. Full-form
|
|
7
|
+
// behavior (validity flipping as values change) stays in useFormState.test.tsx.
|
|
8
|
+
|
|
9
|
+
type Form = { email: string | undefined };
|
|
10
|
+
|
|
11
|
+
describe('useFormSubmit', () => {
|
|
12
|
+
test('starts with submitAttempted false', () => {
|
|
13
|
+
const { result } = renderHook(() =>
|
|
14
|
+
useFormSubmit<Form>({ values: { email: undefined }, isValid: true }),
|
|
15
|
+
);
|
|
16
|
+
expect(result.current.submitAttempted).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('submit on an invalid form marks the attempt and skips onSubmit', () => {
|
|
20
|
+
const onSubmit = mock(() => {});
|
|
21
|
+
const { result } = renderHook(() =>
|
|
22
|
+
useFormSubmit<Form>({ values: { email: undefined }, isValid: false, onSubmit }),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
act(() => {
|
|
26
|
+
result.current.submit();
|
|
27
|
+
});
|
|
28
|
+
expect(result.current.submitAttempted).toBe(true);
|
|
29
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('submit on a valid form calls onSubmit with the current values', () => {
|
|
33
|
+
const onSubmit = mock(() => {});
|
|
34
|
+
const { result } = renderHook(() =>
|
|
35
|
+
useFormSubmit<Form>({ values: { email: 'a@b.co' }, isValid: true, onSubmit }),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
act(() => {
|
|
39
|
+
result.current.submit();
|
|
40
|
+
});
|
|
41
|
+
expect(result.current.submitAttempted).toBe(true);
|
|
42
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(onSubmit).toHaveBeenCalledWith({ email: 'a@b.co' });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('submit without an onSubmit handler is a no-op beyond the flag', () => {
|
|
47
|
+
const { result } = renderHook(() =>
|
|
48
|
+
useFormSubmit<Form>({ values: { email: 'a@b.co' }, isValid: true }),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
act(() => {
|
|
52
|
+
result.current.submit();
|
|
53
|
+
});
|
|
54
|
+
expect(result.current.submitAttempted).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('submitAttempted stays true once validity flips', () => {
|
|
58
|
+
const onSubmit = mock(() => {});
|
|
59
|
+
const initialProps: { isValid: boolean; email: string | undefined } = {
|
|
60
|
+
isValid: false,
|
|
61
|
+
email: undefined,
|
|
62
|
+
};
|
|
63
|
+
const { result, rerender } = renderHook(
|
|
64
|
+
(props: { isValid: boolean; email: string | undefined }) =>
|
|
65
|
+
useFormSubmit<Form>({
|
|
66
|
+
values: { email: props.email },
|
|
67
|
+
isValid: props.isValid,
|
|
68
|
+
onSubmit,
|
|
69
|
+
}),
|
|
70
|
+
{ initialProps },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
act(() => {
|
|
74
|
+
result.current.submit();
|
|
75
|
+
});
|
|
76
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
77
|
+
|
|
78
|
+
rerender({ isValid: true, email: 'a@b.co' });
|
|
79
|
+
expect(result.current.submitAttempted).toBe(true);
|
|
80
|
+
|
|
81
|
+
act(() => {
|
|
82
|
+
result.current.submit();
|
|
83
|
+
});
|
|
84
|
+
expect(onSubmit).toHaveBeenCalledWith({ email: 'a@b.co' });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Refine, Validations } from '../validations/types';
|
|
3
|
+
import type { FormValuesObject } from './types';
|
|
4
|
+
|
|
5
|
+
export type UseFormSubmitArgs<
|
|
6
|
+
T extends FormValuesObject,
|
|
7
|
+
V extends Validations<T>,
|
|
8
|
+
> = {
|
|
9
|
+
values: T;
|
|
10
|
+
isValid: boolean;
|
|
11
|
+
onSubmit?: (values: Refine<T, V>) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Submit orchestration: owns the `submitAttempted` flag (UIs gate error
|
|
15
|
+
// display on it) and the validity gate in front of `onSubmit`. This is the
|
|
16
|
+
// home of the ONE sanctioned refinement cast (see the cast doctrine in
|
|
17
|
+
// src/forms/CLAUDE.md). `V` is not inferable from `onSubmit`'s parameter
|
|
18
|
+
// position, so `useFormState` applies both type arguments explicitly.
|
|
19
|
+
export const useFormSubmit = <
|
|
20
|
+
T extends FormValuesObject,
|
|
21
|
+
V extends Validations<T> = Validations<T>,
|
|
22
|
+
>(
|
|
23
|
+
args: UseFormSubmitArgs<T, V>,
|
|
24
|
+
) => {
|
|
25
|
+
const { values, isValid, onSubmit } = args;
|
|
26
|
+
|
|
27
|
+
const [submitAttempted, setSubmitAttempted] = useState(false);
|
|
28
|
+
|
|
29
|
+
const submit = () => {
|
|
30
|
+
setSubmitAttempted(true);
|
|
31
|
+
if (!isValid) return;
|
|
32
|
+
// Every constrained field's validator just passed at runtime — exactly
|
|
33
|
+
// the guarantee Refine<T, V> encodes.
|
|
34
|
+
onSubmit?.(values as Refine<T, V>);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return { submitAttempted, submit };
|
|
38
|
+
};
|
|
@@ -3,8 +3,9 @@ import type { PathStep } from '../path/types';
|
|
|
3
3
|
// The runtime walk over a constraints object, kept separate from the hook so
|
|
4
4
|
// its semantics are unit-testable without React. Phase 1 grammar is flat —
|
|
5
5
|
// every path has exactly one step — but errors already carry a `PathStep[]`
|
|
6
|
-
// address
|
|
7
|
-
//
|
|
6
|
+
// address, which the hook's structured `{path, error}[]` error model
|
|
7
|
+
// (`FormErrors<T>`) consumes directly; the recursive grammar of plan phases
|
|
8
|
+
// 2–3 just hands the walk longer paths, no second rewrite.
|
|
8
9
|
|
|
9
10
|
export type ValidationError = {
|
|
10
11
|
readonly path: readonly PathStep[];
|
|
@@ -18,8 +19,9 @@ export type ValidationError = {
|
|
|
18
19
|
type AnyFieldValidator = (val: never) => string | null;
|
|
19
20
|
|
|
20
21
|
// The walk's view of one entry in a `Validations<T>` object. This type is
|
|
21
|
-
// what lets the compiler police the walk's assumptions: the
|
|
22
|
-
// `constraints[key]` to it WITHOUT
|
|
22
|
+
// what lets the compiler police the walk's assumptions: the error derivation
|
|
23
|
+
// (`useFormState/deriveErrors.ts`) assigns `constraints[key]` to it WITHOUT
|
|
24
|
+
// a cast, so when the grammar grows a form
|
|
23
25
|
// that is neither a function nor an array of them (nested spec, list `each`),
|
|
24
26
|
// that assignment stops compiling and the walk must learn the new form —
|
|
25
27
|
// instead of a stale walk misinterpreting it at runtime.
|