@structuralists/scaffolding 0.11.0 → 0.13.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/.storybook/preview.tsx +42 -0
- package/AGENTS.md +9 -0
- package/README.md +1 -1
- 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 +110 -26
- package/src/forms/plan.md +115 -23
- package/src/forms/state/useFormState/FormDebugger.test.tsx +4 -3
- package/src/forms/state/useFormState/deriveErrors.test.ts +129 -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/useFieldBinding.test.tsx +34 -0
- package/src/forms/state/useFormState/useFormState.stories.tsx +214 -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 +79 -17
- package/src/forms/state/validations/walk.test.ts +272 -19
- package/src/forms/state/validations/walk.ts +97 -25
- package/tokens.css +55 -0
|
@@ -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.
|
|
@@ -422,6 +441,188 @@ export const FieldBinding: Story = {
|
|
|
422
441
|
},
|
|
423
442
|
};
|
|
424
443
|
|
|
444
|
+
type FleetFormValues = {
|
|
445
|
+
fleetName: string | undefined;
|
|
446
|
+
drivers: Array<{
|
|
447
|
+
name: string | undefined;
|
|
448
|
+
licenseNumber: string | undefined;
|
|
449
|
+
}>;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// What onSubmit receives: the `each` spec refines the ELEMENT type and the
|
|
453
|
+
// refinement flows through Array<...> — every driver's constrained fields
|
|
454
|
+
// lose undefined. The setSubmitted(vals) call compiling is the proof.
|
|
455
|
+
type SubmittedFleet = {
|
|
456
|
+
fleetName: string;
|
|
457
|
+
drivers: Array<{ name: string; licenseNumber: string }>;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// Phase 3 of the recursive grammar: a `{ each: … }` spec validates EVERY
|
|
461
|
+
// list element, and failures are addressed with the numeric index step —
|
|
462
|
+
// ['drivers', 1, 'name'] — the same step semantics the bindings read. Each
|
|
463
|
+
// driver's fields are bound at their numeric paths, so element errors land
|
|
464
|
+
// on exactly the element that failed.
|
|
465
|
+
const ListValidationDemo = () => {
|
|
466
|
+
const [submitted, setSubmitted] = useState<SubmittedFleet | null>(null);
|
|
467
|
+
|
|
468
|
+
const { values, errors, getFormFieldPropsAt, submit } = useFormState({
|
|
469
|
+
initialValues: {
|
|
470
|
+
fleetName: undefined,
|
|
471
|
+
drivers: [
|
|
472
|
+
{ name: undefined, licenseNumber: undefined },
|
|
473
|
+
{ name: undefined, licenseNumber: undefined },
|
|
474
|
+
],
|
|
475
|
+
} as FleetFormValues,
|
|
476
|
+
constraints: {
|
|
477
|
+
fleetName: notEmpty('fleetName'),
|
|
478
|
+
drivers: {
|
|
479
|
+
each: {
|
|
480
|
+
name: notEmpty('name'),
|
|
481
|
+
licenseNumber: [
|
|
482
|
+
notEmpty('licenseNumber'),
|
|
483
|
+
minLength('licenseNumber', 5),
|
|
484
|
+
],
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
onSubmit: (vals) => setSubmitted(vals),
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
return (
|
|
492
|
+
<form
|
|
493
|
+
style={{ maxWidth: 420, display: 'grid', gap: 16 }}
|
|
494
|
+
onSubmit={(e) => {
|
|
495
|
+
e.preventDefault();
|
|
496
|
+
submit();
|
|
497
|
+
}}
|
|
498
|
+
>
|
|
499
|
+
<TextInputForForm
|
|
500
|
+
label="Fleet name"
|
|
501
|
+
formFieldProps={getFormFieldPropsAt(['fleetName'])}
|
|
502
|
+
/>
|
|
503
|
+
|
|
504
|
+
{values.drivers.map((_, index) => (
|
|
505
|
+
// Index keys are fine here: the list is fixed-size. Stable element
|
|
506
|
+
// keys for editable lists are plan item 8.
|
|
507
|
+
<fieldset
|
|
508
|
+
key={index}
|
|
509
|
+
style={{ display: 'grid', gap: 12, border: '1px solid var(--ui-border, #ddd)', borderRadius: 6, padding: 12 }}
|
|
510
|
+
>
|
|
511
|
+
<legend style={{ fontSize: 13, padding: '0 4px' }}>
|
|
512
|
+
Driver {index + 1}
|
|
513
|
+
</legend>
|
|
514
|
+
<TextInputForForm
|
|
515
|
+
label={`Driver ${index + 1} name`}
|
|
516
|
+
formFieldProps={getFormFieldPropsAt(['drivers', index, 'name'])}
|
|
517
|
+
/>
|
|
518
|
+
<TextInputForForm
|
|
519
|
+
label={`Driver ${index + 1} license`}
|
|
520
|
+
hint="At least 5 characters"
|
|
521
|
+
formFieldProps={getFormFieldPropsAt([
|
|
522
|
+
'drivers',
|
|
523
|
+
index,
|
|
524
|
+
'licenseNumber',
|
|
525
|
+
])}
|
|
526
|
+
/>
|
|
527
|
+
</fieldset>
|
|
528
|
+
))}
|
|
529
|
+
|
|
530
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
531
|
+
<Button type="submit" variant="primary">
|
|
532
|
+
Save fleet
|
|
533
|
+
</Button>
|
|
534
|
+
{/* errorAt reads one element's raw error at its numeric path — no
|
|
535
|
+
display gating, the live truth the bindings' errorMessage sits
|
|
536
|
+
on top of. */}
|
|
537
|
+
<span style={{ fontSize: 13 }} data-testid="raw-driver-2-name-error">
|
|
538
|
+
{"raw ['drivers', 1, 'name']: "}
|
|
539
|
+
{errorAt(errors, ['drivers', 1, 'name']) ?? '—'}
|
|
540
|
+
</span>
|
|
541
|
+
</div>
|
|
542
|
+
|
|
543
|
+
{submitted && (
|
|
544
|
+
<pre
|
|
545
|
+
style={{
|
|
546
|
+
background: 'var(--ui-surface-muted, #f4f4f4)',
|
|
547
|
+
padding: 12,
|
|
548
|
+
borderRadius: 6,
|
|
549
|
+
fontSize: 12,
|
|
550
|
+
margin: 0,
|
|
551
|
+
}}
|
|
552
|
+
>
|
|
553
|
+
{`onSubmit received (narrowed type):\n${JSON.stringify(submitted, null, 2)}`}
|
|
554
|
+
</pre>
|
|
555
|
+
)}
|
|
556
|
+
</form>
|
|
557
|
+
);
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
export const ListValidation: Story = {
|
|
561
|
+
render: () => (
|
|
562
|
+
<div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
|
|
563
|
+
<ListValidationDemo />
|
|
564
|
+
</div>
|
|
565
|
+
),
|
|
566
|
+
// Walks the per-element flow: errorAt at a numeric path is live from the
|
|
567
|
+
// start, element errors surface per element (not per list), fixing one
|
|
568
|
+
// element leaves its siblings' errors alone, and the refined element type
|
|
569
|
+
// flows through Array<...> to onSubmit.
|
|
570
|
+
play: async ({ canvasElement }) => {
|
|
571
|
+
const canvas = within(canvasElement);
|
|
572
|
+
|
|
573
|
+
// errorAt is raw truth — the numeric-path lookup sees driver 2's error
|
|
574
|
+
// before anything is touched or submitted. (Exact-text queries below
|
|
575
|
+
// don't match this line — its text includes the path prefix.)
|
|
576
|
+
await expect(
|
|
577
|
+
canvas.getByTestId('raw-driver-2-name-error'),
|
|
578
|
+
).toHaveTextContent("'name' cannot be empty");
|
|
579
|
+
// The bindings withhold everything until touched/submit-attempted.
|
|
580
|
+
await expect(
|
|
581
|
+
canvas.queryAllByText("'name' cannot be empty"),
|
|
582
|
+
).toHaveLength(0);
|
|
583
|
+
|
|
584
|
+
// Fix driver 1's name, then submit: every remaining element error
|
|
585
|
+
// appears at its own element — driver 1 name stays clean.
|
|
586
|
+
await userEvent.type(canvas.getByLabelText(/^Driver 1 name/), 'Ayrton');
|
|
587
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Save fleet' }));
|
|
588
|
+
await expect(
|
|
589
|
+
canvas.getAllByText("'name' cannot be empty"),
|
|
590
|
+
).toHaveLength(1); // driver 2's field only — driver 1's name passed
|
|
591
|
+
await expect(
|
|
592
|
+
canvas.getAllByText("'licenseNumber' cannot be empty"),
|
|
593
|
+
).toHaveLength(2); // both drivers' licenses
|
|
594
|
+
await expect(
|
|
595
|
+
canvas.getByText("'fleetName' cannot be empty"),
|
|
596
|
+
).toBeInTheDocument();
|
|
597
|
+
|
|
598
|
+
// Validator arrays keep first-error-wins semantics INSIDE an element:
|
|
599
|
+
// a short license moves that element (and only it) to the minLength
|
|
600
|
+
// message.
|
|
601
|
+
await userEvent.type(canvas.getByLabelText(/^Driver 1 license/), 'abc');
|
|
602
|
+
await expect(
|
|
603
|
+
canvas.getByText("'licenseNumber' must be at least 5 characters"),
|
|
604
|
+
).toBeInTheDocument();
|
|
605
|
+
await expect(
|
|
606
|
+
canvas.getAllByText("'licenseNumber' cannot be empty"),
|
|
607
|
+
).toHaveLength(1); // driver 2 still on notEmpty
|
|
608
|
+
|
|
609
|
+
// Fix everything; the narrowed payload reaches onSubmit.
|
|
610
|
+
await userEvent.type(canvas.getByLabelText(/^Driver 1 license/), 'de');
|
|
611
|
+
await userEvent.type(canvas.getByLabelText(/^Driver 2 name/), 'Michele');
|
|
612
|
+
await userEvent.type(
|
|
613
|
+
canvas.getByLabelText(/^Driver 2 license/),
|
|
614
|
+
'XK-4471',
|
|
615
|
+
);
|
|
616
|
+
await userEvent.type(canvas.getByLabelText(/^Fleet name/), 'Scuderia');
|
|
617
|
+
// Driver 2's name is fixed — the raw errorAt lookup clears live.
|
|
618
|
+
await expect(
|
|
619
|
+
canvas.getByTestId('raw-driver-2-name-error'),
|
|
620
|
+
).toHaveTextContent('—');
|
|
621
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Save fleet' }));
|
|
622
|
+
await expect(canvas.getByText(/onSubmit received/)).toBeInTheDocument();
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
|
|
425
626
|
type DebuggerDemoValues = {
|
|
426
627
|
email: string | undefined;
|
|
427
628
|
nickname: string | undefined;
|
|
@@ -550,24 +751,27 @@ export const WithDebugger: Story = {
|
|
|
550
751
|
|
|
551
752
|
// Open: live state, including errors the form itself isn't showing yet
|
|
552
753
|
// (its display is submit-gated; the debugger sees the raw truth).
|
|
754
|
+
// String leaves render with surrounding quotes (Chrome-inspector style).
|
|
553
755
|
await userEvent.click(body.getByRole('button', { name: 'signup form' }));
|
|
554
756
|
await expect(body.getByText('isValid')).toBeInTheDocument();
|
|
555
|
-
await expect(body.getByText("'email' cannot be empty")).toBeInTheDocument();
|
|
556
757
|
await expect(
|
|
557
|
-
body.getByText("'
|
|
758
|
+
body.getByText('"\'email\' cannot be empty"'),
|
|
759
|
+
).toBeInTheDocument();
|
|
760
|
+
await expect(
|
|
761
|
+
body.getByText('"\'nickname\' cannot be empty"'),
|
|
558
762
|
).toBeInTheDocument();
|
|
559
763
|
|
|
560
764
|
// Live update while open: value appears, its error entry drops out.
|
|
561
765
|
await userEvent.type(canvas.getByLabelText(/^Nickname/), 'will');
|
|
562
|
-
await expect(body.getByText('will')).toBeInTheDocument();
|
|
766
|
+
await expect(body.getByText('"will"')).toBeInTheDocument();
|
|
563
767
|
await expect(
|
|
564
|
-
body.queryByText("'nickname' cannot be empty"),
|
|
768
|
+
body.queryByText('"\'nickname\' cannot be empty"'),
|
|
565
769
|
).not.toBeInTheDocument();
|
|
566
770
|
|
|
567
771
|
// List values render in the window (index-keyed).
|
|
568
772
|
await userEvent.type(canvas.getByLabelText(/^Tags/), 'typescript');
|
|
569
773
|
await userEvent.click(canvas.getByRole('button', { name: 'Add' }));
|
|
570
|
-
await expect(body.getByText('typescript')).toBeInTheDocument();
|
|
774
|
+
await expect(body.getByText('"typescript"')).toBeInTheDocument();
|
|
571
775
|
|
|
572
776
|
// Close: window unmounts, trigger stays.
|
|
573
777
|
await userEvent.click(body.getByRole('button', { name: 'signup form' }));
|