@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.
@@ -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.
@@ -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("'nickname' cannot be empty"),
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' }));