@structuralists/scaffolding 0.11.0 → 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/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 +104 -26
- package/src/forms/plan.md +104 -23
- package/src/forms/state/useFormState/FormDebugger.test.tsx +4 -3
- 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/inspectable.test.ts +9 -9
- package/src/forms/state/useFormState/inspectable.ts +5 -7
- package/src/forms/state/useFormState/types.ts +2 -2
- package/src/forms/state/useFormState/useFormState.stories.tsx +32 -10
- package/src/forms/state/useFormState/useFormState.test-d.ts +436 -0
- package/src/forms/state/useFormState/useFormState.test.tsx +2 -2
- package/src/forms/state/validations/types.ts +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
|
@@ -44,7 +44,8 @@ describe('FormDebugger', () => {
|
|
|
44
44
|
|
|
45
45
|
expect(screen.getByText('isValid')).toBeTruthy();
|
|
46
46
|
expect(screen.getByText('submitAttempted')).toBeTruthy();
|
|
47
|
-
|
|
47
|
+
// String leaves render with surrounding quotes (Chrome-inspector style).
|
|
48
|
+
expect(screen.getByText('"\'nickname\' cannot be empty"')).toBeTruthy();
|
|
48
49
|
});
|
|
49
50
|
|
|
50
51
|
test('the open window live-updates as the form changes', () => {
|
|
@@ -57,8 +58,8 @@ describe('FormDebugger', () => {
|
|
|
57
58
|
|
|
58
59
|
// The window survived the form re-render (stable component identity —
|
|
59
60
|
// a remount would have reset it to closed) and shows the new state.
|
|
60
|
-
expect(screen.getByText('will')).toBeTruthy();
|
|
61
|
-
expect(screen.queryByText("'nickname' cannot be empty")).toBeNull();
|
|
61
|
+
expect(screen.getByText('"will"')).toBeTruthy();
|
|
62
|
+
expect(screen.queryByText('"\'nickname\' cannot be empty"')).toBeNull();
|
|
62
63
|
});
|
|
63
64
|
|
|
64
65
|
test('clicking the trigger again closes the window', () => {
|
|
@@ -74,3 +74,97 @@ describe('deriveFormErrors', () => {
|
|
|
74
74
|
expect(deriveFormErrors(emptyForm, constraints)).toEqual([]);
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
// The recursive grammar: nested object specs walk the value tree and
|
|
79
|
+
// address failures with real multi-step paths. (List `each` specs are
|
|
80
|
+
// type-level only until plan phase 3 — the walk throws on one; pinned in
|
|
81
|
+
// walk.test.ts.)
|
|
82
|
+
|
|
83
|
+
type ProfileForm = {
|
|
84
|
+
email: string | undefined;
|
|
85
|
+
homeAddress: {
|
|
86
|
+
city: string | undefined;
|
|
87
|
+
postalCode: string | undefined;
|
|
88
|
+
};
|
|
89
|
+
mailingAddress: { city: string | undefined } | undefined;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const emptyProfile: ProfileForm = {
|
|
93
|
+
email: undefined,
|
|
94
|
+
homeAddress: { city: undefined, postalCode: undefined },
|
|
95
|
+
mailingAddress: undefined,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
describe('deriveFormErrors — nested object specs', () => {
|
|
99
|
+
test('a failing nested leaf contributes an entry addressed by its full path', () => {
|
|
100
|
+
const errors = deriveFormErrors(emptyProfile, {
|
|
101
|
+
homeAddress: { city: notEmpty('city') },
|
|
102
|
+
});
|
|
103
|
+
expect(errors).toEqual([
|
|
104
|
+
{ path: ['homeAddress', 'city'], error: "'city' cannot be empty" },
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('sibling fields fail independently — one entry per failing node', () => {
|
|
109
|
+
const errors = deriveFormErrors(emptyProfile, {
|
|
110
|
+
email: notEmpty('email'),
|
|
111
|
+
homeAddress: {
|
|
112
|
+
city: notEmpty('city'),
|
|
113
|
+
postalCode: notEmpty('postalCode'),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
expect(errors).toEqual([
|
|
117
|
+
{ path: ['email'], error: "'email' cannot be empty" },
|
|
118
|
+
{ path: ['homeAddress', 'city'], error: "'city' cannot be empty" },
|
|
119
|
+
{
|
|
120
|
+
path: ['homeAddress', 'postalCode'],
|
|
121
|
+
error: "'postalCode' cannot be empty",
|
|
122
|
+
},
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('passing nested leaves contribute nothing', () => {
|
|
127
|
+
const errors = deriveFormErrors(
|
|
128
|
+
{
|
|
129
|
+
...emptyProfile,
|
|
130
|
+
homeAddress: { city: 'SF', postalCode: '94110' },
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
homeAddress: {
|
|
134
|
+
city: notEmpty('city'),
|
|
135
|
+
postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
expect(errors).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a nested spec on an absent section is skipped', () => {
|
|
143
|
+
// Decided with the phase-2 type spike: the honest runtime mirror of the
|
|
144
|
+
// type level (which refines only the present branch of a nullable
|
|
145
|
+
// section) is to validate nothing when the section is absent.
|
|
146
|
+
const errors = deriveFormErrors(emptyProfile, {
|
|
147
|
+
mailingAddress: { city: notEmpty('city') },
|
|
148
|
+
});
|
|
149
|
+
expect(errors).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('the same nested spec fires once the section is present', () => {
|
|
153
|
+
const errors = deriveFormErrors(
|
|
154
|
+
{ ...emptyProfile, mailingAddress: { city: undefined } },
|
|
155
|
+
{ mailingAddress: { city: notEmpty('city') } },
|
|
156
|
+
);
|
|
157
|
+
expect(errors).toEqual([
|
|
158
|
+
{ path: ['mailingAddress', 'city'], error: "'city' cannot be empty" },
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('a required section is a leaf validator on the section field', () => {
|
|
163
|
+
const errors = deriveFormErrors(emptyProfile, {
|
|
164
|
+
mailingAddress: notEmpty('mailingAddress'),
|
|
165
|
+
});
|
|
166
|
+
expect(errors).toEqual([
|
|
167
|
+
{ path: ['mailingAddress'], error: "'mailingAddress' cannot be empty" },
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Path } from '../path/types';
|
|
2
2
|
import type { Validations } from '../validations/types';
|
|
3
3
|
import { validateEntry } from '../validations/walk';
|
|
4
|
-
import type {
|
|
4
|
+
import type { ConstraintEntry } from '../validations/walk';
|
|
5
5
|
import type { FormError, FormValuesObject } from './types';
|
|
6
6
|
|
|
7
7
|
// The pure half of the hook's error model: current values + constraints in,
|
|
@@ -19,16 +19,16 @@ export const deriveFormErrors = <T extends FormValuesObject>(
|
|
|
19
19
|
for (const key of Object.keys(constraints) as (keyof T & string)[]) {
|
|
20
20
|
// No cast: if the grammar grows a constraint form the walk doesn't
|
|
21
21
|
// understand, this assignment is the compile error that says so.
|
|
22
|
-
const entry:
|
|
22
|
+
const entry: ConstraintEntry | undefined = constraints[key];
|
|
23
23
|
if (entry == null) continue;
|
|
24
|
-
const failure
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
for (const failure of validateEntry(entry, values[key], [key])) {
|
|
25
|
+
// The walk extends the address it was handed only along keys of specs
|
|
26
|
+
// type-checked against T's subtree, so every returned path is a valid
|
|
27
|
+
// Path<T>. TS can't compute Path<T> for an unresolved generic T to
|
|
28
|
+
// see the correlation, so it needs the same honest widening as the
|
|
29
|
+
// keys cast above.
|
|
30
|
+
errors.push({ path: failure.path as Path<T>, error: failure.error });
|
|
31
|
+
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
return errors;
|
|
@@ -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 };
|
|
@@ -11,15 +11,15 @@ describe('toInspectable', () => {
|
|
|
11
11
|
expect(toInspectable(true)).toBe(true);
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
test('
|
|
15
|
-
expect(toInspectable(['a', 'b'])).toEqual(
|
|
14
|
+
test('keeps arrays as arrays (JsonTable renders them natively)', () => {
|
|
15
|
+
expect(toInspectable(['a', 'b'])).toEqual(['a', 'b']);
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
test('
|
|
19
|
-
expect(toInspectable([{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
test('walks array elements recursively (Sets may hide inside)', () => {
|
|
19
|
+
expect(toInspectable([{ roles: new Set(['admin']) }, { name: 'bo' }])).toEqual([
|
|
20
|
+
{ roles: 'Set(1) { "admin" }' },
|
|
21
|
+
{ name: 'bo' },
|
|
22
|
+
]);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
test('renders Sets as a descriptive string leaf', () => {
|
|
@@ -35,8 +35,8 @@ describe('toInspectable', () => {
|
|
|
35
35
|
};
|
|
36
36
|
expect(toInspectable(form)).toEqual({
|
|
37
37
|
email: 'a@b.co',
|
|
38
|
-
address: { city: undefined, tags:
|
|
39
|
-
drivers: {
|
|
38
|
+
address: { city: undefined, tags: ['home'] },
|
|
39
|
+
drivers: [{ name: 'ada', incidents: [] }],
|
|
40
40
|
});
|
|
41
41
|
});
|
|
42
42
|
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Converts a form-state snapshot into a shape JsonTable renders without
|
|
2
|
-
// throwing. JsonTable
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// throwing. JsonTable recurses into plain objects and arrays; everything
|
|
3
|
+
// else falls to the leaf renderer, and a Set there would throw as a React
|
|
4
|
+
// child. A debugger must render *any* legal form state, so:
|
|
5
5
|
//
|
|
6
|
-
// - arrays → index-keyed plain objects ({ 0: ..., 1: ... }), recursively
|
|
7
6
|
// - Sets → a descriptive string leaf: `Set(2) { "a", "b" }`
|
|
7
|
+
// - arrays → walked recursively (a Set may hide inside), kept as arrays
|
|
8
8
|
// - objects → walked recursively
|
|
9
9
|
// - leaves → passed through untouched
|
|
10
10
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -20,9 +20,7 @@ export const toInspectable = (value: unknown): unknown => {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
if (Array.isArray(value)) {
|
|
23
|
-
return
|
|
24
|
-
value.map((element, index) => [index, toInspectable(element)]),
|
|
25
|
-
);
|
|
23
|
+
return value.map(toInspectable);
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
if (isPlainObject(value)) {
|
|
@@ -78,8 +78,8 @@ export type UnionPolicyCheck<T> = HasDisallowedUnion<T> extends true
|
|
|
78
78
|
: unknown;
|
|
79
79
|
|
|
80
80
|
// Structured error model: one entry per failing constrained node, addressed
|
|
81
|
-
// by a typed path
|
|
82
|
-
//
|
|
81
|
+
// by a typed path as deep as the node (root leaves get single-key paths,
|
|
82
|
+
// nested-spec leaves get the full address). Deliberately a plain list — at form
|
|
83
83
|
// scale a linear scan is fine, and serialized string keys ('drivers.0.name')
|
|
84
84
|
// are never exposed. Read a field's error with `errorAt` (./errorAt.ts),
|
|
85
85
|
// never by hand-assembled keys.
|
|
@@ -278,13 +278,14 @@ type QuoteFormValues = {
|
|
|
278
278
|
};
|
|
279
279
|
|
|
280
280
|
// What onSubmit receives: notEmpty strips undefined from email and null
|
|
281
|
-
// from coverageType
|
|
282
|
-
//
|
|
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
|
|
283
284
|
// through bindings.
|
|
284
285
|
type SubmittedQuote = {
|
|
285
286
|
email: string;
|
|
286
287
|
coverageType: CoverageType;
|
|
287
|
-
homeAddress:
|
|
288
|
+
homeAddress: { city: string; postalCode: string };
|
|
288
289
|
};
|
|
289
290
|
|
|
290
291
|
const COVERAGE_OPTIONS: SelectOption<CoverageType>[] = [
|
|
@@ -309,6 +310,13 @@ const FieldBindingDemo = () => {
|
|
|
309
310
|
constraints: {
|
|
310
311
|
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
311
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
|
+
},
|
|
312
320
|
},
|
|
313
321
|
onSubmit: (vals) => setSubmitted(vals),
|
|
314
322
|
});
|
|
@@ -401,13 +409,24 @@ export const FieldBinding: Story = {
|
|
|
401
409
|
await userEvent.type(canvas.getByLabelText(/^City/), 'Lyon');
|
|
402
410
|
await expect(canvas.getByLabelText(/^City/)).toHaveValue('Lyon');
|
|
403
411
|
|
|
404
|
-
// A submit attempt unlocks the untouched
|
|
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.
|
|
405
415
|
await userEvent.click(canvas.getByRole('button', { name: 'Get quote' }));
|
|
406
416
|
await expect(
|
|
407
417
|
canvas.getByText("'coverageType' cannot be empty"),
|
|
408
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();
|
|
409
424
|
|
|
410
|
-
// Fix
|
|
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();
|
|
411
430
|
await userEvent.type(canvas.getByLabelText(/^Email/), 'will@example.com');
|
|
412
431
|
await userEvent.click(canvas.getByRole('button', { name: 'Coverage' }));
|
|
413
432
|
// The option list is portaled — query the document, not the canvas.
|
|
@@ -550,24 +569,27 @@ export const WithDebugger: Story = {
|
|
|
550
569
|
|
|
551
570
|
// Open: live state, including errors the form itself isn't showing yet
|
|
552
571
|
// (its display is submit-gated; the debugger sees the raw truth).
|
|
572
|
+
// String leaves render with surrounding quotes (Chrome-inspector style).
|
|
553
573
|
await userEvent.click(body.getByRole('button', { name: 'signup form' }));
|
|
554
574
|
await expect(body.getByText('isValid')).toBeInTheDocument();
|
|
555
|
-
await expect(body.getByText("'email' cannot be empty")).toBeInTheDocument();
|
|
556
575
|
await expect(
|
|
557
|
-
body.getByText("'
|
|
576
|
+
body.getByText('"\'email\' cannot be empty"'),
|
|
577
|
+
).toBeInTheDocument();
|
|
578
|
+
await expect(
|
|
579
|
+
body.getByText('"\'nickname\' cannot be empty"'),
|
|
558
580
|
).toBeInTheDocument();
|
|
559
581
|
|
|
560
582
|
// Live update while open: value appears, its error entry drops out.
|
|
561
583
|
await userEvent.type(canvas.getByLabelText(/^Nickname/), 'will');
|
|
562
|
-
await expect(body.getByText('will')).toBeInTheDocument();
|
|
584
|
+
await expect(body.getByText('"will"')).toBeInTheDocument();
|
|
563
585
|
await expect(
|
|
564
|
-
body.queryByText("'nickname' cannot be empty"),
|
|
586
|
+
body.queryByText('"\'nickname\' cannot be empty"'),
|
|
565
587
|
).not.toBeInTheDocument();
|
|
566
588
|
|
|
567
589
|
// List values render in the window (index-keyed).
|
|
568
590
|
await userEvent.type(canvas.getByLabelText(/^Tags/), 'typescript');
|
|
569
591
|
await userEvent.click(canvas.getByRole('button', { name: 'Add' }));
|
|
570
|
-
await expect(body.getByText('typescript')).toBeInTheDocument();
|
|
592
|
+
await expect(body.getByText('"typescript"')).toBeInTheDocument();
|
|
571
593
|
|
|
572
594
|
// Close: window unmounts, trigger stays.
|
|
573
595
|
await userEvent.click(body.getByRole('button', { name: 'signup form' }));
|