@structuralists/scaffolding 0.10.2 → 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/eslint.config.mjs +3 -3
- 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 +195 -41
- package/src/forms/elements/Input/index.tsx +2 -0
- package/src/forms/elements/Input/types.ts +2 -1
- package/src/forms/plan.md +146 -29
- package/src/forms/state/bindings/SingleSelectForForm.tsx +45 -0
- package/src/forms/state/bindings/TextInputForForm.tsx +45 -0
- package/src/forms/state/path/path.test.ts +71 -1
- package/src/forms/state/path/path.ts +50 -0
- package/src/forms/state/useFormState/FormDebugger.test.tsx +4 -3
- package/src/forms/state/useFormState/FormDebugger.tsx +1 -0
- 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/errorAt.ts +8 -12
- 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 +35 -4
- package/src/forms/state/useFormState/useFieldBinding.test.tsx +165 -0
- package/src/forms/state/useFormState/useFieldBinding.ts +71 -0
- package/src/forms/state/useFormState/useFormDebugger.test.tsx +1 -0
- package/src/forms/state/useFormState/useFormState.stories.tsx +190 -5
- package/src/forms/state/useFormState/useFormState.test-d.ts +516 -1
- package/src/forms/state/useFormState/useFormState.test.tsx +2 -2
- package/src/forms/state/useFormState/useFormState.ts +12 -3
- 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
|
@@ -2,9 +2,11 @@ import { describe, it, expectTypeOf } from 'vitest';
|
|
|
2
2
|
import { useFormState } from './useFormState';
|
|
3
3
|
import { errorAt } from './errorAt';
|
|
4
4
|
import { allOf, matches, min, minLength, notEmpty } from '../validators/validators';
|
|
5
|
-
import type { FormErrors } from './types';
|
|
5
|
+
import type { FormErrors, FormFieldProps } from './types';
|
|
6
6
|
import type { Refine, Validations } from '../validations/types';
|
|
7
7
|
import type { Path, ValueAt } from '../path/types';
|
|
8
|
+
import type { TextInputForFormProps } from '../bindings/TextInputForForm';
|
|
9
|
+
import type { SingleSelectForFormProps } from '../bindings/SingleSelectForForm';
|
|
8
10
|
|
|
9
11
|
// These tests exercise the headline feature end-to-end at the hook boundary:
|
|
10
12
|
// the type `onSubmit` receives must be the *refined* form type. The two
|
|
@@ -308,6 +310,12 @@ type InsuranceQuoteForm = {
|
|
|
308
310
|
referralSource: string | null;
|
|
309
311
|
notes: string | undefined;
|
|
310
312
|
agreedToTerms: boolean | undefined;
|
|
313
|
+
// Disambiguation probe: an object field owning a key literally named
|
|
314
|
+
// `each` must still be treated as a nested object spec (directed by T).
|
|
315
|
+
audit: {
|
|
316
|
+
each: string | undefined;
|
|
317
|
+
reviewedBy: string | undefined;
|
|
318
|
+
};
|
|
311
319
|
paperlessBilling: boolean;
|
|
312
320
|
};
|
|
313
321
|
|
|
@@ -425,6 +433,83 @@ describe('useFormState narrowing at realistic scale', () => {
|
|
|
425
433
|
errorAt(errors, ['email', 'domain']);
|
|
426
434
|
});
|
|
427
435
|
|
|
436
|
+
it('getFormFieldPropsAt infers FormFieldProps<ValueAt> at deep paths, inline at the call site', () => {
|
|
437
|
+
const form = useFormState({
|
|
438
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
439
|
+
constraints: { email: notEmpty('email') },
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// Flat and deep paths: value/onChange typed by ValueAt<T, P>.
|
|
443
|
+
expectTypeOf(form.getFormFieldPropsAt(['email'])).toEqualTypeOf<
|
|
444
|
+
FormFieldProps<string | undefined>
|
|
445
|
+
>();
|
|
446
|
+
expectTypeOf(
|
|
447
|
+
form.getFormFieldPropsAt(['homeAddress', 'city']),
|
|
448
|
+
).toEqualTypeOf<FormFieldProps<string | undefined>>();
|
|
449
|
+
expectTypeOf(
|
|
450
|
+
form.getFormFieldPropsAt(['drivers', 0, 'incidents', 1, 'claimAmountUsd']),
|
|
451
|
+
).toEqualTypeOf<FormFieldProps<number | null>>();
|
|
452
|
+
expectTypeOf(
|
|
453
|
+
form.getFormFieldPropsAt(['vehicles', 0, 'garagingAddress', 'postalCode']),
|
|
454
|
+
).toEqualTypeOf<FormFieldProps<string | undefined>>();
|
|
455
|
+
|
|
456
|
+
// Nullable-object semantics (PR #17) flow through the binding: stepping
|
|
457
|
+
// through a dead ancestor adds `| undefined`, stopping AT a nullable
|
|
458
|
+
// field keeps its exact type.
|
|
459
|
+
expectTypeOf(
|
|
460
|
+
form.getFormFieldPropsAt(['coApplicant', 'sharesResidence']),
|
|
461
|
+
).toEqualTypeOf<FormFieldProps<boolean | undefined>>();
|
|
462
|
+
expectTypeOf(
|
|
463
|
+
form.getFormFieldPropsAt(['pastPolicies', 0, 'activeUntil']),
|
|
464
|
+
).toEqualTypeOf<FormFieldProps<string | null | undefined>>();
|
|
465
|
+
expectTypeOf(form.getFormFieldPropsAt(['employer'])).toEqualTypeOf<
|
|
466
|
+
FormFieldProps<string | null>
|
|
467
|
+
>();
|
|
468
|
+
|
|
469
|
+
// Only Path<T> is admitted.
|
|
470
|
+
// @ts-expect-error 'emial' is not a field of the form
|
|
471
|
+
form.getFormFieldPropsAt(['emial']);
|
|
472
|
+
// @ts-expect-error no paths exist below a scalar leaf
|
|
473
|
+
form.getFormFieldPropsAt(['email', 'domain']);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('element wrappers accept only shape-compatible bindings', () => {
|
|
477
|
+
const form = useFormState({ initialValues: {} as InsuranceQuoteForm });
|
|
478
|
+
|
|
479
|
+
type TextBinding = TextInputForFormProps['formFieldProps'];
|
|
480
|
+
|
|
481
|
+
// Text-shaped fields bind: V must sit between the wrapper's emit type
|
|
482
|
+
// (string) and display type (string | null | undefined).
|
|
483
|
+
expectTypeOf(
|
|
484
|
+
form.getFormFieldPropsAt(['homeAddress', 'city']),
|
|
485
|
+
).toMatchTypeOf<TextBinding>();
|
|
486
|
+
expectTypeOf(form.getFormFieldPropsAt(['employer'])).toMatchTypeOf<TextBinding>();
|
|
487
|
+
expectTypeOf(
|
|
488
|
+
form.getFormFieldPropsAt(['pastPolicies', 0, 'activeUntil']),
|
|
489
|
+
).toMatchTypeOf<TextBinding>();
|
|
490
|
+
|
|
491
|
+
// Wrong-shaped bindings fail at the formFieldProps prop.
|
|
492
|
+
// @ts-expect-error a number-typed path cannot bind to a text element
|
|
493
|
+
const numberIntoText: TextBinding = form.getFormFieldPropsAt(['deductibleUsd']);
|
|
494
|
+
// @ts-expect-error a boolean-typed path cannot bind to a text element
|
|
495
|
+
const booleanIntoText: TextBinding = form.getFormFieldPropsAt(['agreedToTerms']);
|
|
496
|
+
|
|
497
|
+
// The select wrapper lines its options' literal union up with the field:
|
|
498
|
+
// emitting a wider type than the field holds is rejected.
|
|
499
|
+
type CoverageBinding = SingleSelectForFormProps<
|
|
500
|
+
'liability' | 'comprehensive'
|
|
501
|
+
>['formFieldProps'];
|
|
502
|
+
expectTypeOf<
|
|
503
|
+
FormFieldProps<'liability' | 'comprehensive' | undefined>
|
|
504
|
+
>().toMatchTypeOf<CoverageBinding>();
|
|
505
|
+
// @ts-expect-error a plain-string field would accept values outside the options
|
|
506
|
+
const stringIntoSelect: CoverageBinding = form.getFormFieldPropsAt(['coverageType']);
|
|
507
|
+
|
|
508
|
+
void numberIntoText;
|
|
509
|
+
void booleanIntoText;
|
|
510
|
+
void stringIntoSelect;
|
|
511
|
+
});
|
|
512
|
+
|
|
428
513
|
it('paths through optional sections and nullable lists resolve, at scale', () => {
|
|
429
514
|
// The latent hole this pins: Path admitted these paths all along, but
|
|
430
515
|
// ValueAt resolved them to `never` because `keyof (Obj | undefined)` is
|
|
@@ -468,3 +553,433 @@ describe('useFormState narrowing at realistic scale', () => {
|
|
|
468
553
|
>().toEqualTypeOf<string | null | undefined>();
|
|
469
554
|
});
|
|
470
555
|
});
|
|
556
|
+
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
// The recursive grammar (plan phase 2, grammar proven whole by the phase-2
|
|
559
|
+
// type spike): nested object specs, list `each` specs, validator arrays at
|
|
560
|
+
// every level, and the recursive `Refine` — exercised inline at the hook
|
|
561
|
+
// boundary on the chunky form. Ported from the spike's probe suite; the
|
|
562
|
+
// spike's two binding adjustments (value-model-first branch order in
|
|
563
|
+
// `RefineField`, the `Refine` identity gate) are what these probes pin.
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
describe('useFormState — full recursive grammar at InsuranceQuoteForm scale, inline', () => {
|
|
567
|
+
it('composes leaf/array/nested/each constraints and refines at every depth', () => {
|
|
568
|
+
useFormState({
|
|
569
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
570
|
+
constraints: {
|
|
571
|
+
// -- root leaves, same mix as the flat probe above
|
|
572
|
+
firstName: notEmpty('firstName'),
|
|
573
|
+
lastName: [notEmpty('lastName')],
|
|
574
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
575
|
+
phone: matches('phone', /^[\d\s()+-]+$/, 'a phone number'),
|
|
576
|
+
dateOfBirth: allOf(
|
|
577
|
+
notEmpty('dateOfBirth'),
|
|
578
|
+
matches('dateOfBirth', /^\d{4}-\d{2}-\d{2}$/, 'an ISO date'),
|
|
579
|
+
),
|
|
580
|
+
yearsEmployed: min('yearsEmployed', 0),
|
|
581
|
+
coverageType: [
|
|
582
|
+
notEmpty('coverageType'),
|
|
583
|
+
(val) => {
|
|
584
|
+
expectTypeOf(val).toEqualTypeOf<string | undefined>();
|
|
585
|
+
return null;
|
|
586
|
+
},
|
|
587
|
+
],
|
|
588
|
+
agreedToTerms: notEmpty('agreedToTerms'),
|
|
589
|
+
// -- nested object spec (depth 2), validators + arrays + bare arrow
|
|
590
|
+
homeAddress: {
|
|
591
|
+
city: notEmpty('city'),
|
|
592
|
+
postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
|
|
593
|
+
state: (val) => {
|
|
594
|
+
expectTypeOf(val).toEqualTypeOf<string | undefined>();
|
|
595
|
+
return null;
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
// -- nested object spec on a NULLABLE section
|
|
599
|
+
mailingAddress: {
|
|
600
|
+
city: notEmpty('mailing city'),
|
|
601
|
+
},
|
|
602
|
+
coApplicant: {
|
|
603
|
+
firstName: notEmpty('coApplicant firstName'),
|
|
604
|
+
},
|
|
605
|
+
// -- list `each` spec with a nested list `each` inside (the full
|
|
606
|
+
// composition case from the plan), plus arrays and bare arrows at
|
|
607
|
+
// both list depths
|
|
608
|
+
drivers: {
|
|
609
|
+
each: {
|
|
610
|
+
name: notEmpty('name'),
|
|
611
|
+
licenseNumber: [notEmpty('licenseNumber'), minLength('licenseNumber', 5)],
|
|
612
|
+
licenseState: (val) => {
|
|
613
|
+
expectTypeOf(val).toEqualTypeOf<string | undefined>();
|
|
614
|
+
return null;
|
|
615
|
+
},
|
|
616
|
+
incidents: {
|
|
617
|
+
each: {
|
|
618
|
+
date: notEmpty('date'),
|
|
619
|
+
kind: (val) => {
|
|
620
|
+
expectTypeOf(val).toEqualTypeOf<string | undefined>();
|
|
621
|
+
return null;
|
|
622
|
+
},
|
|
623
|
+
claimAmountUsd: min('claimAmountUsd', 0),
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
// -- object-in-list: each spec whose element has a nested object spec
|
|
629
|
+
vehicles: {
|
|
630
|
+
each: {
|
|
631
|
+
vin: [notEmpty('vin')],
|
|
632
|
+
year: min('year', 1900),
|
|
633
|
+
garagingAddress: {
|
|
634
|
+
line1: notEmpty('garaging line1'),
|
|
635
|
+
postalCode: [notEmpty('garaging postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
// -- each spec on a NULLABLE list
|
|
640
|
+
pastPolicies: {
|
|
641
|
+
each: {
|
|
642
|
+
insurer: notEmpty('insurer'),
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
// -- whole-list leaf validator on a list field (leaf forms stay legal
|
|
646
|
+
// for structural fields)
|
|
647
|
+
discountCodes: (val) => {
|
|
648
|
+
expectTypeOf(val).toEqualTypeOf<string[]>();
|
|
649
|
+
return null;
|
|
650
|
+
},
|
|
651
|
+
// -- object field owning a key named `each`: interpreted as a nested
|
|
652
|
+
// object spec because T directs, not the constraint's shape
|
|
653
|
+
audit: {
|
|
654
|
+
each: notEmpty('audit each'),
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
onSubmit: (values) => {
|
|
658
|
+
// Root leaves refine exactly as on the flat grammar.
|
|
659
|
+
expectTypeOf(values.firstName).toEqualTypeOf<string>();
|
|
660
|
+
expectTypeOf(values.lastName).toEqualTypeOf<string>();
|
|
661
|
+
expectTypeOf(values.email).toEqualTypeOf<string>();
|
|
662
|
+
expectTypeOf(values.dateOfBirth).toEqualTypeOf<string>();
|
|
663
|
+
expectTypeOf(values.coverageType).toEqualTypeOf<string>();
|
|
664
|
+
expectTypeOf(values.agreedToTerms).toEqualTypeOf<boolean>();
|
|
665
|
+
expectTypeOf(values.phone).toEqualTypeOf<string | undefined>();
|
|
666
|
+
expectTypeOf(values.yearsEmployed).toEqualTypeOf<number | null>();
|
|
667
|
+
|
|
668
|
+
// Nested object spec: constrained leaves refine, siblings pass through.
|
|
669
|
+
expectTypeOf(values.homeAddress.city).toEqualTypeOf<string>();
|
|
670
|
+
expectTypeOf(values.homeAddress.postalCode).toEqualTypeOf<string>();
|
|
671
|
+
expectTypeOf(values.homeAddress.state).toEqualTypeOf<string | undefined>();
|
|
672
|
+
expectTypeOf(values.homeAddress.line1).toEqualTypeOf<string | undefined>();
|
|
673
|
+
|
|
674
|
+
// Nullable section: the section stays optional, its constrained leaf
|
|
675
|
+
// refines inside the present branch.
|
|
676
|
+
expectTypeOf(values.mailingAddress?.city).toEqualTypeOf<string | undefined>();
|
|
677
|
+
expectTypeOf(values.mailingAddress?.line1).toEqualTypeOf<string | undefined>();
|
|
678
|
+
expectTypeOf(values.coApplicant?.firstName).toEqualTypeOf<string | undefined>();
|
|
679
|
+
expectTypeOf(values.coApplicant?.sharesResidence).toEqualTypeOf<boolean | undefined>();
|
|
680
|
+
|
|
681
|
+
// each: refined element type flows through Array<...>.
|
|
682
|
+
expectTypeOf(values.drivers[0].name).toEqualTypeOf<string>();
|
|
683
|
+
expectTypeOf(values.drivers[0].licenseNumber).toEqualTypeOf<string>();
|
|
684
|
+
expectTypeOf(values.drivers[0].licenseState).toEqualTypeOf<string | undefined>();
|
|
685
|
+
|
|
686
|
+
// list-in-list: refinement reaches depth 4.
|
|
687
|
+
expectTypeOf(values.drivers[0].incidents[0].date).toEqualTypeOf<string>();
|
|
688
|
+
expectTypeOf(values.drivers[0].incidents[0].kind).toEqualTypeOf<string | undefined>();
|
|
689
|
+
expectTypeOf(values.drivers[0].incidents[0].claimAmountUsd).toEqualTypeOf<number | null>();
|
|
690
|
+
|
|
691
|
+
// object-in-list: nested spec inside an each spec.
|
|
692
|
+
expectTypeOf(values.vehicles[0].vin).toEqualTypeOf<string>();
|
|
693
|
+
expectTypeOf(values.vehicles[0].garagingAddress.line1).toEqualTypeOf<string>();
|
|
694
|
+
expectTypeOf(values.vehicles[0].garagingAddress.postalCode).toEqualTypeOf<string>();
|
|
695
|
+
expectTypeOf(values.vehicles[0].garagingAddress.city).toEqualTypeOf<string | undefined>();
|
|
696
|
+
|
|
697
|
+
// Nullable list: `| null` on the list survives, elements refine.
|
|
698
|
+
expectTypeOf(values.pastPolicies?.[0].insurer).toEqualTypeOf<string | undefined>();
|
|
699
|
+
|
|
700
|
+
// Disambiguation: `audit` refined as an object spec despite the
|
|
701
|
+
// `each` key.
|
|
702
|
+
expectTypeOf(values.audit.each).toEqualTypeOf<string>();
|
|
703
|
+
expectTypeOf(values.audit.reviewedBy).toEqualTypeOf<string | undefined>();
|
|
704
|
+
|
|
705
|
+
// Unconstrained structure passes through untouched.
|
|
706
|
+
expectTypeOf(values.paperlessBilling).toEqualTypeOf<boolean>();
|
|
707
|
+
expectTypeOf(values.discountCodes).toEqualTypeOf<string[]>();
|
|
708
|
+
expectTypeOf(values.deductibleUsd).toEqualTypeOf<number | undefined>();
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
describe('useFormState — hook-boundary regimes on the recursive grammar', () => {
|
|
715
|
+
it('without constraints, onSubmit receives the IDENTICAL form type at every depth', () => {
|
|
716
|
+
// With the default V = Validations<T>, the identity gate on Refine must
|
|
717
|
+
// short-circuit to T itself — not a union of structurally-identical
|
|
718
|
+
// mapped copies of each section (the one genuine bug the type spike
|
|
719
|
+
// found in the plan's sketch; toEqualTypeOf is the strict-identity pin).
|
|
720
|
+
useFormState({
|
|
721
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
722
|
+
onSubmit: (values) => {
|
|
723
|
+
expectTypeOf(values).toEqualTypeOf<InsuranceQuoteForm>();
|
|
724
|
+
expectTypeOf(values.homeAddress).toEqualTypeOf<UsAddress>();
|
|
725
|
+
expectTypeOf(values.drivers).toEqualTypeOf<InsuranceQuoteForm['drivers']>();
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('infers T from literal initialValues while contextually typing nested constraints', () => {
|
|
731
|
+
useFormState({
|
|
732
|
+
initialValues: {
|
|
733
|
+
name: undefined as string | undefined,
|
|
734
|
+
home: {
|
|
735
|
+
city: undefined as string | undefined,
|
|
736
|
+
zip: undefined as string | undefined,
|
|
737
|
+
},
|
|
738
|
+
pets: [] as Array<{ nickname: string | undefined }>,
|
|
739
|
+
},
|
|
740
|
+
constraints: {
|
|
741
|
+
name: notEmpty('name'),
|
|
742
|
+
home: { city: notEmpty('city') },
|
|
743
|
+
pets: { each: { nickname: notEmpty('nickname') } },
|
|
744
|
+
},
|
|
745
|
+
onSubmit: (values) => {
|
|
746
|
+
expectTypeOf(values.name).toEqualTypeOf<string>();
|
|
747
|
+
expectTypeOf(values.home.city).toEqualTypeOf<string>();
|
|
748
|
+
expectTypeOf(values.home.zip).toEqualTypeOf<string | undefined>();
|
|
749
|
+
expectTypeOf(values.pets[0].nickname).toEqualTypeOf<string>();
|
|
750
|
+
},
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it('preserves refinement markers through a pre-built `as const satisfies` object at full depth', () => {
|
|
755
|
+
const prebuilt = {
|
|
756
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
757
|
+
homeAddress: {
|
|
758
|
+
city: notEmpty('city'),
|
|
759
|
+
},
|
|
760
|
+
drivers: {
|
|
761
|
+
each: {
|
|
762
|
+
name: notEmpty('name'),
|
|
763
|
+
incidents: {
|
|
764
|
+
each: { date: notEmpty('date') },
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
},
|
|
768
|
+
} as const satisfies Validations<InsuranceQuoteForm>;
|
|
769
|
+
|
|
770
|
+
// Direct Refine application, no hook in between.
|
|
771
|
+
type Submit = Refine<InsuranceQuoteForm, typeof prebuilt>;
|
|
772
|
+
expectTypeOf<Submit['email']>().toEqualTypeOf<string>();
|
|
773
|
+
expectTypeOf<Submit['homeAddress']['city']>().toEqualTypeOf<string>();
|
|
774
|
+
expectTypeOf<Submit['homeAddress']['line1']>().toEqualTypeOf<string | undefined>();
|
|
775
|
+
expectTypeOf<Submit['drivers'][number]['name']>().toEqualTypeOf<string>();
|
|
776
|
+
expectTypeOf<Submit['drivers'][number]['incidents'][number]['date']>().toEqualTypeOf<string>();
|
|
777
|
+
expectTypeOf<Submit['drivers'][number]['licenseState']>().toEqualTypeOf<string | undefined>();
|
|
778
|
+
|
|
779
|
+
useFormState({
|
|
780
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
781
|
+
constraints: prebuilt,
|
|
782
|
+
onSubmit: (values) => {
|
|
783
|
+
expectTypeOf(values.email).toEqualTypeOf<string>();
|
|
784
|
+
expectTypeOf(values.drivers[0].incidents[0].date).toEqualTypeOf<string>();
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
declare const cond: boolean;
|
|
791
|
+
|
|
792
|
+
describe('useFormState — leaf forms on structural fields, and phase-1 soundness at depth', () => {
|
|
793
|
+
it('contextually types whole-section and whole-list leaf validators', () => {
|
|
794
|
+
useFormState({
|
|
795
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
796
|
+
constraints: {
|
|
797
|
+
// A bare arrow on an OBJECT field is a whole-value validator, not a
|
|
798
|
+
// nested spec — the leaf forms stay legal for structural fields.
|
|
799
|
+
homeAddress: (val) => {
|
|
800
|
+
expectTypeOf(val).toEqualTypeOf<UsAddress>();
|
|
801
|
+
return null;
|
|
802
|
+
},
|
|
803
|
+
mailingAddress: (val) => {
|
|
804
|
+
expectTypeOf(val).toEqualTypeOf<UsAddress | undefined>();
|
|
805
|
+
return null;
|
|
806
|
+
},
|
|
807
|
+
// Whole-list validator array on a list field.
|
|
808
|
+
drivers: [
|
|
809
|
+
(val) => {
|
|
810
|
+
expectTypeOf(val).toEqualTypeOf<InsuranceQuoteForm['drivers']>();
|
|
811
|
+
return val.length > 0 ? null : 'at least one driver';
|
|
812
|
+
},
|
|
813
|
+
],
|
|
814
|
+
},
|
|
815
|
+
onSubmit: (values) => {
|
|
816
|
+
// Whole-value leaf validators narrow nothing (no markers here).
|
|
817
|
+
expectTypeOf(values.homeAddress).toEqualTypeOf<UsAddress>();
|
|
818
|
+
expectTypeOf(values.drivers).toEqualTypeOf<InsuranceQuoteForm['drivers']>();
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it('keeps union-typed constraints per-branch sound inside nested and each specs', () => {
|
|
824
|
+
useFormState({
|
|
825
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
826
|
+
constraints: {
|
|
827
|
+
homeAddress: {
|
|
828
|
+
// Only one branch runs: distribution must yield the union of the
|
|
829
|
+
// per-branch refinements, exactly as at the root (phase 1).
|
|
830
|
+
city: cond ? notEmpty('city') : minLength('city', 2),
|
|
831
|
+
// Array with a union-typed MEMBER: the member contributes only the
|
|
832
|
+
// intersection of its branches' excludes (never here — minLength
|
|
833
|
+
// narrows nothing), so the field must NOT refine.
|
|
834
|
+
state: [cond ? notEmpty('state') : minLength('state', 2)],
|
|
835
|
+
},
|
|
836
|
+
drivers: {
|
|
837
|
+
each: {
|
|
838
|
+
name: cond ? notEmpty('name') : minLength('name', 2),
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
},
|
|
842
|
+
onSubmit: (values) => {
|
|
843
|
+
// string (notEmpty branch) | string | undefined (minLength branch)
|
|
844
|
+
expectTypeOf(values.homeAddress.city).toEqualTypeOf<string | undefined>();
|
|
845
|
+
expectTypeOf(values.homeAddress.state).toEqualTypeOf<string | undefined>();
|
|
846
|
+
expectTypeOf(values.drivers[0].name).toEqualTypeOf<string | undefined>();
|
|
847
|
+
},
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
// Negative probes at depth. Anchor gotcha (from the spike): wrong-KEY
|
|
853
|
+
// rejections are TS2353 excess-property errors anchored at the offending
|
|
854
|
+
// leaf line, but wrong-VALIDATOR-INPUT rejections are TS2322 assignability
|
|
855
|
+
// errors anchored at the OUTERMOST constraint key — their `@ts-expect-error`
|
|
856
|
+
// directives must sit on the parent/root key's line.
|
|
857
|
+
|
|
858
|
+
describe('useFormState — recursive-grammar negative probes', () => {
|
|
859
|
+
it('rejects a wrong key inside a nested object spec', () => {
|
|
860
|
+
useFormState({
|
|
861
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
862
|
+
constraints: {
|
|
863
|
+
homeAddress: {
|
|
864
|
+
// @ts-expect-error 'cityy' is not a field of UsAddress
|
|
865
|
+
cityy: notEmpty('cityy'),
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it('rejects a wrong key inside an each spec', () => {
|
|
872
|
+
useFormState({
|
|
873
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
874
|
+
constraints: {
|
|
875
|
+
drivers: {
|
|
876
|
+
each: {
|
|
877
|
+
// @ts-expect-error 'nam' is not a field of the driver element
|
|
878
|
+
nam: notEmpty('nam'),
|
|
879
|
+
},
|
|
880
|
+
},
|
|
881
|
+
},
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
it('rejects a wrong key inside a list-in-list each spec', () => {
|
|
886
|
+
useFormState({
|
|
887
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
888
|
+
constraints: {
|
|
889
|
+
drivers: {
|
|
890
|
+
each: {
|
|
891
|
+
incidents: {
|
|
892
|
+
each: {
|
|
893
|
+
// @ts-expect-error 'datee' is not a field of the incident element
|
|
894
|
+
datee: notEmpty('datee'),
|
|
895
|
+
},
|
|
896
|
+
},
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it('rejects a bare object spec (no `each`) on a list field', () => {
|
|
904
|
+
useFormState({
|
|
905
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
906
|
+
constraints: {
|
|
907
|
+
drivers: {
|
|
908
|
+
// @ts-expect-error a list field takes { each: ... }, not a field
|
|
909
|
+
// map — EPC anchors at the unknown property
|
|
910
|
+
name: notEmpty('name'),
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it('rejects an `each` spec on a plain object field', () => {
|
|
917
|
+
useFormState({
|
|
918
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
919
|
+
constraints: {
|
|
920
|
+
homeAddress: {
|
|
921
|
+
// @ts-expect-error homeAddress is an object, not a list — no `each`
|
|
922
|
+
each: { city: notEmpty('city') },
|
|
923
|
+
},
|
|
924
|
+
},
|
|
925
|
+
});
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it('rejects a nested spec on a scalar leaf', () => {
|
|
929
|
+
useFormState({
|
|
930
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
931
|
+
constraints: {
|
|
932
|
+
email: {
|
|
933
|
+
// @ts-expect-error email is a scalar; only validators/arrays apply
|
|
934
|
+
domain: notEmpty('domain'),
|
|
935
|
+
},
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it('rejects a wrong-input validator inside a nested spec', () => {
|
|
941
|
+
useFormState({
|
|
942
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
943
|
+
constraints: {
|
|
944
|
+
// @ts-expect-error min validates numbers; postalCode is a string —
|
|
945
|
+
// the TS2322 anchors at the PARENT key, elaboration names postalCode
|
|
946
|
+
homeAddress: {
|
|
947
|
+
postalCode: min('postalCode', 0),
|
|
948
|
+
},
|
|
949
|
+
},
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it('rejects a wrong-input validator in an array at list-in-list depth', () => {
|
|
954
|
+
useFormState({
|
|
955
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
956
|
+
constraints: {
|
|
957
|
+
// @ts-expect-error min validates numbers; date is a string — the
|
|
958
|
+
// TS2322 anchors at the ROOT constraint key, elaboration walks
|
|
959
|
+
// each.incidents → each.date down to the parameter mismatch
|
|
960
|
+
drivers: {
|
|
961
|
+
each: {
|
|
962
|
+
incidents: {
|
|
963
|
+
each: {
|
|
964
|
+
date: [notEmpty('date'), min('date', 0)],
|
|
965
|
+
},
|
|
966
|
+
},
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
},
|
|
970
|
+
});
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
it('rejects a bare validator where an each spec map is required', () => {
|
|
974
|
+
useFormState({
|
|
975
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
976
|
+
constraints: {
|
|
977
|
+
// @ts-expect-error `each` takes a per-field map, not a validator
|
|
978
|
+
// (rejected by the weak-type check; anchors at the parent key)
|
|
979
|
+
vehicles: {
|
|
980
|
+
each: minLength('vehicles', 1),
|
|
981
|
+
},
|
|
982
|
+
},
|
|
983
|
+
});
|
|
984
|
+
});
|
|
985
|
+
});
|
|
@@ -40,8 +40,8 @@ describe('useFormState', () => {
|
|
|
40
40
|
constraints: { email: notEmpty('email') },
|
|
41
41
|
}),
|
|
42
42
|
);
|
|
43
|
-
// The full structured shape: one entry per failing
|
|
44
|
-
// a typed path (single-key
|
|
43
|
+
// The full structured shape: one entry per failing constrained node,
|
|
44
|
+
// addressed by a typed path (single-key for a root leaf).
|
|
45
45
|
expect(result.current.errors).toEqual([
|
|
46
46
|
{ path: ['email'], error: "'email' cannot be empty" },
|
|
47
47
|
]);
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
FormValuesObject,
|
|
6
6
|
UnionPolicyCheck,
|
|
7
7
|
} from './types';
|
|
8
|
+
import { useFieldBinding } from './useFieldBinding';
|
|
8
9
|
import { useFormDebugger } from './useFormDebugger';
|
|
9
10
|
import { useFormSubmit } from './useFormSubmit';
|
|
10
11
|
import type { Refine, Validations } from '../validations/types';
|
|
@@ -30,8 +31,8 @@ type Args<T extends FormValuesObject, V extends Validations<T>> = {
|
|
|
30
31
|
// Plumbing only: each slice of form state lives in its own pure function or
|
|
31
32
|
// focused hook, and this hook just links them up and recomposes their
|
|
32
33
|
// outputs into the `FormHelpers<T>` surface. Values state is the one slice
|
|
33
|
-
// kept inline —
|
|
34
|
-
//
|
|
34
|
+
// kept inline — a bare `useState`; the granular path writes layer on top of
|
|
35
|
+
// its setter (`useFieldBinding` funnels `write()` results through it).
|
|
35
36
|
export const useFormState = <
|
|
36
37
|
T extends FormValuesObject,
|
|
37
38
|
const V extends Validations<T> = Validations<T>,
|
|
@@ -53,8 +54,15 @@ export const useFormState = <
|
|
|
53
54
|
onSubmit,
|
|
54
55
|
});
|
|
55
56
|
|
|
57
|
+
const { touched, getFormFieldPropsAt } = useFieldBinding({
|
|
58
|
+
values,
|
|
59
|
+
onValueChanges,
|
|
60
|
+
errors,
|
|
61
|
+
submitAttempted,
|
|
62
|
+
});
|
|
63
|
+
|
|
56
64
|
const Debugger = useFormDebugger({
|
|
57
|
-
snapshot: { values, errors, isValid, submitAttempted },
|
|
65
|
+
snapshot: { values, errors, isValid, submitAttempted, touched },
|
|
58
66
|
});
|
|
59
67
|
|
|
60
68
|
return {
|
|
@@ -64,6 +72,7 @@ export const useFormState = <
|
|
|
64
72
|
isValid,
|
|
65
73
|
submitAttempted,
|
|
66
74
|
submit,
|
|
75
|
+
getFormFieldPropsAt,
|
|
67
76
|
Debugger,
|
|
68
77
|
};
|
|
69
78
|
};
|