@structuralists/scaffolding 0.13.0 → 0.14.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/storybook/CLAUDE.md +9 -0
- package/src/storybook/Demo.stories.tsx +467 -0
package/.storybook/preview.tsx
CHANGED
|
@@ -47,6 +47,9 @@ const preview: Preview = {
|
|
|
47
47
|
options: {
|
|
48
48
|
storySort: {
|
|
49
49
|
order: [
|
|
50
|
+
// Demo: polished illustrations of what can be built — first thing
|
|
51
|
+
// a visitor sees.
|
|
52
|
+
'Demo',
|
|
50
53
|
// Showcase: how the pieces fit together.
|
|
51
54
|
'Composition',
|
|
52
55
|
// Main library, alphabetical.
|
package/AGENTS.md
CHANGED
|
@@ -63,6 +63,17 @@ The Storybook toolbar has a Theme control (paintbrush icon) listing all six
|
|
|
63
63
|
leaves the attribute unset (prefers-color-scheme fallback) and is the initial
|
|
64
64
|
value, so the vitest story run is unaffected by the toggle.
|
|
65
65
|
|
|
66
|
+
## The Demo section
|
|
67
|
+
|
|
68
|
+
The first Storybook section (`'Demo'` in `storySort` in
|
|
69
|
+
`.storybook/preview.tsx`) holds polished showcase compositions — full
|
|
70
|
+
mini-apps built entirely from the library's public pieces, not component API
|
|
71
|
+
docs. Demo stories live in `src/storybook/` (like `Composition.stories.tsx`)
|
|
72
|
+
with titles of the form `Demo/<Name>`, keep their datasets deterministic (no
|
|
73
|
+
`Date.now()`/randomness at render), and double as integration tests via
|
|
74
|
+
`play` functions. Don't add new public components for a demo; compose from
|
|
75
|
+
what exists.
|
|
76
|
+
|
|
66
77
|
## Testing
|
|
67
78
|
|
|
68
79
|
### Story tests (the main gate)
|
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ export const SignupCard = () => {
|
|
|
53
53
|
};
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
Browse every component (with live controls) in the Storybook: `bun run storybook`. The toolbar's Theme control (paintbrush icon) previews any of the themes above — "System (default)" leaves `data-theme` unset, following system preference.
|
|
56
|
+
Browse every component (with live controls) in the Storybook: `bun run storybook`. The sidebar opens with a **Demo** section — full mini-apps (like a CRUD Team Directory) composed entirely from the library's public pieces, showing what an app scaffolded with it looks like. The toolbar's Theme control (paintbrush icon) previews any of the themes above — "System (default)" leaves `data-theme` unset, following system preference.
|
|
57
57
|
|
|
58
58
|
## Consumer notes
|
|
59
59
|
|
package/package.json
CHANGED
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`.
|
|
@@ -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
|
+
};
|