@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.
@@ -44,7 +44,8 @@ describe('FormDebugger', () => {
44
44
 
45
45
  expect(screen.getByText('isValid')).toBeTruthy();
46
46
  expect(screen.getByText('submitAttempted')).toBeTruthy();
47
- expect(screen.getByText("'nickname' cannot be empty")).toBeTruthy();
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 { FlatConstraintEntry } from '../validations/walk';
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: FlatConstraintEntry | undefined = constraints[key];
22
+ const entry: ConstraintEntry | undefined = constraints[key];
23
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 });
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
- // 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.
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('converts arrays to index-keyed objects', () => {
15
- expect(toInspectable(['a', 'b'])).toEqual({ 0: 'a', 1: 'b' });
14
+ test('keeps arrays as arrays (JsonTable renders them natively)', () => {
15
+ expect(toInspectable(['a', 'b'])).toEqual(['a', 'b']);
16
16
  });
17
17
 
18
- test('converts arrays of objects recursively', () => {
19
- expect(toInspectable([{ name: 'ada' }, { name: 'bo' }])).toEqual({
20
- 0: { name: 'ada' },
21
- 1: { name: 'bo' },
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: { 0: 'home' } },
39
- drivers: { 0: { name: 'ada', incidents: {} } },
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 dispatches on "plain object vs leaf": arrays and Sets
3
- // fall to the leaf renderer, and an array of objects (FormValueList) would
4
- // throw as a React child. A debugger must render *any* legal form state, so:
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 Object.fromEntries(
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 (single-key paths on the flat grammar; deeper addresses
82
- // arrive with the recursive grammar). Deliberately a plain list — at form
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. The setSubmitted(vals) call below compiling is the
282
- // proof that refinement still flows end-to-end when fields are wired
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: QuoteFormValues['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 coverage field's error too.
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 both fields; committing a select option counts as its touch.
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("'nickname' cannot be empty"),
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' }));