@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,467 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
|
4
|
+
import {
|
|
5
|
+
BigTable,
|
|
6
|
+
badgeColumn,
|
|
7
|
+
booleanColumn,
|
|
8
|
+
currencyColumn,
|
|
9
|
+
dateColumn,
|
|
10
|
+
textColumn,
|
|
11
|
+
} from '../components/Tables/BigTable';
|
|
12
|
+
import type { ColumnDef } from '../components/Tables/BigTable';
|
|
13
|
+
import { Bar } from '../components/Layout/Bar';
|
|
14
|
+
import { Panels } from '../components/Layout/Panels';
|
|
15
|
+
import { Stack } from '../components/Layout/Stack';
|
|
16
|
+
import { Text } from '../components/Content/Text';
|
|
17
|
+
import { MediumModal } from '../components/Modals/MediumModal';
|
|
18
|
+
import { ConfirmModal } from '../components/Modals/ConfirmModal';
|
|
19
|
+
import { Button } from '../forms/elements/Button';
|
|
20
|
+
import { IconButton } from '../forms/elements/IconButton';
|
|
21
|
+
import type { SelectOption } from '../forms/elements/Select';
|
|
22
|
+
import { useFormState } from '../forms/state/useFormState/useFormState';
|
|
23
|
+
import { matches, notEmpty } from '../forms/state/validators/validators';
|
|
24
|
+
import { TextInputForForm } from '../forms/state/bindings/TextInputForForm';
|
|
25
|
+
import { SingleSelectForForm } from '../forms/state/bindings/SingleSelectForForm';
|
|
26
|
+
|
|
27
|
+
// The Demo section is a showcase: polished compositions of the library's
|
|
28
|
+
// public pieces, not component API docs. This one is the full CRUD loop —
|
|
29
|
+
// BigTable + MediumModal + useFormState field bindings + ConfirmModal —
|
|
30
|
+
// wired together the way a consuming app would.
|
|
31
|
+
const meta: Meta = {
|
|
32
|
+
title: 'Demo/Team Directory',
|
|
33
|
+
parameters: { layout: 'fullscreen' },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default meta;
|
|
37
|
+
type Story = StoryObj;
|
|
38
|
+
|
|
39
|
+
const ROLES = ['engineer', 'designer', 'pm', 'sales'] as const;
|
|
40
|
+
type Role = (typeof ROLES)[number];
|
|
41
|
+
|
|
42
|
+
const TEAMS = ['Platform', 'Growth', 'Infra', 'Brand', 'Revenue', 'Search'] as const;
|
|
43
|
+
type Team = (typeof TEAMS)[number];
|
|
44
|
+
|
|
45
|
+
const ROLE_OPTIONS: SelectOption<Role>[] = [
|
|
46
|
+
{ value: 'engineer', label: 'Engineer' },
|
|
47
|
+
{ value: 'designer', label: 'Designer' },
|
|
48
|
+
{ value: 'pm', label: 'PM' },
|
|
49
|
+
{ value: 'sales', label: 'Sales' },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const TEAM_OPTIONS: SelectOption<Team>[] = TEAMS.map((team) => ({
|
|
53
|
+
value: team,
|
|
54
|
+
label: team,
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
type Member = {
|
|
58
|
+
id: string;
|
|
59
|
+
name: string;
|
|
60
|
+
email: string;
|
|
61
|
+
role: Role;
|
|
62
|
+
team: Team;
|
|
63
|
+
salary: number;
|
|
64
|
+
startDate: string; // ISO
|
|
65
|
+
isActive: boolean;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const FIRST_NAMES = [
|
|
69
|
+
'Ada', 'Alan', 'Edsger', 'Barbara', 'Donald', 'Margaret',
|
|
70
|
+
'John', 'Katherine', 'Dennis', 'Radia', 'Ken', 'Frances',
|
|
71
|
+
];
|
|
72
|
+
const LAST_NAMES = [
|
|
73
|
+
'Lovelace', 'Turing', 'Dijkstra', 'Liskov', 'Knuth',
|
|
74
|
+
'Hamilton', 'Backus', 'Johnson', 'Ritchie',
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
// Deterministic: every value is index arithmetic over literal arrays — the
|
|
78
|
+
// same 34 rows on every render, so the story (and its play test) never
|
|
79
|
+
// flickers. The (i % 12, 5i % 9) name pairing has period 36, so all 34
|
|
80
|
+
// name/email combos are distinct.
|
|
81
|
+
const SEED_MEMBERS: Member[] = Array.from({ length: 34 }, (_, i) => {
|
|
82
|
+
const first = FIRST_NAMES[i % FIRST_NAMES.length];
|
|
83
|
+
const last = LAST_NAMES[(i * 5) % LAST_NAMES.length];
|
|
84
|
+
return {
|
|
85
|
+
id: `emp_${100 + i}`,
|
|
86
|
+
name: `${first} ${last}`,
|
|
87
|
+
email: `${first.toLowerCase()}.${last.toLowerCase()}@acme.dev`,
|
|
88
|
+
role: ROLES[i % ROLES.length],
|
|
89
|
+
team: TEAMS[(i * 7) % TEAMS.length],
|
|
90
|
+
salary: 95_000 + (i % 7) * 8_000 + (i % 3) * 2_500,
|
|
91
|
+
startDate: new Date(
|
|
92
|
+
Date.UTC(2019 + (i % 6), (i * 5) % 12, ((i * 7) % 27) + 1),
|
|
93
|
+
).toISOString(),
|
|
94
|
+
isActive: i % 6 !== 0,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// What the modal form holds while editing: loose everywhere a field starts
|
|
99
|
+
// blank. Salary is a string because it's typed into a text input; the
|
|
100
|
+
// digits-only constraint plus Number() at the boundary turn it back into
|
|
101
|
+
// a number.
|
|
102
|
+
type MemberFormValues = {
|
|
103
|
+
name: string | undefined;
|
|
104
|
+
email: string | undefined;
|
|
105
|
+
role: Role | null;
|
|
106
|
+
team: Team | null;
|
|
107
|
+
salary: string | undefined;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// What onSubmit receives: the constraints below strip undefined/null from
|
|
111
|
+
// every field. This type annotating the parent's handler compiles only
|
|
112
|
+
// because useFormState actually delivers the narrowed type.
|
|
113
|
+
type MemberFormSubmit = {
|
|
114
|
+
name: string;
|
|
115
|
+
email: string;
|
|
116
|
+
role: Role;
|
|
117
|
+
team: Team;
|
|
118
|
+
salary: string;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const EMPTY_MEMBER_FORM: MemberFormValues = {
|
|
122
|
+
name: undefined,
|
|
123
|
+
email: undefined,
|
|
124
|
+
role: null,
|
|
125
|
+
team: null,
|
|
126
|
+
salary: undefined,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
type MemberFormModalProps = {
|
|
130
|
+
isOpen: boolean;
|
|
131
|
+
title: string;
|
|
132
|
+
submitLabel: string;
|
|
133
|
+
initialValues: MemberFormValues;
|
|
134
|
+
onCancel: () => void;
|
|
135
|
+
onSubmit: (values: MemberFormSubmit) => void;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// One modal serves both add and edit — the parent remounts it fresh per
|
|
139
|
+
// opening (via key) with the right initialValues, so the hook state never
|
|
140
|
+
// leaks between sessions. Every field is wired with one getFormFieldPropsAt
|
|
141
|
+
// expression; error display (touched OR submit-attempted) comes with it.
|
|
142
|
+
const MemberFormModal = (props: MemberFormModalProps) => {
|
|
143
|
+
const { isOpen, title, submitLabel, initialValues, onCancel, onSubmit } =
|
|
144
|
+
props;
|
|
145
|
+
|
|
146
|
+
const { getFormFieldPropsAt, submit } = useFormState({
|
|
147
|
+
initialValues,
|
|
148
|
+
constraints: {
|
|
149
|
+
name: notEmpty('name'),
|
|
150
|
+
email: [
|
|
151
|
+
notEmpty('email'),
|
|
152
|
+
matches('email', /^[^@\s]+@[^@\s]+\.[^@\s]+$/, 'a valid email'),
|
|
153
|
+
],
|
|
154
|
+
role: notEmpty('role'),
|
|
155
|
+
team: notEmpty('team'),
|
|
156
|
+
salary: [
|
|
157
|
+
notEmpty('salary'),
|
|
158
|
+
matches('salary', /^\d+$/, 'a whole dollar amount (digits only)'),
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
onSubmit,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<MediumModal
|
|
166
|
+
isOpen={isOpen}
|
|
167
|
+
onClose={onCancel}
|
|
168
|
+
title={title}
|
|
169
|
+
footer={
|
|
170
|
+
<>
|
|
171
|
+
<Button onClick={onCancel}>Cancel</Button>
|
|
172
|
+
<Button variant="primary" onClick={() => submit()}>
|
|
173
|
+
{submitLabel}
|
|
174
|
+
</Button>
|
|
175
|
+
</>
|
|
176
|
+
}
|
|
177
|
+
>
|
|
178
|
+
<Stack gap={3}>
|
|
179
|
+
<TextInputForForm
|
|
180
|
+
label="Full name"
|
|
181
|
+
placeholder="Ada Lovelace"
|
|
182
|
+
formFieldProps={getFormFieldPropsAt(['name'])}
|
|
183
|
+
/>
|
|
184
|
+
<TextInputForForm
|
|
185
|
+
label="Email"
|
|
186
|
+
type="email"
|
|
187
|
+
placeholder="ada.lovelace@acme.dev"
|
|
188
|
+
formFieldProps={getFormFieldPropsAt(['email'])}
|
|
189
|
+
/>
|
|
190
|
+
<SingleSelectForForm
|
|
191
|
+
label="Role"
|
|
192
|
+
options={ROLE_OPTIONS}
|
|
193
|
+
placeholder="Pick a role"
|
|
194
|
+
formFieldProps={getFormFieldPropsAt(['role'])}
|
|
195
|
+
/>
|
|
196
|
+
<SingleSelectForForm
|
|
197
|
+
label="Team"
|
|
198
|
+
options={TEAM_OPTIONS}
|
|
199
|
+
placeholder="Pick a team"
|
|
200
|
+
formFieldProps={getFormFieldPropsAt(['team'])}
|
|
201
|
+
/>
|
|
202
|
+
<TextInputForForm
|
|
203
|
+
label="Salary (USD)"
|
|
204
|
+
hint="Whole dollars, digits only"
|
|
205
|
+
placeholder="120000"
|
|
206
|
+
formFieldProps={getFormFieldPropsAt(['salary'])}
|
|
207
|
+
/>
|
|
208
|
+
</Stack>
|
|
209
|
+
</MediumModal>
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const PencilIcon = () => (
|
|
214
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
215
|
+
<path
|
|
216
|
+
d="M11.5 2.5 L13.5 4.5 L5.5 12.5 L2.8 13.2 L3.5 10.5 Z"
|
|
217
|
+
stroke="currentColor"
|
|
218
|
+
strokeWidth="1.5"
|
|
219
|
+
strokeLinejoin="round"
|
|
220
|
+
/>
|
|
221
|
+
</svg>
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const TrashIcon = () => (
|
|
225
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
226
|
+
<path
|
|
227
|
+
d="M3 4.5 H13 M6.5 4.5 V3 H9.5 V4.5 M4.5 4.5 L5.2 13 H10.8 L11.5 4.5 M6.8 7 V10.7 M9.2 7 V10.7"
|
|
228
|
+
stroke="currentColor"
|
|
229
|
+
strokeWidth="1.5"
|
|
230
|
+
strokeLinecap="round"
|
|
231
|
+
strokeLinejoin="round"
|
|
232
|
+
/>
|
|
233
|
+
</svg>
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
type EditorSession = { mode: 'add' } | { mode: 'edit'; member: Member };
|
|
237
|
+
|
|
238
|
+
// The session outlives the open flag so the form keeps rendering its
|
|
239
|
+
// contents while ModalShell plays the 200ms exit animation; sessionId keys
|
|
240
|
+
// a fresh remount per opening so form state never leaks between sessions.
|
|
241
|
+
type EditorState = {
|
|
242
|
+
session: EditorSession;
|
|
243
|
+
sessionId: number;
|
|
244
|
+
isOpen: boolean;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const TeamDirectoryDemo = () => {
|
|
248
|
+
const [rows, setRows] = useState<Member[]>(SEED_MEMBERS);
|
|
249
|
+
const [editor, setEditor] = useState<EditorState | null>(null);
|
|
250
|
+
const [pendingDelete, setPendingDelete] = useState<Member | null>(null);
|
|
251
|
+
const nextIdRef = useRef(100 + SEED_MEMBERS.length);
|
|
252
|
+
|
|
253
|
+
const openEditor = (session: EditorSession) =>
|
|
254
|
+
setEditor((prev) => ({
|
|
255
|
+
session,
|
|
256
|
+
sessionId: (prev?.sessionId ?? 0) + 1,
|
|
257
|
+
isOpen: true,
|
|
258
|
+
}));
|
|
259
|
+
|
|
260
|
+
const closeEditor = () =>
|
|
261
|
+
setEditor((prev) => (prev ? { ...prev, isOpen: false } : prev));
|
|
262
|
+
|
|
263
|
+
const handleEditorSubmit = (vals: MemberFormSubmit) => {
|
|
264
|
+
if (editor?.session.mode === 'edit') {
|
|
265
|
+
const { id } = editor.session.member;
|
|
266
|
+
setRows((prev) =>
|
|
267
|
+
prev.map((row) =>
|
|
268
|
+
row.id === id
|
|
269
|
+
? { ...row, ...vals, salary: Number(vals.salary) }
|
|
270
|
+
: row,
|
|
271
|
+
),
|
|
272
|
+
);
|
|
273
|
+
} else {
|
|
274
|
+
const id = `emp_${nextIdRef.current++}`;
|
|
275
|
+
setRows((prev) => [
|
|
276
|
+
...prev,
|
|
277
|
+
{
|
|
278
|
+
...vals,
|
|
279
|
+
salary: Number(vals.salary),
|
|
280
|
+
id,
|
|
281
|
+
startDate: '2026-07-01T00:00:00.000Z',
|
|
282
|
+
isActive: true,
|
|
283
|
+
},
|
|
284
|
+
]);
|
|
285
|
+
}
|
|
286
|
+
closeEditor();
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const columnDefs: ColumnDef<Member>[] = [
|
|
290
|
+
textColumn({ id: 'name', header: 'Name', value: (m) => m.name, minWidth: 160 }),
|
|
291
|
+
textColumn({ id: 'email', header: 'Email', value: (m) => m.email, minWidth: 220 }),
|
|
292
|
+
badgeColumn({ id: 'role', header: 'Role', value: (m) => m.role }),
|
|
293
|
+
textColumn({ id: 'team', header: 'Team', value: (m) => m.team }),
|
|
294
|
+
currencyColumn({ id: 'salary', header: 'Salary', value: (m) => m.salary }),
|
|
295
|
+
booleanColumn({ id: 'active', header: 'Active', value: (m) => m.isActive }),
|
|
296
|
+
dateColumn({ id: 'start', header: 'Started', value: (m) => m.startDate }),
|
|
297
|
+
{
|
|
298
|
+
id: 'actions',
|
|
299
|
+
header: '',
|
|
300
|
+
align: 'right',
|
|
301
|
+
cell: (m) => (
|
|
302
|
+
<Stack direction="row" gap={1} justify="end">
|
|
303
|
+
<IconButton
|
|
304
|
+
ariaLabel={`Edit ${m.name}`}
|
|
305
|
+
tooltip="Edit"
|
|
306
|
+
size="small"
|
|
307
|
+
onClick={() => openEditor({ mode: 'edit', member: m })}
|
|
308
|
+
>
|
|
309
|
+
<PencilIcon />
|
|
310
|
+
</IconButton>
|
|
311
|
+
<IconButton
|
|
312
|
+
ariaLabel={`Delete ${m.name}`}
|
|
313
|
+
tooltip="Delete"
|
|
314
|
+
size="small"
|
|
315
|
+
variant="danger"
|
|
316
|
+
onClick={() => setPendingDelete(m)}
|
|
317
|
+
>
|
|
318
|
+
<TrashIcon />
|
|
319
|
+
</IconButton>
|
|
320
|
+
</Stack>
|
|
321
|
+
),
|
|
322
|
+
},
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
return (
|
|
326
|
+
<div style={{ height: '100vh' }}>
|
|
327
|
+
<Panels
|
|
328
|
+
header={
|
|
329
|
+
<Bar
|
|
330
|
+
title="Team Directory"
|
|
331
|
+
right={
|
|
332
|
+
<Stack direction="row" gap={2} align="center">
|
|
333
|
+
<Text size="small" isMuted>
|
|
334
|
+
{rows.length} members
|
|
335
|
+
</Text>
|
|
336
|
+
<Button
|
|
337
|
+
size="small"
|
|
338
|
+
variant="primary"
|
|
339
|
+
onClick={() => openEditor({ mode: 'add' })}
|
|
340
|
+
>
|
|
341
|
+
Add member
|
|
342
|
+
</Button>
|
|
343
|
+
</Stack>
|
|
344
|
+
}
|
|
345
|
+
/>
|
|
346
|
+
}
|
|
347
|
+
>
|
|
348
|
+
<BigTable data={rows} columnDefs={columnDefs} getRowKey={(m) => m.id} />
|
|
349
|
+
</Panels>
|
|
350
|
+
|
|
351
|
+
{editor && (
|
|
352
|
+
<MemberFormModal
|
|
353
|
+
key={editor.sessionId}
|
|
354
|
+
isOpen={editor.isOpen}
|
|
355
|
+
title={editor.session.mode === 'edit' ? 'Edit member' : 'Add member'}
|
|
356
|
+
submitLabel="Save"
|
|
357
|
+
initialValues={
|
|
358
|
+
editor.session.mode === 'edit'
|
|
359
|
+
? {
|
|
360
|
+
name: editor.session.member.name,
|
|
361
|
+
email: editor.session.member.email,
|
|
362
|
+
role: editor.session.member.role,
|
|
363
|
+
team: editor.session.member.team,
|
|
364
|
+
salary: String(editor.session.member.salary),
|
|
365
|
+
}
|
|
366
|
+
: EMPTY_MEMBER_FORM
|
|
367
|
+
}
|
|
368
|
+
onCancel={closeEditor}
|
|
369
|
+
onSubmit={handleEditorSubmit}
|
|
370
|
+
/>
|
|
371
|
+
)}
|
|
372
|
+
|
|
373
|
+
<ConfirmModal
|
|
374
|
+
isOpen={pendingDelete !== null}
|
|
375
|
+
title="Delete member"
|
|
376
|
+
description={
|
|
377
|
+
pendingDelete
|
|
378
|
+
? `Remove ${pendingDelete.name} from the directory? This cannot be undone.`
|
|
379
|
+
: undefined
|
|
380
|
+
}
|
|
381
|
+
confirmLabel="Delete member"
|
|
382
|
+
isDanger
|
|
383
|
+
onConfirm={() => {
|
|
384
|
+
if (pendingDelete) {
|
|
385
|
+
const { id } = pendingDelete;
|
|
386
|
+
setRows((prev) => prev.filter((row) => row.id !== id));
|
|
387
|
+
}
|
|
388
|
+
setPendingDelete(null);
|
|
389
|
+
}}
|
|
390
|
+
onCancel={() => setPendingDelete(null)}
|
|
391
|
+
/>
|
|
392
|
+
</div>
|
|
393
|
+
);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
export const FullCrud: Story = {
|
|
397
|
+
name: 'Full CRUD',
|
|
398
|
+
render: () => <TeamDirectoryDemo />,
|
|
399
|
+
// Walks the whole loop: add (validation errors → fix → row appends),
|
|
400
|
+
// edit (same modal pre-filled → row updates), delete (confirm → row
|
|
401
|
+
// removed). Modals and select option lists are portaled to <body>, so
|
|
402
|
+
// everything inside them is queried on the document body.
|
|
403
|
+
play: async ({ canvasElement }) => {
|
|
404
|
+
const canvas = within(canvasElement);
|
|
405
|
+
const body = within(canvasElement.ownerDocument.body);
|
|
406
|
+
|
|
407
|
+
await expect(canvas.getByText('34 members')).toBeInTheDocument();
|
|
408
|
+
|
|
409
|
+
// — Add: a failing submit surfaces every constrained field's error.
|
|
410
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Add member' }));
|
|
411
|
+
await userEvent.click(await body.findByRole('button', { name: 'Save' }));
|
|
412
|
+
await expect(body.getByText("'name' cannot be empty")).toBeInTheDocument();
|
|
413
|
+
await expect(body.getByText("'email' cannot be empty")).toBeInTheDocument();
|
|
414
|
+
await expect(body.getByText("'role' cannot be empty")).toBeInTheDocument();
|
|
415
|
+
await expect(body.getByText("'team' cannot be empty")).toBeInTheDocument();
|
|
416
|
+
await expect(body.getByText("'salary' cannot be empty")).toBeInTheDocument();
|
|
417
|
+
|
|
418
|
+
// Validator-array progression: notEmpty passes, matches takes over.
|
|
419
|
+
await userEvent.type(body.getByLabelText(/^Email/), 'not-an-email');
|
|
420
|
+
await expect(
|
|
421
|
+
body.getByText("'email' must be a valid email"),
|
|
422
|
+
).toBeInTheDocument();
|
|
423
|
+
|
|
424
|
+
// Fill everything in; select option lists are portaled too.
|
|
425
|
+
await userEvent.type(body.getByLabelText(/^Full name/), 'Grace Hopper');
|
|
426
|
+
await userEvent.clear(body.getByLabelText(/^Email/));
|
|
427
|
+
await userEvent.type(body.getByLabelText(/^Email/), 'grace.hopper@acme.dev');
|
|
428
|
+
await userEvent.click(body.getByRole('button', { name: 'Role' }));
|
|
429
|
+
await userEvent.click(await body.findByRole('option', { name: 'Engineer' }));
|
|
430
|
+
await userEvent.click(body.getByRole('button', { name: 'Team' }));
|
|
431
|
+
await userEvent.click(await body.findByRole('option', { name: 'Platform' }));
|
|
432
|
+
await userEvent.type(body.getByLabelText(/^Salary/), '180000');
|
|
433
|
+
|
|
434
|
+
// A valid submit closes the modal and appends the row.
|
|
435
|
+
await userEvent.click(body.getByRole('button', { name: 'Save' }));
|
|
436
|
+
await expect(await canvas.findByText('Grace Hopper')).toBeInTheDocument();
|
|
437
|
+
await expect(canvas.getByText('grace.hopper@acme.dev')).toBeInTheDocument();
|
|
438
|
+
await expect(canvas.getByText('35 members')).toBeInTheDocument();
|
|
439
|
+
|
|
440
|
+
// — Edit: the same modal opens pre-filled; saving updates in place.
|
|
441
|
+
await userEvent.click(
|
|
442
|
+
canvas.getByRole('button', { name: 'Edit Grace Hopper' }),
|
|
443
|
+
);
|
|
444
|
+
const nameInput = await body.findByLabelText(/^Full name/);
|
|
445
|
+
await expect(nameInput).toHaveValue('Grace Hopper');
|
|
446
|
+
await expect(body.getByLabelText(/^Salary/)).toHaveValue('180000');
|
|
447
|
+
await userEvent.clear(nameInput);
|
|
448
|
+
await userEvent.type(nameInput, 'Grace Murray Hopper');
|
|
449
|
+
await userEvent.click(body.getByRole('button', { name: 'Save' }));
|
|
450
|
+
await expect(
|
|
451
|
+
await canvas.findByText('Grace Murray Hopper'),
|
|
452
|
+
).toBeInTheDocument();
|
|
453
|
+
await expect(canvas.getByText('35 members')).toBeInTheDocument();
|
|
454
|
+
|
|
455
|
+
// — Delete: per-row action asks for confirmation before removing.
|
|
456
|
+
await userEvent.click(
|
|
457
|
+
canvas.getByRole('button', { name: 'Delete Grace Murray Hopper' }),
|
|
458
|
+
);
|
|
459
|
+
await userEvent.click(
|
|
460
|
+
await body.findByRole('button', { name: 'Delete member' }),
|
|
461
|
+
);
|
|
462
|
+
await waitFor(() =>
|
|
463
|
+
expect(canvas.queryByText('Grace Murray Hopper')).not.toBeInTheDocument(),
|
|
464
|
+
);
|
|
465
|
+
await expect(canvas.getByText('34 members')).toBeInTheDocument();
|
|
466
|
+
},
|
|
467
|
+
};
|