@aircall/blocks 0.12.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/dist/globals.css +1 -1
- package/dist/index.d.ts +202 -56
- package/dist/index.js +203 -64
- package/package.json +3 -3
- package/skills/aircall-blocks/migrate-dashboard/SKILL.md +2 -1
- package/skills/aircall-blocks/migrate-dashboard/dashboard-page/SKILL.md +41 -14
- package/skills/aircall-blocks/migrate-dashboard/form-wizard/SKILL.md +113 -1
|
@@ -29,32 +29,51 @@ Campaign Creation (`CampaignWizardPage`) and Add Contacts (`AddContactsPage`) ar
|
|
|
29
29
|
|
|
30
30
|
## 2. DashboardStandalonePage (full-page flows)
|
|
31
31
|
|
|
32
|
+
Four-part nesting: the outer `DashboardStandalonePage` is the `page-background` shell
|
|
33
|
+
(8px inset, flex row); `DashboardStandalonePageContent` is the rounded content **panel**
|
|
34
|
+
that holds the header + scroll area; the header carries a centered title (+ optional
|
|
35
|
+
description) with edge-pinned actions; `DashboardStandalonePageMain` is the scroll area.
|
|
36
|
+
|
|
32
37
|
```tsx
|
|
33
38
|
import {
|
|
34
39
|
DashboardStandalonePage,
|
|
40
|
+
DashboardStandalonePageContent,
|
|
35
41
|
DashboardStandalonePageHeader,
|
|
36
42
|
DashboardStandalonePageTitle,
|
|
43
|
+
DashboardStandalonePageDescription,
|
|
37
44
|
DashboardStandalonePageActions,
|
|
38
45
|
DashboardStandalonePageAction,
|
|
39
|
-
|
|
46
|
+
DashboardStandalonePageMain
|
|
40
47
|
} from '@aircall/blocks';
|
|
41
48
|
|
|
42
49
|
<DashboardStandalonePage>
|
|
43
|
-
<DashboardStandalonePageHeader>
|
|
44
|
-
<DashboardStandalonePageTitle>Create campaign</DashboardStandalonePageTitle>
|
|
45
|
-
<DashboardStandalonePageActions>
|
|
46
|
-
<DashboardStandalonePageAction onClick={onCancel}>Cancel</DashboardStandalonePageAction>
|
|
47
|
-
</DashboardStandalonePageActions>
|
|
48
|
-
</DashboardStandalonePageHeader>
|
|
49
50
|
<DashboardStandalonePageContent>
|
|
50
|
-
|
|
51
|
+
<DashboardStandalonePageHeader>
|
|
52
|
+
<DashboardStandalonePageTitle>Create campaign</DashboardStandalonePageTitle>
|
|
53
|
+
{/* optional subtitle under the title */}
|
|
54
|
+
<DashboardStandalonePageDescription>Set up your outbound campaign</DashboardStandalonePageDescription>
|
|
55
|
+
{/* actions pin to an edge; side="start" pins left, default "end" pins right */}
|
|
56
|
+
<DashboardStandalonePageActions>
|
|
57
|
+
<DashboardStandalonePageAction onClick={onCancel}>Cancel</DashboardStandalonePageAction>
|
|
58
|
+
</DashboardStandalonePageActions>
|
|
59
|
+
</DashboardStandalonePageHeader>
|
|
60
|
+
<DashboardStandalonePageMain>
|
|
61
|
+
{/* a centered, width-capped Card — see below */}
|
|
62
|
+
</DashboardStandalonePageMain>
|
|
51
63
|
</DashboardStandalonePageContent>
|
|
52
64
|
</DashboardStandalonePage>
|
|
53
65
|
```
|
|
54
66
|
|
|
55
|
-
|
|
67
|
+
- **Header** is a fixed 64px bar; the title (+ description) is optically centered and
|
|
68
|
+
actions are pinned to the left/right edges via `side` — declare them in any order.
|
|
69
|
+
- **Split layouts:** put a second panel (e.g. a `ChatbotPanel`) as a sibling of
|
|
70
|
+
`DashboardStandalonePageContent`; they share the shell's 8px gap. Hide a secondary
|
|
71
|
+
side panel below the `md` breakpoint (`className="hidden md:flex"`) so the main flow
|
|
72
|
+
gets the full width on narrow screens.
|
|
73
|
+
|
|
74
|
+
### What goes inside `DashboardStandalonePageMain` — a centered, width-capped Card
|
|
56
75
|
|
|
57
|
-
`
|
|
76
|
+
`DashboardStandalonePageMain` is a full-bleed scroll area; it does **not** constrain
|
|
58
77
|
width. The actual content is a centered container (`mx-auto` + a `max-w-*` cap) wrapping
|
|
59
78
|
one or more `Card`s — the consistent pattern across the `DashboardStandalonePage` stories.
|
|
60
79
|
Don't let content run full-width, and don't reach for `Paper`/raw page wrappers.
|
|
@@ -62,7 +81,7 @@ Don't let content run full-width, and don't reach for `Paper`/raw page wrappers.
|
|
|
62
81
|
```tsx
|
|
63
82
|
import { Card, CardHeader, CardContent, CardFooter } from '@aircall/ds';
|
|
64
83
|
|
|
65
|
-
<
|
|
84
|
+
<DashboardStandalonePageMain>
|
|
66
85
|
{/* single-column form / wizard */}
|
|
67
86
|
<div className="mx-auto max-w-2xl pt-4">
|
|
68
87
|
<Card>
|
|
@@ -71,13 +90,19 @@ import { Card, CardHeader, CardContent, CardFooter } from '@aircall/ds';
|
|
|
71
90
|
<CardFooter className="justify-end gap-2">{/* Back / Next / Submit */}</CardFooter>
|
|
72
91
|
</Card>
|
|
73
92
|
</div>
|
|
74
|
-
</
|
|
93
|
+
</DashboardStandalonePageMain>
|
|
75
94
|
```
|
|
76
95
|
|
|
77
96
|
Width cap by layout:
|
|
78
97
|
- **Single-column form / wizard** → `mx-auto max-w-2xl pt-4` around one `Card` (CampaignWizard pattern).
|
|
79
98
|
- **Wide / two-column** (form + summary aside) → `mx-auto flex max-w-7xl flex-col gap-6 pt-8 lg:flex-row`, a `Card` per column.
|
|
80
99
|
|
|
100
|
+
> **API note (renamed):** `DashboardStandalonePageContent` is now the rounded **panel**
|
|
101
|
+
> wrapper (header + main), and the scroll area is `DashboardStandalonePageMain` — matching
|
|
102
|
+
> the `DashboardPage` family (`Page → Content → Main`). Earlier code where
|
|
103
|
+
> `DashboardStandalonePageContent` was the scroll area must move that content into
|
|
104
|
+
> `DashboardStandalonePageMain` and wrap header+main in `DashboardStandalonePageContent`.
|
|
105
|
+
|
|
81
106
|
## 3. DashboardPage (standard in-app page)
|
|
82
107
|
|
|
83
108
|
```tsx
|
|
@@ -154,8 +179,10 @@ Wrong:
|
|
|
154
179
|
Correct:
|
|
155
180
|
```tsx
|
|
156
181
|
<DashboardStandalonePage>
|
|
157
|
-
<
|
|
158
|
-
|
|
182
|
+
<DashboardStandalonePageContent>
|
|
183
|
+
<DashboardStandalonePageHeader>...</DashboardStandalonePageHeader>
|
|
184
|
+
<DashboardStandalonePageMain>{/* wizard */}</DashboardStandalonePageMain>
|
|
185
|
+
</DashboardStandalonePageContent>
|
|
159
186
|
</DashboardStandalonePage>
|
|
160
187
|
```
|
|
161
188
|
|
|
@@ -155,6 +155,31 @@ Why one form: `useFormWizard().data` was a single accumulator across pages — a
|
|
|
155
155
|
submits the complete object. Per-step `useForm`s would fragment that state and lose
|
|
156
156
|
cross-step validation.
|
|
157
157
|
|
|
158
|
+
## Validation UX — validate on click, don't disable
|
|
159
|
+
|
|
160
|
+
The `goNext` above is the **validate-on-click** pattern, and it's the one to reach for:
|
|
161
|
+
the Next button stays enabled, and clicking it validates the step and reveals what's
|
|
162
|
+
wrong. This is usually the product requirement ("never disable the button — tell me on
|
|
163
|
+
click"). It works because a **`'submit'`-cause validation also runs the field's
|
|
164
|
+
`onChange` validator** — so `form.validateField(name, 'submit')` populates errors even
|
|
165
|
+
for fields the user never touched, and each `Form*Field` renders them through its own
|
|
166
|
+
`FieldError`. (Verify against `form-core`'s `defaultValidationLogic`: the `submit` case
|
|
167
|
+
runs `[onChange, onBlur, onSubmit, …]`.)
|
|
168
|
+
|
|
169
|
+
- **Cross-field rules** ("at least one of A or B") can't be a single-field validator.
|
|
170
|
+
Put them on the parent `useForm`'s `validators`, or `safeParse` a step schema inside
|
|
171
|
+
`goNext`, and render the message where it belongs (e.g. a section-level `FieldError`).
|
|
172
|
+
- **i18n:** function validators can `return t(key)` directly; if you use a schema
|
|
173
|
+
library, build the schema in a factory that receives `t` (module-scope schemas can't
|
|
174
|
+
call `t`).
|
|
175
|
+
- If the design genuinely wants the button **disabled** until valid, subscribe
|
|
176
|
+
reactively instead — but this is the exception, not the default:
|
|
177
|
+
```tsx
|
|
178
|
+
<form.Subscribe selector={s => isStepValid(s.values)}>
|
|
179
|
+
{ok => <Button disabled={!ok} onClick={() => setStep(next.id)}>Next</Button>}
|
|
180
|
+
</form.Subscribe>
|
|
181
|
+
```
|
|
182
|
+
|
|
158
183
|
## Prop mapping
|
|
159
184
|
|
|
160
185
|
| `FormWizardProps` / `useFormWizard` | After |
|
|
@@ -192,6 +217,22 @@ one field.
|
|
|
192
217
|
**`necessityIndicator="required" | "optional"`** — mark only the exceptions (the few optional fields
|
|
193
218
|
in a mostly-required form, or vice versa); never mix both markers in one form.
|
|
194
219
|
|
|
220
|
+
**Layout — `orientation` / `controlPosition`** — every wrapper defaults to a **vertical** field
|
|
221
|
+
(label above the control). Two shared props flip to a side-by-side layout, reusing the DS `Field`
|
|
222
|
+
orientation:
|
|
223
|
+
- `orientation="horizontal"` puts the label/description beside the control. `orientation="responsive"`
|
|
224
|
+
is vertical on narrow screens and horizontal once wide — it needs a `FieldGroup` ancestor (which
|
|
225
|
+
provides the `@container/field-group` the responsive variant reads).
|
|
226
|
+
- `controlPosition="start" | "end"` picks the control's side. Defaults per control:
|
|
227
|
+
`FormSwitchField` → `'start'` (switch left of its label), text/select/etc. → `'end'`.
|
|
228
|
+
- `FormSwitchField` **defaults to the inline (horizontal) layout** — a switch stacked under its label
|
|
229
|
+
is rarely wanted. A settings toggle: `<FormSwitchField label description>` (switch on the left);
|
|
230
|
+
pin the switch to the right with `controlPosition="end"`. Force the old stacked look with
|
|
231
|
+
`orientation="vertical"`.
|
|
232
|
+
- A responsive text field (label + description on the left, input + error on the right on desktop,
|
|
233
|
+
stacked on mobile): `<FormInputField orientation="responsive" label description … />` inside a
|
|
234
|
+
`FieldGroup`.
|
|
235
|
+
|
|
195
236
|
## Fields reference
|
|
196
237
|
|
|
197
238
|
`@aircall/blocks` ships typed wrappers for all common DS controls; each takes `form`,
|
|
@@ -204,9 +245,45 @@ spread the control bundle onto the matching DS primitive.
|
|
|
204
245
|
| text/email/password | `FormInputField` | radio group | `FormRadioGroupField` |
|
|
205
246
|
| select | `FormSelectField` | OTP | `FormOTPField` |
|
|
206
247
|
| combobox (single) | `FormComboboxField` | switch | `FormSwitchField` |
|
|
207
|
-
| combobox (multi) | `FormMultiComboboxField` | number | `
|
|
248
|
+
| combobox (multi) | `FormMultiComboboxField` | number | `FormNumericField` |
|
|
208
249
|
| textarea | `FormTextareaField` | slider | `FormSliderField` |
|
|
209
250
|
|
|
251
|
+
## Multi-step gotchas
|
|
252
|
+
|
|
253
|
+
The `Stepper` **unmounts inactive `StepperContent`** (there's no `forceMount`). That's
|
|
254
|
+
usually fine — but two consequences bite:
|
|
255
|
+
|
|
256
|
+
- Field **values** persist in the form store across unmount, and so does field **meta**
|
|
257
|
+
(`isDirty` / `isTouched`) — a field's `mount()` cleanup is a no-op. So you can read
|
|
258
|
+
`form.state.fieldMeta['step1.field'].isDirty` while on step 3, and edits made on an
|
|
259
|
+
earlier step are still there when you submit. Add `forceMount` only if a step must
|
|
260
|
+
stay in the DOM.
|
|
261
|
+
- **Re-basing a saved draft: do NOT use `form.reset(values)`.** `reset` clears
|
|
262
|
+
`isTouched`, and when an unmounted step later remounts its field re-seeds to the
|
|
263
|
+
(empty) default — silently wiping the user's value. To mark the current values as
|
|
264
|
+
"synced" (e.g. after an autosave / draft save) while keeping them editable, clear
|
|
265
|
+
`isDirty` per field instead:
|
|
266
|
+
```tsx
|
|
267
|
+
for (const path of Object.keys(form.state.fieldMeta))
|
|
268
|
+
form.setFieldMeta(path, m => ({ ...m, isDirty: false }));
|
|
269
|
+
```
|
|
270
|
+
Now `isDirty` marks exactly what changed since the last sync — ideal for sending a
|
|
271
|
+
minimal update patch on the next save.
|
|
272
|
+
|
|
273
|
+
## Field groups for large steps (optional)
|
|
274
|
+
|
|
275
|
+
For steps with many fields you can extract each into a `withFieldGroup` bound to a
|
|
276
|
+
subtree of the form, instead of listing everything in one flat `useForm`. Two things
|
|
277
|
+
to know:
|
|
278
|
+
|
|
279
|
+
- `fields` accepts a **subtree key** (`fields="general"`) **or** a **`FieldsMap`**
|
|
280
|
+
(`fields={{ groupField: 'form.path' }}`) — the map lets a group bind onto a flat
|
|
281
|
+
model without restructuring it.
|
|
282
|
+
- A field group has **no group-level `validators`**. Put cross-field rules on a
|
|
283
|
+
field-level validator that reads its sibling via `fieldApi.form.getFieldValue(...)`,
|
|
284
|
+
or on the parent `useForm`. Use `group.Subscribe` for reactive reads inside the group
|
|
285
|
+
(its `state.values` is the group's subset).
|
|
286
|
+
|
|
210
287
|
## Common Mistakes
|
|
211
288
|
|
|
212
289
|
### 1. Fabricating a `Stepper` instance API (`useStepper({steps})`, `nextStep`, `stepper=` prop)
|
|
@@ -288,3 +365,38 @@ Correct:
|
|
|
288
365
|
`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`.
|
|
289
366
|
|
|
290
367
|
Source: `packages/blocks/src/form/use-form.ts`
|
|
368
|
+
|
|
369
|
+
### 5. Re-basing a multi-step draft with `form.reset`
|
|
370
|
+
|
|
371
|
+
Wrong:
|
|
372
|
+
```tsx
|
|
373
|
+
await saveDraft(form.state.values);
|
|
374
|
+
form.reset(form.state.values); // clears isTouched → remounted steps re-seed to empty
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Correct:
|
|
378
|
+
```tsx
|
|
379
|
+
await saveDraft(form.state.values);
|
|
380
|
+
for (const path of Object.keys(form.state.fieldMeta))
|
|
381
|
+
form.setFieldMeta(path, m => ({ ...m, isDirty: false })); // keeps values + isTouched
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
`form.reset` is right for a single-screen form, but inside a `Stepper` (which unmounts inactive steps) it makes fields re-seed to their defaults on the next remount, wiping user input. Clearing `isDirty` re-bases the "changed since last sync" baseline without touching values.
|
|
385
|
+
|
|
386
|
+
Source: `packages/blocks/src/form/use-form.ts`
|
|
387
|
+
|
|
388
|
+
## Testing (jsdom)
|
|
389
|
+
|
|
390
|
+
- **Drive navigation one step at a time.** `Next` handlers are async (validation runs
|
|
391
|
+
before advancing), so fire the click, **wait for the next step to render**, then
|
|
392
|
+
continue. Two back-to-back clicks race the async nav and both land on the same step.
|
|
393
|
+
```tsx
|
|
394
|
+
fireEvent.click(getByTestId('next'));
|
|
395
|
+
await screen.findByText('Role'); // step 2 is on screen
|
|
396
|
+
fireEvent.click(getByTestId('next'));
|
|
397
|
+
```
|
|
398
|
+
- **Assert errors without filling every field.** Because a `'submit'`-cause validation
|
|
399
|
+
runs the `onChange` validators (see "Validation UX"), clicking Next on an empty step
|
|
400
|
+
surfaces each field's `FieldError` — assert on those directly.
|
|
401
|
+
- **Field meta survives step changes**, so a test can edit a field on step 1, navigate
|
|
402
|
+
to a later step, submit, and still see the step-1 value in the payload.
|