@structuralists/scaffolding 0.6.1 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -5,6 +5,7 @@ import type { Cursor, Path, ValueAt } from './types';
5
5
  type FlatObj = {
6
6
  name: string;
7
7
  age: number;
8
+ active: boolean;
8
9
  tags: string[]; // string[] is a FormValueSimple — treated as a leaf
9
10
  };
10
11
 
@@ -30,7 +31,7 @@ type Mixed = {
30
31
  describe('Path<T>', () => {
31
32
  it('expands flat object keys to single-step paths', () => {
32
33
  type P = Path<FlatObj>;
33
- expectTypeOf<P>().toEqualTypeOf<['name'] | ['age'] | ['tags']>();
34
+ expectTypeOf<P>().toEqualTypeOf<['name'] | ['age'] | ['active'] | ['tags']>();
34
35
  });
35
36
 
36
37
  it('descends into nested objects', () => {
@@ -71,6 +72,7 @@ describe('Path<T>', () => {
71
72
  it('produces `never` for FormValueSimple leaves', () => {
72
73
  expectTypeOf<Path<string>>().toEqualTypeOf<never>();
73
74
  expectTypeOf<Path<number>>().toEqualTypeOf<never>();
75
+ expectTypeOf<Path<boolean>>().toEqualTypeOf<never>();
74
76
  expectTypeOf<Path<string[]>>().toEqualTypeOf<never>();
75
77
  expectTypeOf<Path<undefined>>().toEqualTypeOf<never>();
76
78
  });
@@ -84,6 +86,7 @@ describe('ValueAt<T, P>', () => {
84
86
  it('resolves a single key', () => {
85
87
  expectTypeOf<ValueAt<FlatObj, ['name']>>().toEqualTypeOf<string>();
86
88
  expectTypeOf<ValueAt<FlatObj, ['age']>>().toEqualTypeOf<number>();
89
+ expectTypeOf<ValueAt<FlatObj, ['active']>>().toEqualTypeOf<boolean>();
87
90
  expectTypeOf<ValueAt<FlatObj, ['tags']>>().toEqualTypeOf<string[]>();
88
91
  });
89
92
 
package/src/forms/plan.md CHANGED
@@ -12,9 +12,10 @@ even if the risky type work later stalls, and the risk zone comes last so a
12
12
  TS wall can't strand finished work behind it.
13
13
 
14
14
  1. **Item 7 — Debugger.** Zero type risk, valuable for developing everything
15
- after it (watching nested state and structured errors live). *current*
15
+ after it (watching nested state and structured errors live). *done*
16
+ (PR #14, released 0.6.0)
16
17
  2. **Item 1 — validator arrays per key.** `ExcludedOf<C[number]>` already
17
- proven inside `allOf`.
18
+ proven inside `allOf`. ← *current*
18
19
  3. **Error-model swap** to `{path, error}[]` on the *flat* baseline (paths
19
20
  are single-key paths). Doesn't depend on nested constraints; hard
20
21
  prerequisite for item 6; makes phases 2–3 runtime-plumbing-free.
@@ -2,7 +2,7 @@
2
2
  // turn, but the cycle never exists at runtime.
3
3
  import type { FormDebuggerComponent } from './FormDebugger';
4
4
 
5
- export type FormValueSimple = string | number | bigint | string[] | Set<string> | undefined | null;
5
+ export type FormValueSimple = string | number | bigint | boolean | string[] | Set<string> | undefined | null;
6
6
 
7
7
  export type FormValuesObject = { [k in string]: FormValue };
8
8
 
@@ -58,6 +58,36 @@ describe('useFormState onSubmit narrowing — inline constraints', () => {
58
58
  });
59
59
  });
60
60
 
61
+ it('accepts a boolean field and narrows it through a marker excluding undefined', () => {
62
+ useFormState({
63
+ initialValues: {
64
+ agreed: undefined as boolean | undefined,
65
+ subscribed: false as boolean,
66
+ },
67
+ constraints: {
68
+ // notEmpty's marker excludes null | undefined | '' — on a
69
+ // boolean | undefined field that refines to plain boolean.
70
+ agreed: notEmpty('agreed'),
71
+ },
72
+ onSubmit: (values) => {
73
+ expectTypeOf(values).toEqualTypeOf<{
74
+ agreed: boolean;
75
+ subscribed: boolean;
76
+ }>();
77
+ },
78
+ });
79
+ });
80
+
81
+ it('rejects a string validator on a boolean field', () => {
82
+ useFormState({
83
+ initialValues: { agreed: false },
84
+ constraints: {
85
+ // @ts-expect-error minLength validates strings, `agreed` is a boolean
86
+ agreed: minLength('agreed', 3),
87
+ },
88
+ });
89
+ });
90
+
61
91
  it('rejects constraints for keys not in the form type', () => {
62
92
  useFormState({
63
93
  initialValues: { a: '' },
@@ -155,6 +185,8 @@ type InsuranceQuoteForm = {
155
185
  discountCodes: string[];
156
186
  referralSource: string | null;
157
187
  notes: string | undefined;
188
+ agreedToTerms: boolean | undefined;
189
+ paperlessBilling: boolean;
158
190
  };
159
191
 
160
192
  describe('useFormState narrowing at realistic scale', () => {
@@ -173,17 +205,20 @@ describe('useFormState narrowing at realistic scale', () => {
173
205
  startDate: notEmpty('startDate'),
174
206
  referralSource: notEmpty('referralSource'),
175
207
  notes: minLength('notes', 10),
208
+ agreedToTerms: notEmpty('agreedToTerms'),
176
209
  },
177
210
  onSubmit: (values) => {
178
211
  // Refined: notEmpty strips null/undefined/'' from the union.
179
212
  expectTypeOf(values.firstName).toEqualTypeOf<string>();
180
213
  expectTypeOf(values.email).toEqualTypeOf<string>();
181
214
  expectTypeOf(values.referralSource).toEqualTypeOf<string>();
215
+ expectTypeOf(values.agreedToTerms).toEqualTypeOf<boolean>();
182
216
  // Constrained but non-refining validators leave the type alone.
183
217
  expectTypeOf(values.phone).toEqualTypeOf<string | undefined>();
184
218
  expectTypeOf(values.annualIncomeUsd).toEqualTypeOf<number | null>();
185
219
  expectTypeOf(values.notes).toEqualTypeOf<string | undefined>();
186
220
  // Unconstrained fields — including all nested structure — untouched.
221
+ expectTypeOf(values.paperlessBilling).toEqualTypeOf<boolean>();
187
222
  expectTypeOf(values.homeAddress).toEqualTypeOf<UsAddress>();
188
223
  expectTypeOf(values.drivers).toEqualTypeOf<
189
224
  InsuranceQuoteForm['drivers']
@@ -201,9 +236,13 @@ describe('useFormState narrowing at realistic scale', () => {
201
236
  expectTypeOf<['homeAddress', 'city']>().toMatchTypeOf<P>();
202
237
  expectTypeOf<['drivers', number, 'incidents', number, 'claimAmountUsd']>().toMatchTypeOf<P>();
203
238
  expectTypeOf<['vehicles', number, 'garagingAddress', 'postalCode']>().toMatchTypeOf<P>();
239
+ expectTypeOf<['agreedToTerms']>().toMatchTypeOf<P>();
204
240
 
205
241
  expectTypeOf<
206
242
  ValueAt<InsuranceQuoteForm, ['drivers', number, 'incidents', number, 'claimAmountUsd']>
207
243
  >().toEqualTypeOf<number | null>();
244
+ expectTypeOf<
245
+ ValueAt<InsuranceQuoteForm, ['agreedToTerms']>
246
+ >().toEqualTypeOf<boolean | undefined>();
208
247
  });
209
248
  });
@@ -108,6 +108,42 @@ describe('useFormState', () => {
108
108
  });
109
109
  });
110
110
 
111
+ test('a boolean field can be set, validated, and submitted', () => {
112
+ const onSubmit = mock(() => {});
113
+ const { result } = renderHook(() =>
114
+ useFormState({
115
+ initialValues: {
116
+ email: 'a@b.co' as string | undefined,
117
+ agreed: false as boolean,
118
+ },
119
+ constraints: {
120
+ agreed: (val) => (val ? null : 'you must agree to the terms'),
121
+ },
122
+ onSubmit,
123
+ }),
124
+ );
125
+ expect(result.current.values.agreed).toBe(false);
126
+ expect(result.current.errors.agreed).toBe('you must agree to the terms');
127
+ expect(result.current.isValid).toBe(false);
128
+
129
+ act(() => {
130
+ result.current.submit();
131
+ });
132
+ expect(onSubmit).not.toHaveBeenCalled();
133
+
134
+ act(() => {
135
+ result.current.onValueChanges((prev) => ({ ...prev, agreed: true }));
136
+ });
137
+ expect(result.current.errors.agreed).toBeUndefined();
138
+ expect(result.current.isValid).toBe(true);
139
+
140
+ act(() => {
141
+ result.current.submit();
142
+ });
143
+ expect(onSubmit).toHaveBeenCalledTimes(1);
144
+ expect(onSubmit).toHaveBeenCalledWith({ email: 'a@b.co', agreed: true });
145
+ });
146
+
111
147
  test('a failed submit followed by a fix allows the next submit through', () => {
112
148
  const onSubmit = mock(() => {});
113
149
  const { result } = renderHook(() =>