@structuralists/scaffolding 0.13.0 → 0.15.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 +3 -0
- package/AGENTS.md +11 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/forms/CLAUDE.md +56 -4
- package/src/forms/plan.md +36 -7
- package/src/forms/state/useFormState/useFormState.composition.test.tsx +342 -0
- package/src/forms/state/useFormState/useFormState.stress.test-d.ts +451 -0
- package/src/forms/state/validations/perField.ts +22 -11
- package/src/forms/state/validations/types.test-d.ts +108 -0
- package/src/storybook/CLAUDE.md +9 -0
- package/src/storybook/Demo.stories.tsx +467 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from 'vitest';
|
|
2
|
+
import { useFormState } from './useFormState';
|
|
3
|
+
import { perField } from '../validations/perField';
|
|
4
|
+
import { matches, min, minLength, notEmpty } from '../validators/validators';
|
|
5
|
+
import type { FormFieldProps } from './types';
|
|
6
|
+
import type { Refine, Validations } from '../validations/types';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// The stress tier (plan phase 4, composition hardening) — the phase-2 type
|
|
10
|
+
// spike's stress fixtures, pinned against the REAL hook. Where
|
|
11
|
+
// useFormState.test-d.ts probes the grammar at chunky (~30-leaf) scale, this
|
|
12
|
+
// file pushes past it to hold the measured headroom: a ~110-leaf form at 5–6
|
|
13
|
+
// levels with the large applicant spec written inline twice, the same spec
|
|
14
|
+
// pre-built ONCE via perField and reused for both applicant sections (the
|
|
15
|
+
// pre-built entry point at realistic scale), a 12-level nested-object spec
|
|
16
|
+
// ladder, a 4-deep list-in-list `each` ladder, and deep Path bindings on the
|
|
17
|
+
// mega form. If the recursion budget regresses, tsc slows or fails here
|
|
18
|
+
// first — watch the instantiation count in the plan's recursion-budget
|
|
19
|
+
// section, not just success.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
type UsAddress = {
|
|
23
|
+
line1: string | undefined;
|
|
24
|
+
line2: string | undefined;
|
|
25
|
+
city: string | undefined;
|
|
26
|
+
state: string | undefined;
|
|
27
|
+
postalCode: string | undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type Incident = {
|
|
31
|
+
date: string | undefined;
|
|
32
|
+
kind: string | undefined;
|
|
33
|
+
claimAmountUsd: number | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type Driver = {
|
|
37
|
+
name: string | undefined;
|
|
38
|
+
licenseNumber: string | undefined;
|
|
39
|
+
licenseState: string | undefined;
|
|
40
|
+
incidents: Incident[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type Vehicle = {
|
|
44
|
+
vin: string | undefined;
|
|
45
|
+
year: number | undefined;
|
|
46
|
+
make: string | undefined;
|
|
47
|
+
model: string | undefined;
|
|
48
|
+
primaryDriverName: string | undefined;
|
|
49
|
+
garagingAddress: UsAddress;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type Applicant = {
|
|
53
|
+
firstName: string | undefined;
|
|
54
|
+
lastName: string | undefined;
|
|
55
|
+
email: string | undefined;
|
|
56
|
+
phone: string | undefined;
|
|
57
|
+
dateOfBirth: string | undefined;
|
|
58
|
+
employer: string | null;
|
|
59
|
+
jobTitle: string | null;
|
|
60
|
+
yearsEmployed: number | null;
|
|
61
|
+
annualIncomeUsd: number | null;
|
|
62
|
+
homeAddress: UsAddress;
|
|
63
|
+
mailingAddress: UsAddress | undefined;
|
|
64
|
+
drivers: Driver[];
|
|
65
|
+
vehicles: Vehicle[];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type MegaQuoteForm = {
|
|
69
|
+
primary: Applicant;
|
|
70
|
+
secondary: Applicant | undefined;
|
|
71
|
+
household: {
|
|
72
|
+
address: UsAddress;
|
|
73
|
+
members: Array<{
|
|
74
|
+
name: string | undefined;
|
|
75
|
+
relation: string | undefined;
|
|
76
|
+
dateOfBirth: string | undefined;
|
|
77
|
+
}>;
|
|
78
|
+
};
|
|
79
|
+
quotes: Array<{
|
|
80
|
+
coverageType: string | undefined;
|
|
81
|
+
deductibleUsd: number | undefined;
|
|
82
|
+
startDate: string | undefined;
|
|
83
|
+
riders: Array<{
|
|
84
|
+
code: string | undefined;
|
|
85
|
+
amountUsd: number | null;
|
|
86
|
+
}>;
|
|
87
|
+
}>;
|
|
88
|
+
referralSource: string | null;
|
|
89
|
+
notes: string | undefined;
|
|
90
|
+
agreedToTerms: boolean | undefined;
|
|
91
|
+
paperlessBilling: boolean;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
describe('useFormState stress: ~110-leaf form, large inline spec written twice', () => {
|
|
95
|
+
it('composes and refines with the applicant spec duplicated inline', () => {
|
|
96
|
+
useFormState({
|
|
97
|
+
initialValues: {} as MegaQuoteForm,
|
|
98
|
+
constraints: {
|
|
99
|
+
primary: {
|
|
100
|
+
firstName: notEmpty('firstName'),
|
|
101
|
+
lastName: [notEmpty('lastName')],
|
|
102
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
103
|
+
phone: matches('phone', /^[\d\s()+-]+$/, 'a phone number'),
|
|
104
|
+
dateOfBirth: notEmpty('dateOfBirth'),
|
|
105
|
+
yearsEmployed: min('yearsEmployed', 0),
|
|
106
|
+
annualIncomeUsd: min('annualIncomeUsd', 0),
|
|
107
|
+
homeAddress: {
|
|
108
|
+
line1: notEmpty('line1'),
|
|
109
|
+
city: notEmpty('city'),
|
|
110
|
+
postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
|
|
111
|
+
},
|
|
112
|
+
mailingAddress: {
|
|
113
|
+
city: notEmpty('mailing city'),
|
|
114
|
+
},
|
|
115
|
+
drivers: {
|
|
116
|
+
each: {
|
|
117
|
+
name: notEmpty('name'),
|
|
118
|
+
licenseNumber: [notEmpty('licenseNumber'), minLength('licenseNumber', 5)],
|
|
119
|
+
incidents: {
|
|
120
|
+
each: {
|
|
121
|
+
date: notEmpty('date'),
|
|
122
|
+
claimAmountUsd: min('claimAmountUsd', 0),
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
vehicles: {
|
|
128
|
+
each: {
|
|
129
|
+
vin: [notEmpty('vin')],
|
|
130
|
+
year: min('year', 1900),
|
|
131
|
+
garagingAddress: {
|
|
132
|
+
line1: notEmpty('garaging line1'),
|
|
133
|
+
postalCode: [notEmpty('garaging postalCode')],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
// The same large spec again, on the nullable secondary applicant —
|
|
139
|
+
// simulates a real page where big sections each get full specs.
|
|
140
|
+
secondary: {
|
|
141
|
+
firstName: notEmpty('firstName'),
|
|
142
|
+
lastName: [notEmpty('lastName')],
|
|
143
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
144
|
+
phone: matches('phone', /^[\d\s()+-]+$/, 'a phone number'),
|
|
145
|
+
dateOfBirth: notEmpty('dateOfBirth'),
|
|
146
|
+
yearsEmployed: min('yearsEmployed', 0),
|
|
147
|
+
annualIncomeUsd: min('annualIncomeUsd', 0),
|
|
148
|
+
homeAddress: {
|
|
149
|
+
line1: notEmpty('line1'),
|
|
150
|
+
city: notEmpty('city'),
|
|
151
|
+
postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
|
|
152
|
+
},
|
|
153
|
+
mailingAddress: {
|
|
154
|
+
city: notEmpty('mailing city'),
|
|
155
|
+
},
|
|
156
|
+
drivers: {
|
|
157
|
+
each: {
|
|
158
|
+
name: notEmpty('name'),
|
|
159
|
+
licenseNumber: [notEmpty('licenseNumber'), minLength('licenseNumber', 5)],
|
|
160
|
+
incidents: {
|
|
161
|
+
each: {
|
|
162
|
+
date: notEmpty('date'),
|
|
163
|
+
claimAmountUsd: min('claimAmountUsd', 0),
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
vehicles: {
|
|
169
|
+
each: {
|
|
170
|
+
vin: [notEmpty('vin')],
|
|
171
|
+
year: min('year', 1900),
|
|
172
|
+
garagingAddress: {
|
|
173
|
+
line1: notEmpty('garaging line1'),
|
|
174
|
+
postalCode: [notEmpty('garaging postalCode')],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
household: {
|
|
180
|
+
address: {
|
|
181
|
+
city: notEmpty('household city'),
|
|
182
|
+
},
|
|
183
|
+
members: {
|
|
184
|
+
each: {
|
|
185
|
+
name: notEmpty('member name'),
|
|
186
|
+
dateOfBirth: notEmpty('member dateOfBirth'),
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
quotes: {
|
|
191
|
+
each: {
|
|
192
|
+
coverageType: notEmpty('coverageType'),
|
|
193
|
+
deductibleUsd: min('deductibleUsd', 0),
|
|
194
|
+
startDate: notEmpty('startDate'),
|
|
195
|
+
riders: {
|
|
196
|
+
each: {
|
|
197
|
+
code: notEmpty('code'),
|
|
198
|
+
amountUsd: min('amountUsd', 0),
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
referralSource: notEmpty('referralSource'),
|
|
204
|
+
agreedToTerms: notEmpty('agreedToTerms'),
|
|
205
|
+
},
|
|
206
|
+
onSubmit: (values) => {
|
|
207
|
+
// Depth 5–6 refinements on both applicant sections.
|
|
208
|
+
expectTypeOf(values.primary.email).toEqualTypeOf<string>();
|
|
209
|
+
expectTypeOf(values.primary.homeAddress.postalCode).toEqualTypeOf<string>();
|
|
210
|
+
expectTypeOf(values.primary.drivers[0].incidents[0].date).toEqualTypeOf<string>();
|
|
211
|
+
expectTypeOf(values.primary.vehicles[0].garagingAddress.line1).toEqualTypeOf<string>();
|
|
212
|
+
expectTypeOf(values.primary.drivers[0].licenseState).toEqualTypeOf<string | undefined>();
|
|
213
|
+
expectTypeOf(values.secondary?.homeAddress.city).toEqualTypeOf<string | undefined>();
|
|
214
|
+
expectTypeOf(values.secondary?.drivers[0].incidents[0].date).toEqualTypeOf<
|
|
215
|
+
string | undefined
|
|
216
|
+
>();
|
|
217
|
+
expectTypeOf(values.household.members[0].name).toEqualTypeOf<string>();
|
|
218
|
+
expectTypeOf(values.quotes[0].riders[0].code).toEqualTypeOf<string>();
|
|
219
|
+
expectTypeOf(values.quotes[0].riders[0].amountUsd).toEqualTypeOf<number | null>();
|
|
220
|
+
expectTypeOf(values.agreedToTerms).toEqualTypeOf<boolean>();
|
|
221
|
+
expectTypeOf(values.paperlessBilling).toEqualTypeOf<boolean>();
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('Path machinery holds at mega scale: deep bindings on the hook result', () => {
|
|
227
|
+
const form = useFormState({ initialValues: {} as MegaQuoteForm });
|
|
228
|
+
|
|
229
|
+
expectTypeOf(
|
|
230
|
+
form.getFormFieldPropsAt(['primary', 'drivers', 0, 'incidents', 0, 'date']),
|
|
231
|
+
).toEqualTypeOf<FormFieldProps<string | undefined>>();
|
|
232
|
+
expectTypeOf(
|
|
233
|
+
form.getFormFieldPropsAt(['secondary', 'vehicles', 0, 'garagingAddress', 'postalCode']),
|
|
234
|
+
).toEqualTypeOf<FormFieldProps<string | undefined>>();
|
|
235
|
+
expectTypeOf(
|
|
236
|
+
form.getFormFieldPropsAt(['quotes', 0, 'riders', 0, 'amountUsd']),
|
|
237
|
+
).toEqualTypeOf<FormFieldProps<number | null>>();
|
|
238
|
+
expectTypeOf(
|
|
239
|
+
form.getFormFieldPropsAt(['household', 'members', 0, 'relation']),
|
|
240
|
+
).toEqualTypeOf<FormFieldProps<string | undefined>>();
|
|
241
|
+
|
|
242
|
+
// @ts-expect-error 'drvers' is not a field of the primary applicant
|
|
243
|
+
form.getFormFieldPropsAt(['primary', 'drvers', 0, 'name']);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// perField at realistic scale (plan phase 4): the pre-built entry point on
|
|
249
|
+
// the full recursive grammar — one applicant spec built once, reused for
|
|
250
|
+
// both applicant sections, mixing each-in-each, nested-in-each, and a
|
|
251
|
+
// whole-list leaf validator on a structural field. Before this phase,
|
|
252
|
+
// perField was only ever exercised on a 3-key flat form.
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
describe('useFormState stress: perField pre-built specs at mega scale', () => {
|
|
256
|
+
it('preserves markers through a perField applicant spec reused across sections', () => {
|
|
257
|
+
const applicantSpec = perField({
|
|
258
|
+
firstName: notEmpty('firstName'),
|
|
259
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
260
|
+
yearsEmployed: min('yearsEmployed', 0),
|
|
261
|
+
homeAddress: {
|
|
262
|
+
city: notEmpty('city'),
|
|
263
|
+
postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
|
|
264
|
+
},
|
|
265
|
+
mailingAddress: {
|
|
266
|
+
city: notEmpty('mailing city'),
|
|
267
|
+
},
|
|
268
|
+
drivers: {
|
|
269
|
+
each: {
|
|
270
|
+
name: notEmpty('name'),
|
|
271
|
+
licenseNumber: [notEmpty('licenseNumber'), minLength('licenseNumber', 5)],
|
|
272
|
+
incidents: {
|
|
273
|
+
each: {
|
|
274
|
+
date: notEmpty('date'),
|
|
275
|
+
claimAmountUsd: min('claimAmountUsd', 0),
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
// Whole-list leaf validator on a structural field: leaf and structural
|
|
281
|
+
// forms are mutually exclusive per key, so vehicles takes the
|
|
282
|
+
// list-as-a-value form while drivers takes `each`.
|
|
283
|
+
vehicles: [
|
|
284
|
+
(val: Vehicle[]) => (val.length > 0 ? null : 'at least one vehicle'),
|
|
285
|
+
],
|
|
286
|
+
}) satisfies Validations<Applicant>;
|
|
287
|
+
|
|
288
|
+
const constraints = {
|
|
289
|
+
primary: applicantSpec,
|
|
290
|
+
secondary: applicantSpec,
|
|
291
|
+
household: {
|
|
292
|
+
members: {
|
|
293
|
+
each: { name: notEmpty('member name') },
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
agreedToTerms: notEmpty('agreedToTerms'),
|
|
297
|
+
} as const satisfies Validations<MegaQuoteForm>;
|
|
298
|
+
|
|
299
|
+
// Direct Refine application on the composed pre-built object.
|
|
300
|
+
type Submit = Refine<MegaQuoteForm, typeof constraints>;
|
|
301
|
+
expectTypeOf<Submit['primary']['email']>().toEqualTypeOf<string>();
|
|
302
|
+
expectTypeOf<Submit['primary']['homeAddress']['postalCode']>().toEqualTypeOf<string>();
|
|
303
|
+
expectTypeOf<
|
|
304
|
+
Submit['primary']['drivers'][number]['incidents'][number]['date']
|
|
305
|
+
>().toEqualTypeOf<string>();
|
|
306
|
+
expectTypeOf<Submit['primary']['vehicles']>().toEqualTypeOf<Vehicle[]>();
|
|
307
|
+
expectTypeOf<Submit['secondary']>().toEqualTypeOf<
|
|
308
|
+
Refine<Applicant, typeof applicantSpec> | undefined
|
|
309
|
+
>();
|
|
310
|
+
expectTypeOf<Submit['household']['members'][number]['name']>().toEqualTypeOf<string>();
|
|
311
|
+
expectTypeOf<Submit['quotes']>().toEqualTypeOf<MegaQuoteForm['quotes']>();
|
|
312
|
+
|
|
313
|
+
// And through the hook boundary.
|
|
314
|
+
useFormState({
|
|
315
|
+
initialValues: {} as MegaQuoteForm,
|
|
316
|
+
constraints,
|
|
317
|
+
onSubmit: (values) => {
|
|
318
|
+
expectTypeOf(values.primary.drivers[0].incidents[0].date).toEqualTypeOf<string>();
|
|
319
|
+
expectTypeOf(values.secondary?.homeAddress.city).toEqualTypeOf<string | undefined>();
|
|
320
|
+
expectTypeOf(values.agreedToTerms).toEqualTypeOf<boolean>();
|
|
321
|
+
expectTypeOf(values.paperlessBilling).toEqualTypeOf<boolean>();
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// --- Object-depth ladder: specs 12 levels deep --------------------------------
|
|
328
|
+
|
|
329
|
+
type L12 = { leaf: string | undefined; tag: string | undefined };
|
|
330
|
+
type L11 = { next: L12; tag: string | undefined };
|
|
331
|
+
type L10 = { next: L11; tag: string | undefined };
|
|
332
|
+
type L9 = { next: L10; tag: string | undefined };
|
|
333
|
+
type L8 = { next: L9; tag: string | undefined };
|
|
334
|
+
type L7 = { next: L8; tag: string | undefined };
|
|
335
|
+
type L6 = { next: L7; tag: string | undefined };
|
|
336
|
+
type L5 = { next: L6; tag: string | undefined };
|
|
337
|
+
type L4 = { next: L5; tag: string | undefined };
|
|
338
|
+
type L3 = { next: L4; tag: string | undefined };
|
|
339
|
+
type L2 = { next: L3; tag: string | undefined };
|
|
340
|
+
type L1 = { next: L2; tag: string | undefined };
|
|
341
|
+
type DeepObjectForm = { root: L1 };
|
|
342
|
+
|
|
343
|
+
describe('useFormState stress: 12-level nested-object spec ladder', () => {
|
|
344
|
+
it('refines a leaf 13 levels down, with a validator at every level', () => {
|
|
345
|
+
useFormState({
|
|
346
|
+
initialValues: {} as DeepObjectForm,
|
|
347
|
+
constraints: {
|
|
348
|
+
root: {
|
|
349
|
+
tag: notEmpty('t1'),
|
|
350
|
+
next: {
|
|
351
|
+
tag: notEmpty('t2'),
|
|
352
|
+
next: {
|
|
353
|
+
tag: notEmpty('t3'),
|
|
354
|
+
next: {
|
|
355
|
+
tag: notEmpty('t4'),
|
|
356
|
+
next: {
|
|
357
|
+
tag: notEmpty('t5'),
|
|
358
|
+
next: {
|
|
359
|
+
tag: notEmpty('t6'),
|
|
360
|
+
next: {
|
|
361
|
+
tag: notEmpty('t7'),
|
|
362
|
+
next: {
|
|
363
|
+
tag: notEmpty('t8'),
|
|
364
|
+
next: {
|
|
365
|
+
tag: notEmpty('t9'),
|
|
366
|
+
next: {
|
|
367
|
+
tag: notEmpty('t10'),
|
|
368
|
+
next: {
|
|
369
|
+
tag: notEmpty('t11'),
|
|
370
|
+
next: {
|
|
371
|
+
tag: notEmpty('t12'),
|
|
372
|
+
leaf: [notEmpty('leaf'), minLength('leaf', 2)],
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
onSubmit: (values) => {
|
|
387
|
+
expectTypeOf(
|
|
388
|
+
values.root.next.next.next.next.next.next.next.next.next.next.next.leaf,
|
|
389
|
+
).toEqualTypeOf<string>();
|
|
390
|
+
expectTypeOf(
|
|
391
|
+
values.root.next.next.next.next.next.next.next.next.next.next.next.tag,
|
|
392
|
+
).toEqualTypeOf<string>();
|
|
393
|
+
expectTypeOf(values.root.tag).toEqualTypeOf<string>();
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// --- List-depth ladder: `each` 4 levels deep ----------------------------------
|
|
400
|
+
|
|
401
|
+
type DeepListForm = {
|
|
402
|
+
xs: Array<{
|
|
403
|
+
label: string | undefined;
|
|
404
|
+
ys: Array<{
|
|
405
|
+
label: string | undefined;
|
|
406
|
+
zs: Array<{
|
|
407
|
+
label: string | undefined;
|
|
408
|
+
ws: Array<{
|
|
409
|
+
v: string | undefined;
|
|
410
|
+
n: number | null;
|
|
411
|
+
}>;
|
|
412
|
+
}>;
|
|
413
|
+
}>;
|
|
414
|
+
}>;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
describe('useFormState stress: 4-deep list-in-list each ladder', () => {
|
|
418
|
+
it('refines through four nested each specs', () => {
|
|
419
|
+
useFormState({
|
|
420
|
+
initialValues: {} as DeepListForm,
|
|
421
|
+
constraints: {
|
|
422
|
+
xs: {
|
|
423
|
+
each: {
|
|
424
|
+
label: notEmpty('x label'),
|
|
425
|
+
ys: {
|
|
426
|
+
each: {
|
|
427
|
+
label: notEmpty('y label'),
|
|
428
|
+
zs: {
|
|
429
|
+
each: {
|
|
430
|
+
label: notEmpty('z label'),
|
|
431
|
+
ws: {
|
|
432
|
+
each: {
|
|
433
|
+
v: notEmpty('v'),
|
|
434
|
+
n: min('n', 0),
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
onSubmit: (values) => {
|
|
445
|
+
expectTypeOf(values.xs[0].ys[0].zs[0].ws[0].v).toEqualTypeOf<string>();
|
|
446
|
+
expectTypeOf(values.xs[0].ys[0].zs[0].ws[0].n).toEqualTypeOf<number | null>();
|
|
447
|
+
expectTypeOf(values.xs[0].ys[0].zs[0].label).toEqualTypeOf<string>();
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
});
|
|
@@ -1,14 +1,25 @@
|
|
|
1
|
+
// The loose, form-type-agnostic shape of one constraint: a validator, a
|
|
2
|
+
// validator array, or an object of more of the same — which covers both
|
|
3
|
+
// nested specs and `{ each: … }` (an `each` value is itself a spec object).
|
|
4
|
+
// `(val: never) => …` admits any validator input contravariantly. This bound
|
|
5
|
+
// only rejects obvious garbage (strings, numbers, null values); the real
|
|
6
|
+
// shape check — every key a field, every validator's input compatible,
|
|
7
|
+
// structural forms only where the field type permits — happens at the
|
|
8
|
+
// caller's `satisfies Validations<FormType>`, which is where the form type
|
|
9
|
+
// first enters the picture.
|
|
10
|
+
type LooseFieldConstraint =
|
|
11
|
+
| ((val: never) => string | null)
|
|
12
|
+
| readonly ((val: never) => string | null)[]
|
|
13
|
+
| { readonly [key: string]: LooseFieldConstraint };
|
|
14
|
+
|
|
1
15
|
// Aggregation entry point for building a `Validations<T>`-shaped constraints
|
|
2
|
-
// object
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
((val: never) => string | null) | readonly ((val: never) => string | null)[]
|
|
11
|
-
>,
|
|
12
|
-
>(
|
|
16
|
+
// object — the full recursive grammar (leaf validators and arrays, nested
|
|
17
|
+
// object specs, list `each` specs, freely composed). Runtime identity; the
|
|
18
|
+
// value is in the type: the `const` type parameter freezes each validator's
|
|
19
|
+
// precise type (including `Refinement` markers) instead of letting function
|
|
20
|
+
// types widen. Callers still apply `satisfies Validations<FormType>` to
|
|
21
|
+
// shape-check against their form type without losing that precision — see
|
|
22
|
+
// src/forms/CLAUDE.md.
|
|
23
|
+
export const perField = <const V extends Record<string, LooseFieldConstraint>>(
|
|
13
24
|
validations: V,
|
|
14
25
|
): V => validations;
|
|
@@ -205,6 +205,114 @@ describe('Refine<T, V> — union-typed members inside arrays', () => {
|
|
|
205
205
|
});
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// perField at the recursive grammar (plan phase 4): the pre-built-spec entry
|
|
210
|
+
// point admits the full grammar — nested object specs, list `each` specs,
|
|
211
|
+
// arrays at every level — and preserves refinement markers through all of it.
|
|
212
|
+
// perField's own bound is shape-only (validators / arrays / objects of the
|
|
213
|
+
// same); the form-type shape check happens at `satisfies Validations<T>`.
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
type NestedFormType = {
|
|
217
|
+
email: string | undefined;
|
|
218
|
+
homeAddress: {
|
|
219
|
+
city: string | undefined;
|
|
220
|
+
postalCode: string | undefined;
|
|
221
|
+
};
|
|
222
|
+
mailingAddress: { city: string | undefined } | undefined;
|
|
223
|
+
drivers: Array<{
|
|
224
|
+
name: string | undefined;
|
|
225
|
+
incidents: Array<{ date: string | undefined }>;
|
|
226
|
+
}>;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
describe('perField — recursive grammar', () => {
|
|
230
|
+
it('preserves markers through nested specs and each-in-each specs', () => {
|
|
231
|
+
const constraints = perField({
|
|
232
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
233
|
+
homeAddress: {
|
|
234
|
+
city: notEmpty('city'),
|
|
235
|
+
postalCode: [notEmpty('postalCode'), minLength('postalCode', 5)],
|
|
236
|
+
},
|
|
237
|
+
mailingAddress: {
|
|
238
|
+
city: notEmpty('mailing city'),
|
|
239
|
+
},
|
|
240
|
+
drivers: {
|
|
241
|
+
each: {
|
|
242
|
+
name: notEmpty('name'),
|
|
243
|
+
incidents: { each: { date: notEmpty('date') } },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
}) satisfies Validations<NestedFormType>;
|
|
247
|
+
|
|
248
|
+
type Result = Refine<NestedFormType, typeof constraints>;
|
|
249
|
+
expectTypeOf<Result['email']>().toEqualTypeOf<string>();
|
|
250
|
+
expectTypeOf<Result['homeAddress']['city']>().toEqualTypeOf<string>();
|
|
251
|
+
expectTypeOf<Result['homeAddress']['postalCode']>().toEqualTypeOf<string>();
|
|
252
|
+
// Nullability survives around the refined interior of a nullable section.
|
|
253
|
+
expectTypeOf<Result['mailingAddress']>().toEqualTypeOf<
|
|
254
|
+
{ city: string } | undefined
|
|
255
|
+
>();
|
|
256
|
+
expectTypeOf<Result['drivers'][number]['name']>().toEqualTypeOf<string>();
|
|
257
|
+
expectTypeOf<
|
|
258
|
+
Result['drivers'][number]['incidents'][number]['date']
|
|
259
|
+
>().toEqualTypeOf<string>();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('a pre-built section spec composes as a nested-spec value', () => {
|
|
263
|
+
// Building one section's spec via perField and placing it under the
|
|
264
|
+
// section key in a larger constraints object — the reuse shape phase 4
|
|
265
|
+
// exists to keep working.
|
|
266
|
+
const addressSpec = perField({
|
|
267
|
+
city: notEmpty('city'),
|
|
268
|
+
}) satisfies Validations<NestedFormType['homeAddress']>;
|
|
269
|
+
|
|
270
|
+
const constraints = {
|
|
271
|
+
homeAddress: addressSpec,
|
|
272
|
+
mailingAddress: addressSpec,
|
|
273
|
+
} as const satisfies Validations<NestedFormType>;
|
|
274
|
+
|
|
275
|
+
type Result = Refine<NestedFormType, typeof constraints>;
|
|
276
|
+
expectTypeOf<Result['homeAddress']['city']>().toEqualTypeOf<string>();
|
|
277
|
+
expectTypeOf<Result['homeAddress']['postalCode']>().toEqualTypeOf<
|
|
278
|
+
string | undefined
|
|
279
|
+
>();
|
|
280
|
+
expectTypeOf<Result['mailingAddress']>().toEqualTypeOf<
|
|
281
|
+
{ city: string } | undefined
|
|
282
|
+
>();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('rejects non-constraint values at the perField call itself', () => {
|
|
286
|
+
perField({
|
|
287
|
+
// @ts-expect-error a string is not a validator, array, or spec
|
|
288
|
+
email: 'not a validator',
|
|
289
|
+
});
|
|
290
|
+
perField({
|
|
291
|
+
// @ts-expect-error garbage stays rejected at depth (the TS2322
|
|
292
|
+
// anchors at the parent key, same as deep validator-input mismatches)
|
|
293
|
+
homeAddress: {
|
|
294
|
+
city: 42,
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('form-type shape errors surface at the satisfies check', () => {
|
|
300
|
+
// perField is form-type-agnostic, so a wrong key passes its bound and
|
|
301
|
+
// is caught where the form type first appears.
|
|
302
|
+
const wrongKey = perField({
|
|
303
|
+
homeAddress: { cityy: notEmpty('cityy') },
|
|
304
|
+
});
|
|
305
|
+
// @ts-expect-error 'cityy' is not a field of homeAddress
|
|
306
|
+
wrongKey satisfies Validations<NestedFormType>;
|
|
307
|
+
|
|
308
|
+
const eachOnObject = perField({
|
|
309
|
+
homeAddress: { each: { city: notEmpty('city') } },
|
|
310
|
+
});
|
|
311
|
+
// @ts-expect-error an `each` spec is not legal on an object field
|
|
312
|
+
eachOnObject satisfies Validations<NestedFormType>;
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
208
316
|
describe('Refine<T, V> — union-typed single constraints (conditional pick)', () => {
|
|
209
317
|
it('narrows a conditionally-picked validator to the union of branch results', () => {
|
|
210
318
|
// Only ONE branch runs at runtime, so the sound result is the UNION of
|
package/src/storybook/CLAUDE.md
CHANGED
|
@@ -23,6 +23,15 @@ What still applies: CSS modules, `--ui-*` tokens, no HTML attribute pass-through
|
|
|
23
23
|
|
|
24
24
|
If a story util starts getting reached for by app code, that's a signal to build a real primitive, not to promote the story util. A real `Placeholder` for empty states would be a different primitive with proper typography, icon support, and action slots — not this dashed box. Keep the lanes separate.
|
|
25
25
|
|
|
26
|
+
## Showcase story files live here too
|
|
27
|
+
|
|
28
|
+
Besides the utils, this folder hosts the cross-component showcase stories:
|
|
29
|
+
`Composition.stories.tsx` (`Composition/…`) and `Demo.stories.tsx` (`Demo/…`
|
|
30
|
+
— the polished mini-app demos; conventions in the root AGENTS.md "The Demo
|
|
31
|
+
section"). They are ordinary stories, not utils: they compose the library's
|
|
32
|
+
public pieces, keep their datasets deterministic, and carry `play`-function
|
|
33
|
+
integration tests. The relaxed util rules above don't apply to them.
|
|
34
|
+
|
|
26
35
|
## Adding a new util
|
|
27
36
|
|
|
28
37
|
- New folder under `src/_StoryUtils/<Name>/` with `index.tsx`.
|