@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
|
@@ -310,6 +310,12 @@ type InsuranceQuoteForm = {
|
|
|
310
310
|
referralSource: string | null;
|
|
311
311
|
notes: string | undefined;
|
|
312
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
|
+
};
|
|
313
319
|
paperlessBilling: boolean;
|
|
314
320
|
};
|
|
315
321
|
|
|
@@ -547,3 +553,433 @@ describe('useFormState narrowing at realistic scale', () => {
|
|
|
547
553
|
>().toEqualTypeOf<string | null | undefined>();
|
|
548
554
|
});
|
|
549
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
|
]);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FormValuesObject } from '../useFormState/types';
|
|
1
|
+
import type { FormValueList, FormValuesObject } from '../useFormState/types';
|
|
2
2
|
|
|
3
3
|
// Phantom marker carried by validators. The runtime value of `__excludes`
|
|
4
4
|
// is meaningless and never read — only its declared type matters. Required
|
|
@@ -23,16 +23,41 @@ export type Validator<Input, Excluded = never> =
|
|
|
23
23
|
// structurally by `Refine<>`.
|
|
24
24
|
export type FieldValidator<F> = (val: F) => string | null;
|
|
25
25
|
|
|
26
|
-
// What a constraints key may map to —
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// structural
|
|
26
|
+
// What a constraints key may map to — the full target grammar (see plan.md).
|
|
27
|
+
// The leaf forms (a single validator, or an ordered list run
|
|
28
|
+
// first-error-wins — sugar over `allOf` at the constraint site; `allOf`
|
|
29
|
+
// remains for building reusable composite validators) are legal for ANY
|
|
30
|
+
// field type, structural fields included: a bare arrow on an object field is
|
|
31
|
+
// a whole-section validator. The structural forms are directed by the
|
|
32
|
+
// FIELD's type, never guessed from the constraint's shape (see
|
|
33
|
+
// "Disambiguation" in plan.md): only an object-typed field admits a nested
|
|
34
|
+
// `Validations`, only a list-typed field admits `{ each: … }`. `F` is naked
|
|
35
|
+
// in the conditionals, so nullable sections/lists distribute — for
|
|
36
|
+
// `UsAddress | undefined` the object arm fires for the present member. Do
|
|
37
|
+
// NOT wrap `F` in `NonNullable` "to help": that breaks the distribution and
|
|
38
|
+
// blows the recursion stack (TS2589) — see the maintainer note in
|
|
39
|
+
// src/forms/CLAUDE.md.
|
|
31
40
|
export type FieldConstraint<F> =
|
|
32
41
|
| FieldValidator<F>
|
|
33
|
-
| readonly FieldValidator<F>[]
|
|
42
|
+
| readonly FieldValidator<F>[]
|
|
43
|
+
| (F extends FormValuesObject ? Validations<F> : never)
|
|
44
|
+
| (F extends FormValueList ? ListConstraint<F[number]> : never);
|
|
34
45
|
|
|
35
|
-
// The constraint for a
|
|
46
|
+
// The constraint form for a list field: a spec applied to each element. At
|
|
47
|
+
// runtime the walk validates EVERY element of a present list against the
|
|
48
|
+
// `each` spec, and each failure carries the numeric index step
|
|
49
|
+
// (`['drivers', 3, 'name']`) — the same step semantics as `read()`/`Path`,
|
|
50
|
+
// so `errorAt` and the bindings look element errors up like any other path.
|
|
51
|
+
// An absent (null/undefined) list has no elements to walk and skips — a
|
|
52
|
+
// "required list" is a leaf validator on the list field instead.
|
|
53
|
+
export type ListConstraint<Element extends FormValuesObject> = {
|
|
54
|
+
readonly each: Validations<Element>;
|
|
55
|
+
// room to grow, e.g. a `self` slot for list-level rules (min count,
|
|
56
|
+
// uniqueness) — not in scope for v1
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// The constraint for a per-field validation map — recursive through nested
|
|
60
|
+
// object specs and list `each` specs.
|
|
36
61
|
export type Validations<T extends FormValuesObject> = {
|
|
37
62
|
readonly [K in keyof T]?: FieldConstraint<T[K]>;
|
|
38
63
|
};
|
|
@@ -84,19 +109,56 @@ export type MemberExcludes<C extends readonly unknown[]> = {
|
|
|
84
109
|
// validator *array* is the opposite regime: every member runs, so the union
|
|
85
110
|
// ACROSS members is earned in a single `Exclude` — but each member's own
|
|
86
111
|
// contribution is per-member sound (`MemberExcludes`): a union-typed member
|
|
87
|
-
// claims only the intersection of its branches' excludes.
|
|
88
|
-
//
|
|
89
|
-
//
|
|
112
|
+
// claims only the intersection of its branches' excludes.
|
|
113
|
+
//
|
|
114
|
+
// Branch order: `Refinement` first (validator functions are objects — a
|
|
115
|
+
// marked validator must not fall into a structural arm), then arrays, then
|
|
116
|
+
// the structural arms. The structural arms interrogate the VALUE MODEL
|
|
117
|
+
// (`F`) before the constraint's shape, per the disambiguation doctrine: an
|
|
118
|
+
// object field that legitimately owns a key named `each` must be refined as
|
|
119
|
+
// a nested spec, so `F extends FormValueList` is asked before looking for
|
|
120
|
+
// `each`. (`F` also distributes here, which is what carries `| null` /
|
|
121
|
+
// `| undefined` on nullable sections/lists through around the refined
|
|
122
|
+
// interior.) A bare marker-less validator on a structural field lands in
|
|
123
|
+
// `RefineObject<F, C>` with `keyof C` empty, an identity map — refined type
|
|
124
|
+
// structurally unchanged, as a whole-value validator should be.
|
|
90
125
|
type RefineField<F, C> = C extends Refinement<infer Excluded>
|
|
91
126
|
? Exclude<F, Excluded>
|
|
92
127
|
: C extends readonly unknown[]
|
|
93
128
|
? Exclude<F, MemberExcludes<C>>
|
|
94
|
-
:
|
|
129
|
+
: C extends object
|
|
130
|
+
? F extends FormValueList
|
|
131
|
+
? C extends { readonly each: infer E }
|
|
132
|
+
? Array<RefineObject<F[number], E>>
|
|
133
|
+
: F
|
|
134
|
+
: F extends FormValuesObject
|
|
135
|
+
? RefineObject<F, C>
|
|
136
|
+
: F
|
|
137
|
+
: F;
|
|
95
138
|
|
|
96
|
-
|
|
97
|
-
// Singles narrow by their marker, arrays by the union of their members'
|
|
98
|
-
// sound excludes; unconstrained fields and bare (marker-less) validators
|
|
99
|
-
// pass through unchanged. Shallow by design — one mapped type, no recursion.
|
|
100
|
-
export type Refine<T extends FormValuesObject, V extends Validations<T>> = {
|
|
139
|
+
type RefineObject<T, V> = {
|
|
101
140
|
[K in keyof T]: K extends keyof V ? RefineField<T[K], V[K]> : T[K];
|
|
102
141
|
};
|
|
142
|
+
|
|
143
|
+
// Applies the constraints object to the form type, recursively: the
|
|
144
|
+
// submit-time type. Singles narrow by their marker, arrays by the union of
|
|
145
|
+
// their members' sound excludes, nested/`each` specs recurse; unconstrained
|
|
146
|
+
// fields and bare (marker-less) validators pass through unchanged.
|
|
147
|
+
//
|
|
148
|
+
// The identity gate up front is load-bearing, not an optimization (phase-2
|
|
149
|
+
// type spike, adjustment 2): with constraints omitted, the hook's default
|
|
150
|
+
// `V = Validations<T>` would send `FieldConstraint<F> | undefined` through
|
|
151
|
+
// the distributive walk at every key, and structural fields would come back
|
|
152
|
+
// as unions of structurally-identical mapped COPIES of each section —
|
|
153
|
+
// mutually assignable with `T` but not identity-equal (mangled hover types,
|
|
154
|
+
// fails strict type equality). `Validations<T> extends V` is true exactly
|
|
155
|
+
// when `V` is the default (or an empty/fully-widened literal — where no
|
|
156
|
+
// markers survive anyway), so short-circuit to `T` itself. Any concrete
|
|
157
|
+
// constraints literal is strictly narrower, so the real walk runs. Nested
|
|
158
|
+
// occurrences don't need the gate: for a concrete literal `V`, `keyof V`
|
|
159
|
+
// holds only the keys actually written, so uncovered fields take the `T[K]`
|
|
160
|
+
// arm verbatim at every level.
|
|
161
|
+
export type Refine<
|
|
162
|
+
T extends FormValuesObject,
|
|
163
|
+
V extends Validations<T>,
|
|
164
|
+
> = Validations<T> extends V ? T : RefineObject<T, V>;
|