@structuralists/scaffolding 0.6.0 → 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/.github/workflows/ci.yml +38 -0
- package/package.json +1 -1
- package/src/forms/path/types.test-d.ts +4 -1
- package/src/forms/plan.md +3 -2
- package/src/forms/useFormState/types.ts +1 -1
- package/src/forms/useFormState/useFormState.stories.tsx +87 -0
- package/src/forms/useFormState/useFormState.test-d.ts +39 -0
- package/src/forms/useFormState/useFormState.test.tsx +36 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
# The same four checks the release job runs post-merge (publish.yml), but on
|
|
4
|
+
# every PR — so merging green is enforced, and the release job's checks are a
|
|
5
|
+
# formality rather than the first place a break surfaces.
|
|
6
|
+
on:
|
|
7
|
+
pull_request:
|
|
8
|
+
|
|
9
|
+
concurrency:
|
|
10
|
+
group: ci-${{ github.ref }}
|
|
11
|
+
cancel-in-progress: true
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
checks:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- uses: oven-sh/setup-bun@v2
|
|
20
|
+
with:
|
|
21
|
+
bun-version: latest
|
|
22
|
+
|
|
23
|
+
- run: bun install --frozen-lockfile
|
|
24
|
+
|
|
25
|
+
- name: Cache Playwright browsers
|
|
26
|
+
uses: actions/cache@v4
|
|
27
|
+
with:
|
|
28
|
+
path: ~/.cache/ms-playwright
|
|
29
|
+
key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
|
30
|
+
restore-keys: playwright-${{ runner.os }}-
|
|
31
|
+
|
|
32
|
+
- name: Install Playwright chromium
|
|
33
|
+
run: npx playwright install chromium --with-deps
|
|
34
|
+
|
|
35
|
+
- run: bun run typecheck
|
|
36
|
+
- run: bun run lint
|
|
37
|
+
- run: bun run test
|
|
38
|
+
- run: bun run test:storybook
|
package/package.json
CHANGED
|
@@ -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).
|
|
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
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { expect, userEvent, within } from 'storybook/test';
|
|
3
4
|
import { useFormState } from './useFormState';
|
|
4
5
|
import { allOf, matches, minLength, notEmpty } from '../validators/validators';
|
|
5
6
|
import { Field } from '../../components/Forms/Field';
|
|
@@ -153,6 +154,43 @@ export const SignupForm: Story = {
|
|
|
153
154
|
<SignupDemo />
|
|
154
155
|
</div>
|
|
155
156
|
),
|
|
157
|
+
// Walks the headline flow: errors gated on submit, allOf first-error
|
|
158
|
+
// progression, live clearing, and the narrowed payload reaching onSubmit.
|
|
159
|
+
play: async ({ canvasElement }) => {
|
|
160
|
+
const canvas = within(canvasElement);
|
|
161
|
+
const body = within(canvasElement.ownerDocument.body);
|
|
162
|
+
|
|
163
|
+
// Errors are gated on submitAttempted — nothing shown initially.
|
|
164
|
+
await expect(
|
|
165
|
+
canvas.queryByText("'email' cannot be empty"),
|
|
166
|
+
).not.toBeInTheDocument();
|
|
167
|
+
|
|
168
|
+
// A failing submit surfaces every constrained field's error.
|
|
169
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Sign up' }));
|
|
170
|
+
await expect(canvas.getByText("'email' cannot be empty")).toBeInTheDocument();
|
|
171
|
+
await expect(
|
|
172
|
+
canvas.getByText("'displayName' cannot be empty"),
|
|
173
|
+
).toBeInTheDocument();
|
|
174
|
+
await expect(canvas.getByText("'role' cannot be empty")).toBeInTheDocument();
|
|
175
|
+
|
|
176
|
+
// allOf progression: notEmpty now passes, matches takes over.
|
|
177
|
+
await userEvent.type(canvas.getByLabelText(/^Email/), 'not-an-email');
|
|
178
|
+
await expect(
|
|
179
|
+
canvas.getByText("'email' must be a valid email"),
|
|
180
|
+
).toBeInTheDocument();
|
|
181
|
+
|
|
182
|
+
// Fix every field; errors clear live.
|
|
183
|
+
await userEvent.clear(canvas.getByLabelText(/^Email/));
|
|
184
|
+
await userEvent.type(canvas.getByLabelText(/^Email/), 'will@example.com');
|
|
185
|
+
await userEvent.type(canvas.getByLabelText(/^Display name/), 'Will');
|
|
186
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Role' }));
|
|
187
|
+
// The option list is portaled — query the document, not the canvas.
|
|
188
|
+
await userEvent.click(await body.findByRole('option', { name: 'Engineer' }));
|
|
189
|
+
|
|
190
|
+
// Valid submit delivers the narrowed payload to onSubmit.
|
|
191
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Sign up' }));
|
|
192
|
+
await expect(canvas.getByText(/onSubmit received/)).toBeInTheDocument();
|
|
193
|
+
},
|
|
156
194
|
};
|
|
157
195
|
|
|
158
196
|
const LiveValidityDemo = () => {
|
|
@@ -193,6 +231,19 @@ export const LiveValidity: Story = {
|
|
|
193
231
|
<LiveValidityDemo />
|
|
194
232
|
</div>
|
|
195
233
|
),
|
|
234
|
+
play: async ({ canvasElement }) => {
|
|
235
|
+
const canvas = within(canvasElement);
|
|
236
|
+
|
|
237
|
+
// Errors here are live — no submit gating.
|
|
238
|
+
await expect(canvas.getByText('false')).toBeInTheDocument();
|
|
239
|
+
await userEvent.type(canvas.getByLabelText(/^Nickname/), 'ab');
|
|
240
|
+
await expect(
|
|
241
|
+
canvas.getByText("'nickname' must be at least 3 characters"),
|
|
242
|
+
).toBeInTheDocument();
|
|
243
|
+
|
|
244
|
+
await userEvent.type(canvas.getByLabelText(/^Nickname/), 'c');
|
|
245
|
+
await expect(canvas.getByText('true')).toBeInTheDocument();
|
|
246
|
+
},
|
|
196
247
|
};
|
|
197
248
|
|
|
198
249
|
type DebuggerDemoValues = {
|
|
@@ -300,4 +351,40 @@ export const WithDebugger: Story = {
|
|
|
300
351
|
<DebuggerDemo />
|
|
301
352
|
</div>
|
|
302
353
|
),
|
|
354
|
+
// The Debugger portals to <body>, so its trigger/window are queried on the
|
|
355
|
+
// document, not the canvas. Exercises open → live update → close, which
|
|
356
|
+
// also pins the stable-identity guarantee: a remounting Debugger would
|
|
357
|
+
// lose its open state on the first keystroke.
|
|
358
|
+
play: async ({ canvasElement }) => {
|
|
359
|
+
const canvas = within(canvasElement);
|
|
360
|
+
const body = within(canvasElement.ownerDocument.body);
|
|
361
|
+
|
|
362
|
+
// Closed: trigger only, no window content.
|
|
363
|
+
await expect(body.queryByText('isValid')).not.toBeInTheDocument();
|
|
364
|
+
|
|
365
|
+
// Open: live state, including errors the form itself isn't showing yet
|
|
366
|
+
// (its display is submit-gated; the debugger sees the raw truth).
|
|
367
|
+
await userEvent.click(body.getByRole('button', { name: 'signup form' }));
|
|
368
|
+
await expect(body.getByText('isValid')).toBeInTheDocument();
|
|
369
|
+
await expect(body.getByText("'email' cannot be empty")).toBeInTheDocument();
|
|
370
|
+
await expect(
|
|
371
|
+
body.getByText("'nickname' cannot be empty"),
|
|
372
|
+
).toBeInTheDocument();
|
|
373
|
+
|
|
374
|
+
// Live update while open: value appears, its error entry drops out.
|
|
375
|
+
await userEvent.type(canvas.getByLabelText(/^Nickname/), 'will');
|
|
376
|
+
await expect(body.getByText('will')).toBeInTheDocument();
|
|
377
|
+
await expect(
|
|
378
|
+
body.queryByText("'nickname' cannot be empty"),
|
|
379
|
+
).not.toBeInTheDocument();
|
|
380
|
+
|
|
381
|
+
// List values render in the window (index-keyed).
|
|
382
|
+
await userEvent.type(canvas.getByLabelText(/^Tags/), 'typescript');
|
|
383
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Add' }));
|
|
384
|
+
await expect(body.getByText('typescript')).toBeInTheDocument();
|
|
385
|
+
|
|
386
|
+
// Close: window unmounts, trigger stays.
|
|
387
|
+
await userEvent.click(body.getByRole('button', { name: 'signup form' }));
|
|
388
|
+
await expect(body.queryByText('isValid')).not.toBeInTheDocument();
|
|
389
|
+
},
|
|
303
390
|
};
|
|
@@ -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(() =>
|