@aircall/blocks 0.6.0 → 0.9.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/README.md +43 -0
- package/dist/globals.css +1 -1
- package/dist/index.d.ts +308 -3
- package/dist/index.js +1021 -147
- package/package.json +17 -3
- package/skills/aircall-blocks/migrate-dashboard/SKILL.md +127 -0
- package/skills/aircall-blocks/migrate-dashboard/card/SKILL.md +364 -0
- package/skills/aircall-blocks/migrate-dashboard/combobox/SKILL.md +487 -0
- package/skills/aircall-blocks/migrate-dashboard/copy-button/SKILL.md +138 -0
- package/skills/aircall-blocks/migrate-dashboard/dashboard-page/SKILL.md +198 -0
- package/skills/aircall-blocks/migrate-dashboard/data-table/SKILL.md +395 -0
- package/skills/aircall-blocks/migrate-dashboard/empty-states/SKILL.md +347 -0
- package/skills/aircall-blocks/migrate-dashboard/form-wizard/SKILL.md +271 -0
- package/skills/aircall-blocks/migrate-dashboard/info-popup/SKILL.md +179 -0
- package/skills/aircall-blocks/migrate-dashboard/layout/SKILL.md +328 -0
- package/skills/aircall-blocks/migrate-dashboard/list/SKILL.md +474 -0
- package/skills/aircall-blocks/migrate-dashboard/loading/SKILL.md +260 -0
- package/skills/aircall-blocks/migrate-dashboard/page-header/SKILL.md +394 -0
- package/skills/aircall-blocks/migrate-dashboard/tile/SKILL.md +429 -0
- package/skills/aircall-blocks/migrate-dashboard/toggle-row/SKILL.md +177 -0
- package/skills/aircall-blocks/setup/SKILL.md +127 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: aircall-blocks/migrate-dashboard/empty-states
|
|
3
|
+
description: >
|
|
4
|
+
Migrate @dashboard/library MessageScreen family (ComingSoonScreen,
|
|
5
|
+
RestrictedAccessScreen, NotFoundScreen, NoDataScreen, NoSupportScreen,
|
|
6
|
+
UnknownErrorScreen) to @aircall/blocks composable empty-state blocks.
|
|
7
|
+
Load when a file imports any of those from @dashboard/library.
|
|
8
|
+
type: sub-skill
|
|
9
|
+
library: aircall-blocks
|
|
10
|
+
library_version: "0.5.1"
|
|
11
|
+
requires:
|
|
12
|
+
- aircall-blocks/setup
|
|
13
|
+
- aircall-blocks/migrate-dashboard
|
|
14
|
+
sources:
|
|
15
|
+
- "aircall/hydra:docs/migration-guides/tractor-to-ds/recipes/empty-states.md"
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
This skill builds on aircall-blocks/migrate-dashboard.
|
|
19
|
+
|
|
20
|
+
## 1. Variant mapping
|
|
21
|
+
|
|
22
|
+
| `@dashboard/library` | `@aircall/blocks` root | Old asset | New icon |
|
|
23
|
+
| --- | --- | --- | --- |
|
|
24
|
+
| `ComingSoonScreen` | `ComingSoonEmptyState` | `aircall-placeholder.svg` | `Sprout` |
|
|
25
|
+
| `RestrictedAccessScreen` | `RestrictedAccessEmptyState` | `lock.svg` | `LockKeyhole` |
|
|
26
|
+
| `NotFoundScreen` | `NotFoundEmptyState` | `not-found.svg` | `Search` |
|
|
27
|
+
| `NoDataScreen` | `NoDataEmptyState` | `no-data.svg` | `Search` |
|
|
28
|
+
| `NoSupportScreen` | `NoSupportEmptyState` | `settings.svg` | `Settings` |
|
|
29
|
+
| `UnknownErrorScreen` | `UnknownErrorEmptyState` | `warning.svg` | `TriangleAlert` |
|
|
30
|
+
|
|
31
|
+
Each preset exports four individually importable parts — `*EmptyState` (root), `*Media`, `*Title`, `*Description` — all from `@aircall/blocks`.
|
|
32
|
+
|
|
33
|
+
## 2. Prop mapping
|
|
34
|
+
|
|
35
|
+
| `MessageScreen` prop | New equivalent |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `title` | children of `*Title` — omit to use the baked-in design copy |
|
|
38
|
+
| `subtitle` (string or element) | children of `*Description` |
|
|
39
|
+
| `buttonText` + `onClick` | `EmptyStateButton` inside `EmptyStateActions` |
|
|
40
|
+
| `image` | fixed per variant — drop it |
|
|
41
|
+
| `data-test` | pass to the root: `<NotFoundEmptyState data-test="…">` |
|
|
42
|
+
| `w` / `h` / `backgroundColor` | drop — constrain the parent instead |
|
|
43
|
+
|
|
44
|
+
## 3. Before / After examples
|
|
45
|
+
|
|
46
|
+
### 3a. NotFoundScreen → NotFoundEmptyState
|
|
47
|
+
|
|
48
|
+
**Before (`@dashboard/library`):**
|
|
49
|
+
```tsx
|
|
50
|
+
import { NotFoundScreen } from '@dashboard/library';
|
|
51
|
+
|
|
52
|
+
<NotFoundScreen
|
|
53
|
+
title="We couldn't find that page"
|
|
54
|
+
subtitle="It may have been moved, deleted, or the link may be incorrect."
|
|
55
|
+
buttonText="Go to inbox"
|
|
56
|
+
onClick={goToInbox}
|
|
57
|
+
/>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**After (`@aircall/blocks`):**
|
|
61
|
+
```tsx
|
|
62
|
+
import {
|
|
63
|
+
NotFoundEmptyState,
|
|
64
|
+
NotFoundMedia,
|
|
65
|
+
NotFoundTitle,
|
|
66
|
+
NotFoundDescription,
|
|
67
|
+
EmptyStateHeader,
|
|
68
|
+
EmptyStateActions,
|
|
69
|
+
EmptyStateButton
|
|
70
|
+
} from '@aircall/blocks';
|
|
71
|
+
|
|
72
|
+
<NotFoundEmptyState>
|
|
73
|
+
<EmptyStateHeader>
|
|
74
|
+
<NotFoundMedia />
|
|
75
|
+
<NotFoundTitle />
|
|
76
|
+
<NotFoundDescription />
|
|
77
|
+
</EmptyStateHeader>
|
|
78
|
+
<EmptyStateActions>
|
|
79
|
+
<EmptyStateButton variant="outline" onClick={goToInbox}>
|
|
80
|
+
Go to inbox
|
|
81
|
+
</EmptyStateButton>
|
|
82
|
+
</EmptyStateActions>
|
|
83
|
+
</NotFoundEmptyState>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The `*Title` and `*Description` ship the Figma copy as defaults — drop `title`/`subtitle` props to adopt them, or override by passing children.
|
|
87
|
+
|
|
88
|
+
### 3b. ComingSoonScreen → ComingSoonEmptyState (two buttons)
|
|
89
|
+
|
|
90
|
+
**Before (`@dashboard/library`):**
|
|
91
|
+
```tsx
|
|
92
|
+
import { ComingSoonScreen } from '@dashboard/library';
|
|
93
|
+
|
|
94
|
+
<ComingSoonScreen
|
|
95
|
+
title="Coming soon!"
|
|
96
|
+
subtitle="You'll be able to access this feature in the near future."
|
|
97
|
+
buttonText="Go back"
|
|
98
|
+
onClick={goBack}
|
|
99
|
+
/>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**After (`@aircall/blocks`):**
|
|
103
|
+
```tsx
|
|
104
|
+
import {
|
|
105
|
+
ComingSoonEmptyState,
|
|
106
|
+
ComingSoonMedia,
|
|
107
|
+
ComingSoonTitle,
|
|
108
|
+
ComingSoonDescription,
|
|
109
|
+
EmptyStateHeader,
|
|
110
|
+
EmptyStateActions,
|
|
111
|
+
EmptyStateButton,
|
|
112
|
+
EmptyStateExternalLink
|
|
113
|
+
} from '@aircall/blocks';
|
|
114
|
+
|
|
115
|
+
<ComingSoonEmptyState>
|
|
116
|
+
<EmptyStateHeader>
|
|
117
|
+
<ComingSoonMedia />
|
|
118
|
+
<ComingSoonTitle />
|
|
119
|
+
<ComingSoonDescription />
|
|
120
|
+
</EmptyStateHeader>
|
|
121
|
+
<EmptyStateActions>
|
|
122
|
+
<EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
|
|
123
|
+
</EmptyStateActions>
|
|
124
|
+
<EmptyStateExternalLink href={docsUrl} />
|
|
125
|
+
</ComingSoonEmptyState>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`EmptyStateExternalLink` is a sibling of `EmptyStateActions` (not inside it) so it sits on its own line below the buttons.
|
|
129
|
+
|
|
130
|
+
### 3c. UnknownErrorScreen → UnknownErrorEmptyState (error state)
|
|
131
|
+
|
|
132
|
+
**Before (`@dashboard/library`):**
|
|
133
|
+
```tsx
|
|
134
|
+
import { UnknownErrorScreen } from '@dashboard/library';
|
|
135
|
+
|
|
136
|
+
<UnknownErrorScreen
|
|
137
|
+
title="Something went wrong"
|
|
138
|
+
subtitle="Please try again."
|
|
139
|
+
buttonText="Try again"
|
|
140
|
+
onClick={reload}
|
|
141
|
+
/>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**After (`@aircall/blocks`):**
|
|
145
|
+
```tsx
|
|
146
|
+
import {
|
|
147
|
+
UnknownErrorEmptyState,
|
|
148
|
+
UnknownErrorMedia,
|
|
149
|
+
UnknownErrorTitle,
|
|
150
|
+
UnknownErrorDescription,
|
|
151
|
+
EmptyStateHeader,
|
|
152
|
+
EmptyStateActions,
|
|
153
|
+
EmptyStateButton
|
|
154
|
+
} from '@aircall/blocks';
|
|
155
|
+
|
|
156
|
+
<UnknownErrorEmptyState>
|
|
157
|
+
<EmptyStateHeader>
|
|
158
|
+
<UnknownErrorMedia />
|
|
159
|
+
<UnknownErrorTitle />
|
|
160
|
+
<UnknownErrorDescription />
|
|
161
|
+
</EmptyStateHeader>
|
|
162
|
+
<EmptyStateActions>
|
|
163
|
+
<EmptyStateButton variant="default" onClick={reload}>Try again</EmptyStateButton>
|
|
164
|
+
</EmptyStateActions>
|
|
165
|
+
</UnknownErrorEmptyState>
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`UnknownErrorEmptyState` carries `role="alert"` — it announces assertively to screen readers when it mounts. `NoDataEmptyState` carries `role="status"` (polite). The others are static page content with no live region.
|
|
169
|
+
|
|
170
|
+
### 3d. RestrictedAccessScreen → RestrictedAccessEmptyState (learn more link)
|
|
171
|
+
|
|
172
|
+
**Before (`@dashboard/library`):**
|
|
173
|
+
```tsx
|
|
174
|
+
import { RestrictedAccessScreen } from '@dashboard/library';
|
|
175
|
+
|
|
176
|
+
<RestrictedAccessScreen
|
|
177
|
+
title="You don't have access to this page."
|
|
178
|
+
subtitle="Contact an admin on your team to get access."
|
|
179
|
+
buttonText="Learn more"
|
|
180
|
+
onClick={() => window.open(docsUrl)}
|
|
181
|
+
/>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**After (`@aircall/blocks`):**
|
|
185
|
+
```tsx
|
|
186
|
+
import {
|
|
187
|
+
RestrictedAccessEmptyState,
|
|
188
|
+
RestrictedAccessMedia,
|
|
189
|
+
RestrictedAccessTitle,
|
|
190
|
+
RestrictedAccessDescription,
|
|
191
|
+
EmptyStateHeader,
|
|
192
|
+
EmptyStateExternalLink
|
|
193
|
+
} from '@aircall/blocks';
|
|
194
|
+
|
|
195
|
+
<RestrictedAccessEmptyState>
|
|
196
|
+
<EmptyStateHeader>
|
|
197
|
+
<RestrictedAccessMedia />
|
|
198
|
+
<RestrictedAccessTitle />
|
|
199
|
+
<RestrictedAccessDescription />
|
|
200
|
+
</EmptyStateHeader>
|
|
201
|
+
<EmptyStateExternalLink href={docsUrl} />
|
|
202
|
+
</RestrictedAccessEmptyState>
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Use `EmptyStateExternalLink` (renders a real `<a target="_blank">`) instead of an `onClick` that calls `window.open`.
|
|
206
|
+
|
|
207
|
+
### 3e. Custom / one-off empty state (no variant)
|
|
208
|
+
|
|
209
|
+
When migrating a bespoke `MessageScreen` usage (custom `image` prop), use the generic family directly:
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
import {
|
|
213
|
+
EmptyState,
|
|
214
|
+
EmptyStateHeader,
|
|
215
|
+
EmptyStateMedia,
|
|
216
|
+
EmptyStateTitle,
|
|
217
|
+
EmptyStateDescription,
|
|
218
|
+
EmptyStateActions,
|
|
219
|
+
EmptyStateButton
|
|
220
|
+
} from '@aircall/blocks';
|
|
221
|
+
import { Sparkles } from '@aircall/react-icons';
|
|
222
|
+
|
|
223
|
+
<EmptyState>
|
|
224
|
+
<EmptyStateHeader>
|
|
225
|
+
<EmptyStateMedia>
|
|
226
|
+
<Sparkles />
|
|
227
|
+
</EmptyStateMedia>
|
|
228
|
+
<EmptyStateTitle>No integrations yet</EmptyStateTitle>
|
|
229
|
+
<EmptyStateDescription>Connect your first integration to get started.</EmptyStateDescription>
|
|
230
|
+
</EmptyStateHeader>
|
|
231
|
+
<EmptyStateActions>
|
|
232
|
+
<EmptyStateButton variant="default" onClick={openCatalog}>Browse catalog</EmptyStateButton>
|
|
233
|
+
</EmptyStateActions>
|
|
234
|
+
</EmptyState>
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Icons must come from `@aircall/react-icons`, never from `lucide-react` directly.
|
|
238
|
+
|
|
239
|
+
## 4. Common Mistakes
|
|
240
|
+
|
|
241
|
+
### Mistake 1: Passing `title`/`subtitle` as props to the root
|
|
242
|
+
|
|
243
|
+
```tsx
|
|
244
|
+
// WRONG — the root block has no title/subtitle props; they are silently ignored
|
|
245
|
+
<NotFoundEmptyState title="Page not found" subtitle="Check the URL." />
|
|
246
|
+
|
|
247
|
+
// CORRECT — pass custom copy as children of the *Title / *Description parts
|
|
248
|
+
<NotFoundEmptyState>
|
|
249
|
+
<EmptyStateHeader>
|
|
250
|
+
<NotFoundMedia />
|
|
251
|
+
<NotFoundTitle>Page not found</NotFoundTitle>
|
|
252
|
+
<NotFoundDescription>Check the URL.</NotFoundDescription>
|
|
253
|
+
</EmptyStateHeader>
|
|
254
|
+
</NotFoundEmptyState>
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Mechanism: the old `MessageScreen` was a single component that accepted flat string props. The new blocks are a composable family; the root only accepts layout-level props (`data-test`, `role`, `className`, …). Title and description are separate sub-components.
|
|
258
|
+
|
|
259
|
+
Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyState.Props` extends `React.ComponentProps<typeof Empty>`, which has no `title` or `subtitle`.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
### Mistake 2: Placing `EmptyStateExternalLink` inside `EmptyStateActions`
|
|
264
|
+
|
|
265
|
+
```tsx
|
|
266
|
+
// WRONG — the link ends up in the same flex row as the buttons
|
|
267
|
+
<EmptyStateActions>
|
|
268
|
+
<EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
|
|
269
|
+
<EmptyStateExternalLink href={docsUrl} />
|
|
270
|
+
</EmptyStateActions>
|
|
271
|
+
|
|
272
|
+
// CORRECT — place it as a sibling after EmptyStateActions
|
|
273
|
+
<EmptyStateActions>
|
|
274
|
+
<EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
|
|
275
|
+
</EmptyStateActions>
|
|
276
|
+
<EmptyStateExternalLink href={docsUrl} />
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Mechanism: `EmptyStateActions` is a flex row (`flex-row flex-wrap justify-center gap-3`). Placing the link inside it makes it appear inline with the buttons at the same level, breaking the stacked design where the link sits on its own line below.
|
|
280
|
+
|
|
281
|
+
Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyStateActions` renders `EmptyContent` with `flex-row` class; `EmptyStateExternalLink` is meant to be a sibling in the `EmptyState` root's column flow.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
### Mistake 3: Setting `size` on `EmptyStateButton`
|
|
286
|
+
|
|
287
|
+
```tsx
|
|
288
|
+
// WRONG — size is locked; passing it is a type error and overrides the design spec
|
|
289
|
+
<EmptyStateButton variant="outline" size="sm" onClick={goBack}>Go back</EmptyStateButton>
|
|
290
|
+
|
|
291
|
+
// CORRECT — omit size; only variant is yours to choose
|
|
292
|
+
<EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Mechanism: `EmptyStateButtonProps` is `Omit<React.ComponentProps<typeof Button>, 'size'>` — `size` is explicitly removed from the type. The button always renders at `size="default"` to keep all empty states visually consistent regardless of the surrounding UI.
|
|
296
|
+
|
|
297
|
+
Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyStateButton` wraps `Button` with `size="default"` hardcoded; the `size` key is stripped from the exported props type.
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
### Mistake 4: Carrying over `w`, `h`, or `backgroundColor` from `Gap` props
|
|
302
|
+
|
|
303
|
+
```tsx
|
|
304
|
+
// WRONG — these props do not exist on the new blocks root
|
|
305
|
+
<NotFoundEmptyState w="100%" h="100%" backgroundColor="neutral-100" />
|
|
306
|
+
|
|
307
|
+
// CORRECT — the block fills its container and centers automatically; constrain the parent
|
|
308
|
+
<div className="h-full w-full">
|
|
309
|
+
<NotFoundEmptyState />
|
|
310
|
+
</div>
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Mechanism: the old `MessageScreen` extended `GapProps` (Tractor) which exposed `w`/`h`/`backgroundColor` to fill the viewport. The new `*EmptyState` roots extend `React.ComponentProps<typeof Empty>` (DS), which is a plain `<div>` with Tailwind layout — these props do not exist.
|
|
314
|
+
|
|
315
|
+
Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyStateProps` extends `React.ComponentProps<typeof Empty>`; `packages/blocks/src/components/coming-soon.tsx` — `ComingSoonEmptyState` is `ComingSoon.Root` with the same props shape.
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
### Mistake 5: Importing icons from `lucide-react` directly in generic empty states
|
|
320
|
+
|
|
321
|
+
```tsx
|
|
322
|
+
// WRONG — imports lucide-react directly, bypassing Aircall's icon layer
|
|
323
|
+
import { Sparkles } from 'lucide-react';
|
|
324
|
+
import { EmptyState, EmptyStateHeader, EmptyStateMedia, EmptyStateTitle } from '@aircall/blocks';
|
|
325
|
+
|
|
326
|
+
<EmptyState>
|
|
327
|
+
<EmptyStateHeader>
|
|
328
|
+
<EmptyStateMedia><Sparkles /></EmptyStateMedia>
|
|
329
|
+
<EmptyStateTitle>No integrations yet</EmptyStateTitle>
|
|
330
|
+
</EmptyStateHeader>
|
|
331
|
+
</EmptyState>
|
|
332
|
+
|
|
333
|
+
// CORRECT — always import from @aircall/react-icons
|
|
334
|
+
import { Sparkles } from '@aircall/react-icons';
|
|
335
|
+
import { EmptyState, EmptyStateHeader, EmptyStateMedia, EmptyStateTitle } from '@aircall/blocks';
|
|
336
|
+
|
|
337
|
+
<EmptyState>
|
|
338
|
+
<EmptyStateHeader>
|
|
339
|
+
<EmptyStateMedia><Sparkles /></EmptyStateMedia>
|
|
340
|
+
<EmptyStateTitle>No integrations yet</EmptyStateTitle>
|
|
341
|
+
</EmptyStateHeader>
|
|
342
|
+
</EmptyState>
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Mechanism: `@aircall/react-icons` re-exports all lucide icons plus Aircall custom icons; importing from `lucide-react` directly bypasses any Aircall overrides and breaks the single icon source-of-truth. All six built-in presets source their icons through `@aircall/react-icons` — custom media slots should follow the same convention.
|
|
346
|
+
|
|
347
|
+
Source: `packages/blocks/src/components/coming-soon.tsx` — imports `Sprout` from `@aircall/react-icons`, not from `lucide-react`.
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: aircall-blocks/migrate-dashboard/form-wizard
|
|
3
|
+
description: >
|
|
4
|
+
Migrate @dashboard/library FormWizard, useFormWizard, and FormField to a fully
|
|
5
|
+
integrated TanStack Form: one @aircall/blocks useForm + the Form*Field wrappers,
|
|
6
|
+
driven through ds Stepper for the multi-step UI. Load when a file imports
|
|
7
|
+
FormWizard, useFormWizard, or FormField from @dashboard/library.
|
|
8
|
+
type: sub-skill
|
|
9
|
+
library: aircall-blocks
|
|
10
|
+
library_version: "0.5.1"
|
|
11
|
+
requires:
|
|
12
|
+
- aircall-blocks/setup
|
|
13
|
+
- aircall-blocks/migrate-dashboard
|
|
14
|
+
sources:
|
|
15
|
+
- "aircall/hydra:packages/ds/src/components/stepper.tsx"
|
|
16
|
+
- "aircall/hydra:packages/blocks/src/form/use-form.ts"
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
This skill builds on aircall-blocks/migrate-dashboard.
|
|
20
|
+
|
|
21
|
+
> **Principle — the form owns state, never `useState`.** All field values, validation,
|
|
22
|
+
> dirty/`canSubmit`/`isSubmitting`, and errors live in ONE `useForm`; render every field
|
|
23
|
+
> via a `Form*Field` wrapper. Do not carry over `useFormWizard().data`/`editData` or raw
|
|
24
|
+
> `useState` + `<Input value/onChange>` — rewrite them to the form. (`useState` is still
|
|
25
|
+
> fine for non-field UI like the active step.)
|
|
26
|
+
|
|
27
|
+
## Concept split
|
|
28
|
+
|
|
29
|
+
`FormWizard` bundled the overlay, the stepper, and the form state. The DS stack
|
|
30
|
+
separates them — and the form state becomes **one** TanStack Form spanning every
|
|
31
|
+
step (the canonical TanStack [multi-step wizard](https://tanstack.com/form/latest/docs/framework/react/examples/multi-step-wizard) pattern):
|
|
32
|
+
|
|
33
|
+
| @dashboard/library | Target |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `FormWizard` overlay shell | `Dialog` from `@aircall/ds` (or render inline) |
|
|
36
|
+
| `FormWizard.Stepper` / `pageIndex` / step titles | `Stepper` + `StepperProgress` + `StepperPanel` + `StepperContent` from `@aircall/ds` |
|
|
37
|
+
| `useFormWizard().data` / `editData` | **one** `useForm` from `@aircall/blocks` with all steps' `defaultValues` — values accumulate across steps automatically |
|
|
38
|
+
| `FormField` | a `Form*Field` from `@aircall/blocks` (`FormInputField`, `FormSelectField`, …) |
|
|
39
|
+
| `navigateToNextPage` | validate the current step's fields, then advance the `Stepper` `value` |
|
|
40
|
+
| `onSuccess(data)` | the single `useForm`'s `onSubmit` (fires from the last step) |
|
|
41
|
+
| `onClose` / `onCancel` | `DialogClose` + `onOpenChange` |
|
|
42
|
+
|
|
43
|
+
## Import mapping
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
// Before
|
|
47
|
+
import { FormWizard, useFormWizard, FormField } from '@dashboard/library';
|
|
48
|
+
|
|
49
|
+
// After
|
|
50
|
+
import { useState } from 'react';
|
|
51
|
+
import {
|
|
52
|
+
Dialog, DialogContent, DialogHeader, DialogTitle, DialogClose,
|
|
53
|
+
Stepper, StepperProgress, StepperPanel, StepperContent,
|
|
54
|
+
Button, Input
|
|
55
|
+
} from '@aircall/ds';
|
|
56
|
+
import { useForm, FormInputField, FormSelectField } from '@aircall/blocks';
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Stepper API (read this — it is value-controlled)
|
|
60
|
+
|
|
61
|
+
`Stepper` takes a `steps: { id; title }[]` array and is controlled by `value` /
|
|
62
|
+
`onValueChange` (the active step **id**). Panels render with `StepperContent value={id}`
|
|
63
|
+
inside a `StepperPanel`. `useStepper()` (no args, only inside a `Stepper`) returns
|
|
64
|
+
`{ activeId, goTo, getIndex, steps }`. There is no `nextStep`/`prevStep`/`currentStep` —
|
|
65
|
+
advance by setting `value` (or `goTo(id)`).
|
|
66
|
+
|
|
67
|
+
## After — one form, ds Stepper, validate-per-step
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
const STEPS = [
|
|
71
|
+
{ id: 'details', title: 'Details' },
|
|
72
|
+
{ id: 'role', title: 'Role' }
|
|
73
|
+
] as const;
|
|
74
|
+
|
|
75
|
+
// which fields belong to which step — used to validate before advancing
|
|
76
|
+
const STEP_FIELDS: Record<string, string[]> = {
|
|
77
|
+
details: ['name'],
|
|
78
|
+
role: ['role']
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function CreateUserWizard({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
82
|
+
const [step, setStep] = useState<string>(STEPS[0].id);
|
|
83
|
+
|
|
84
|
+
// ONE form for the whole wizard — values from every step accumulate here
|
|
85
|
+
const form = useForm({
|
|
86
|
+
defaultValues: { name: '', role: '' },
|
|
87
|
+
onSubmit: async ({ value }) => {
|
|
88
|
+
await saveUser(value);
|
|
89
|
+
onClose();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const stepIndex = STEPS.findIndex(s => s.id === step);
|
|
94
|
+
const isLast = stepIndex === STEPS.length - 1;
|
|
95
|
+
|
|
96
|
+
// validate only the CURRENT step's fields before moving on
|
|
97
|
+
const goNext = async () => {
|
|
98
|
+
const results = await Promise.all(
|
|
99
|
+
STEP_FIELDS[step].map(name => form.validateField(name, 'submit'))
|
|
100
|
+
);
|
|
101
|
+
if (results.every(errors => errors.length === 0)) {
|
|
102
|
+
setStep(STEPS[stepIndex + 1].id);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
|
|
108
|
+
<DialogContent className="max-w-lg p-0">
|
|
109
|
+
<DialogHeader className="border-b px-6 py-4">
|
|
110
|
+
<DialogTitle>Create user</DialogTitle>
|
|
111
|
+
<DialogClose />
|
|
112
|
+
</DialogHeader>
|
|
113
|
+
|
|
114
|
+
<form onSubmit={e => { e.preventDefault(); void form.handleSubmit(); }}>
|
|
115
|
+
<Stepper steps={STEPS} value={step} onValueChange={setStep} className="px-6 pt-4">
|
|
116
|
+
<StepperProgress />
|
|
117
|
+
<StepperPanel className="p-6">
|
|
118
|
+
<StepperContent value="details" className="space-y-4">
|
|
119
|
+
<FormInputField form={form} name="name" label="Full name">
|
|
120
|
+
{(_field, { inputProps }) => <Input {...inputProps} />}
|
|
121
|
+
</FormInputField>
|
|
122
|
+
</StepperContent>
|
|
123
|
+
|
|
124
|
+
<StepperContent value="role" className="space-y-4">
|
|
125
|
+
<FormSelectField form={form} name="role" label="Role">
|
|
126
|
+
{(_field, control) => <Input {...control.inputProps} />}
|
|
127
|
+
</FormSelectField>
|
|
128
|
+
</StepperContent>
|
|
129
|
+
</StepperPanel>
|
|
130
|
+
</Stepper>
|
|
131
|
+
|
|
132
|
+
<div className="flex justify-end gap-2 border-t px-6 py-3">
|
|
133
|
+
{stepIndex > 0 && (
|
|
134
|
+
<Button type="button" variant="ghost" onClick={() => setStep(STEPS[stepIndex - 1].id)}>
|
|
135
|
+
Back
|
|
136
|
+
</Button>
|
|
137
|
+
)}
|
|
138
|
+
{isLast ? (
|
|
139
|
+
<form.AppForm>
|
|
140
|
+
<form.SubmitButton>Create user</form.SubmitButton>
|
|
141
|
+
</form.AppForm>
|
|
142
|
+
) : (
|
|
143
|
+
<Button type="button" onClick={goNext}>Next</Button>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
</form>
|
|
147
|
+
</DialogContent>
|
|
148
|
+
</Dialog>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Why one form: `useFormWizard().data` was a single accumulator across pages — a single
|
|
154
|
+
`useForm` reproduces that exactly (every field lives in one `value`), so the final step
|
|
155
|
+
submits the complete object. Per-step `useForm`s would fragment that state and lose
|
|
156
|
+
cross-step validation.
|
|
157
|
+
|
|
158
|
+
## Prop mapping
|
|
159
|
+
|
|
160
|
+
| `FormWizardProps` / `useFormWizard` | After |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `initialData` | the single `useForm({ defaultValues })` |
|
|
163
|
+
| `onSuccess(data)` | `onSubmit` on that `useForm` (runs from the last step) |
|
|
164
|
+
| `onClose` / `onCancel` | `DialogClose` + `onOpenChange` on `Dialog` |
|
|
165
|
+
| `pageIndex` / current page | `value` (active step id) on `Stepper` |
|
|
166
|
+
| `navigateToNextPage` | validate step fields, then `setStep(next.id)` |
|
|
167
|
+
| `navigateToPreviousPage` | `setStep(prev.id)` |
|
|
168
|
+
| `FormWizard.Page stepTitle` | a `{ id, title }` entry in the `steps` array |
|
|
169
|
+
|
|
170
|
+
| `FormFieldProps` | After |
|
|
171
|
+
|---|---|
|
|
172
|
+
| `name` / `label` | `name` / `label` on the `Form*Field` |
|
|
173
|
+
| `validate` | `validators.onChange` / `onSubmit` on the `Form*Field` |
|
|
174
|
+
| `defaultValue` | `defaultValues[name]` in `useForm` |
|
|
175
|
+
| `getErrorMessage(e)` | return the translated string from the validator |
|
|
176
|
+
|
|
177
|
+
## Fields reference
|
|
178
|
+
|
|
179
|
+
`@aircall/blocks` ships typed wrappers for all common DS controls; each takes `form`,
|
|
180
|
+
`name`, `label`, optional `validators`, and a render-prop `(field, control) => …` —
|
|
181
|
+
spread the control bundle onto the matching DS primitive.
|
|
182
|
+
|
|
183
|
+
| Input | Block | Input | Block |
|
|
184
|
+
|---|---|---|---|
|
|
185
|
+
| text/email/password | `FormInputField` | radio group | `FormRadioGroupField` |
|
|
186
|
+
| select | `FormSelectField` | OTP | `FormOTPField` |
|
|
187
|
+
| combobox (single) | `FormComboboxField` | switch | `FormSwitchField` |
|
|
188
|
+
| combobox (multi) | `FormMultiComboboxField` | number | `FormNumberField` |
|
|
189
|
+
| textarea | `FormTextareaField` | slider | `FormSliderField` |
|
|
190
|
+
|
|
191
|
+
## Common Mistakes
|
|
192
|
+
|
|
193
|
+
### 1. Fabricating a `Stepper` instance API (`useStepper({steps})`, `nextStep`, `stepper=` prop)
|
|
194
|
+
|
|
195
|
+
Wrong:
|
|
196
|
+
```tsx
|
|
197
|
+
const stepper = useStepper({ steps }); // useStepper takes no args
|
|
198
|
+
<Stepper stepper={stepper}> // no `stepper` prop
|
|
199
|
+
<StepperContent step={steps[0]}>... // no `step` prop
|
|
200
|
+
<Button onClick={stepper.nextStep}>Next</Button> // no nextStep
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Correct:
|
|
204
|
+
```tsx
|
|
205
|
+
const [step, setStep] = useState(STEPS[0].id);
|
|
206
|
+
<Stepper steps={STEPS} value={step} onValueChange={setStep}>
|
|
207
|
+
<StepperPanel>
|
|
208
|
+
<StepperContent value="details">...</StepperContent>
|
|
209
|
+
</StepperPanel>
|
|
210
|
+
</Stepper>
|
|
211
|
+
<Button type="button" onClick={() => setStep(next.id)}>Next</Button>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`Stepper` is value-controlled by step **id**; `useStepper()` (no args, inside the tree) exposes `{ activeId, goTo, getIndex, steps }`. The invented instance API does not exist and won't compile.
|
|
215
|
+
|
|
216
|
+
Source: `packages/ds/src/components/stepper.tsx`
|
|
217
|
+
|
|
218
|
+
### 2. One `useForm` per step instead of one for the wizard
|
|
219
|
+
|
|
220
|
+
Wrong:
|
|
221
|
+
```tsx
|
|
222
|
+
function DetailsStep() { const form = useForm({ defaultValues: { name: '' }, ... }); }
|
|
223
|
+
function RoleStep() { const form = useForm({ defaultValues: { role: '' }, ... }); }
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Correct:
|
|
227
|
+
```tsx
|
|
228
|
+
// one form at the wizard level holds every step's fields
|
|
229
|
+
const form = useForm({ defaultValues: { name: '', role: '' }, onSubmit });
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`useFormWizard().data` was one accumulator. Per-step forms fragment the value, drop earlier steps on submit, and prevent cross-step validation.
|
|
233
|
+
|
|
234
|
+
Source: `packages/blocks/src/form/use-form.ts`
|
|
235
|
+
|
|
236
|
+
### 3. Advancing without validating the current step
|
|
237
|
+
|
|
238
|
+
Wrong:
|
|
239
|
+
```tsx
|
|
240
|
+
<Button onClick={() => setStep(next.id)}>Next</Button> // skips validation
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Correct:
|
|
244
|
+
```tsx
|
|
245
|
+
const goNext = async () => {
|
|
246
|
+
const r = await Promise.all(STEP_FIELDS[step].map(n => form.validateField(n, 'submit')));
|
|
247
|
+
if (r.every(e => e.length === 0)) setStep(next.id);
|
|
248
|
+
};
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`form.validateField(name, cause)` returns the field's errors; gating `setStep` on them reproduces `FormWizard`'s per-page validation. Without it the user advances past empty/invalid required fields.
|
|
252
|
+
|
|
253
|
+
Source: `packages/blocks/src/form/use-form.ts`
|
|
254
|
+
|
|
255
|
+
### 4. `CardSaveBar`/`SubmitButton` passed `form` as a prop or placed outside `form.AppForm`
|
|
256
|
+
|
|
257
|
+
Wrong:
|
|
258
|
+
```tsx
|
|
259
|
+
<CardSaveBar form={form} />
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Correct:
|
|
263
|
+
```tsx
|
|
264
|
+
<form.AppForm>
|
|
265
|
+
<form.SubmitButton>Create user</form.SubmitButton>
|
|
266
|
+
</form.AppForm>
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`SubmitButton`/`CardSaveBar` are registered form components — they read `canSubmit`/`isSubmitting` from form context and must be rendered via `form.AppForm`, not handed a `form` prop. They must also sit inside the `<form>` so their `type="submit"` triggers `onSubmit`.
|
|
270
|
+
|
|
271
|
+
Source: `packages/blocks/src/form/use-form.ts`
|