@aircall/blocks
Version:
Aircall Blocks — higher-level UI compositions built on @aircall/ds
442 lines (365 loc) • 19.5 kB
Markdown
---
name: aircall-blocks/migrate-dashboard/form-wizard
description: >
Migrate @dashboard/library FormWizard, useFormWizard, and FormField to a fully
integrated TanStack Form: one @aircall/blocks useForm + the Form*Field wrappers,
driven through ds Stepper for the multi-step UI. Load when a file imports
FormWizard, useFormWizard, or FormField from @dashboard/library.
type: sub-skill
library: aircall-blocks
requires:
- aircall-blocks/setup
- aircall-blocks/migrate-dashboard
sources:
- "aircall/hydra:packages/ds/src/components/stepper.tsx"
- "aircall/hydra:packages/blocks/src/form/use-form.ts"
---
This skill builds on aircall-blocks/migrate-dashboard.
> **Principle — the form owns state, never `useState`.** All field values, validation,
> dirty/`canSubmit`/`isSubmitting`, and errors live in ONE `useForm`; render every field
> via a `Form*Field` wrapper. Do not carry over `useFormWizard().data`/`editData` or raw
> `useState` + `<Input value/onChange>` — rewrite them to the form. (`useState` is still
> fine for non-field UI like the active step.)
## Concept split
`FormWizard` bundled the overlay, the stepper, and the form state. The DS stack
separates them — and the form state becomes **one** TanStack Form spanning every
step (the canonical TanStack [multi-step wizard](https://tanstack.com/form/latest/docs/framework/react/examples/multi-step-wizard) pattern):
| @dashboard/library | Target |
|---|---|
| `FormWizard` overlay shell | `Dialog` from `@aircall/ds` (or render inline) |
| `FormWizard.Stepper` / `pageIndex` / step titles | `Stepper` + `StepperProgress` + `StepperPanel` + `StepperContent` from `@aircall/ds` |
| `useFormWizard().data` / `editData` | **one** `useForm` from `@aircall/blocks` with all steps' `defaultValues` — values accumulate across steps automatically |
| `FormField` | a `Form*Field` from `@aircall/blocks` (`FormInputField`, `FormSelectField`, …) |
| `navigateToNextPage` | validate the current step's fields, then advance the `Stepper` `value` |
| `onSuccess(data)` | the single `useForm`'s `onSubmit` (fires from the last step) |
| `onClose` / `onCancel` | `DialogClose` + `onOpenChange` |
## Import mapping
```tsx
// Before
import { FormWizard, useFormWizard, FormField } from '@dashboard/library';
// After
import { useState } from 'react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogClose,
Stepper, StepperProgress, StepperPanel, StepperContent,
Button, Input
} from '@aircall/ds';
import { useForm, FormInputField, FormSelectField } from '@aircall/blocks';
```
## Stepper API (read this — it is value-controlled)
`Stepper` takes a `steps: { id; title }[]` array and is controlled by `value` /
`onValueChange` (the active step **id**). Panels render with `StepperContent value={id}`
inside a `StepperPanel`. `useStepper()` (no args, only inside a `Stepper`) returns
`{ activeId, goTo, getIndex, steps }`. There is no `nextStep`/`prevStep`/`currentStep` —
advance by setting `value` (or `goTo(id)`).
## After — one form, ds Stepper, validate-per-step
```tsx
const STEPS = [
{ id: 'details', title: 'Details' },
{ id: 'role', title: 'Role' }
] as const;
// which fields belong to which step — used to validate before advancing
const STEP_FIELDS: Record<string, string[]> = {
details: ['name'],
role: ['role']
};
export function CreateUserWizard({ open, onClose }: { open: boolean; onClose: () => void }) {
const [step, setStep] = useState<string>(STEPS[0].id);
// ONE form for the whole wizard — values from every step accumulate here
const form = useForm({
defaultValues: { name: '', role: '' },
onSubmit: async ({ value }) => {
await saveUser(value);
onClose();
}
});
const stepIndex = STEPS.findIndex(s => s.id === step);
const isLast = stepIndex === STEPS.length - 1;
// validate only the CURRENT step's fields before moving on
const goNext = async () => {
const results = await Promise.all(
STEP_FIELDS[step].map(name => form.validateField(name, 'submit'))
);
if (results.every(errors => errors.length === 0)) {
setStep(STEPS[stepIndex + 1].id);
}
};
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="max-w-lg p-0">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>Create user</DialogTitle>
<DialogClose />
</DialogHeader>
<form onSubmit={e => { e.preventDefault(); void form.handleSubmit(); }}>
<Stepper steps={STEPS} value={step} onValueChange={setStep} className="px-6 pt-4">
<StepperProgress />
<StepperPanel className="p-6">
<StepperContent value="details" className="space-y-4">
<FormInputField form={form} name="name" label="Full name">
{(_field, { inputProps }) => <Input {...inputProps} />}
</FormInputField>
</StepperContent>
<StepperContent value="role" className="space-y-4">
<FormSelectField form={form} name="role" label="Role">
{(_field, control) => <Input {...control.inputProps} />}
</FormSelectField>
</StepperContent>
</StepperPanel>
</Stepper>
<div className="flex justify-end gap-2 border-t px-6 py-3">
{stepIndex > 0 && (
<Button type="button" variant="ghost" onClick={() => setStep(STEPS[stepIndex - 1].id)}>
Back
</Button>
)}
{isLast ? (
<form.AppForm>
<form.SubmitButton>Create user</form.SubmitButton>
</form.AppForm>
) : (
<Button type="button" onClick={goNext}>Next</Button>
)}
</div>
</form>
</DialogContent>
</Dialog>
);
}
```
Why one form: `useFormWizard().data` was a single accumulator across pages — a single
`useForm` reproduces that exactly (every field lives in one `value`), so the final step
submits the complete object. Per-step `useForm`s would fragment that state and lose
cross-step validation.
## Validation UX — validate on click, don't disable
The `goNext` above is the **validate-on-click** pattern, and it's the one to reach for:
the Next button stays enabled, and clicking it validates the step and reveals what's
wrong. This is usually the product requirement ("never disable the button — tell me on
click"). It works because a **`'submit'`-cause validation also runs the field's
`onChange` validator** — so `form.validateField(name, 'submit')` populates errors even
for fields the user never touched, and each `Form*Field` renders them through its own
`FieldError`. (Verify against `form-core`'s `defaultValidationLogic`: the `submit` case
runs `[onChange, onBlur, onSubmit, …]`.)
- **Cross-field rules** ("at least one of A or B") can't be a single-field validator.
Put them on the parent `useForm`'s `validators`, or `safeParse` a step schema inside
`goNext`, and render the message where it belongs (e.g. a section-level `FieldError`).
- **i18n:** function validators can `return t(key)` directly; if you use a schema
library, build the schema in a factory that receives `t` (module-scope schemas can't
call `t`).
- If the design genuinely wants the button **disabled** until valid, subscribe
reactively instead — but this is the exception, not the default:
```tsx
<form.Subscribe selector={s => isStepValid(s.values)}>
{ok => <Button disabled={!ok} onClick={() => setStep(next.id)}>Next</Button>}
</form.Subscribe>
```
## Prop mapping
| `FormWizardProps` / `useFormWizard` | After |
|---|---|
| `initialData` | the single `useForm({ defaultValues })` |
| `onSuccess(data)` | `onSubmit` on that `useForm` (runs from the last step) |
| `onClose` / `onCancel` | `DialogClose` + `onOpenChange` on `Dialog` |
| `pageIndex` / current page | `value` (active step id) on `Stepper` |
| `navigateToNextPage` | validate step fields, then `setStep(next.id)` |
| `navigateToPreviousPage` | `setStep(prev.id)` |
| `FormWizard.Page stepTitle` | a `{ id, title }` entry in the `steps` array |
| `FormFieldProps` | After |
|---|---|
| `name` | `name` on the `Form*Field` |
| `label` | `label` — a string, or `{ content, aside?, info? }` (see below) |
| helper/description text | `description` — a string, or `{ content, variant? }` (see below) |
| `validate` | `validators.onChange` / `onSubmit` on the `Form*Field` |
| `defaultValue` | `defaultValues[name]` in `useForm` |
| `getErrorMessage(e)` | return the translated string from the validator |
**`label`** — a string is the label text. The object form adds label-row affordances:
- `label={{ content, info }}` — `info` is an `(i)` icon opening a popover of purely additive context
(hide it when ~90% of users don't need it).
- `label={{ content, aside }}` — `aside` is free-form content pinned to the **right** of the row (a
"Where do I find this?" link, a popover, plain text, …). You pass the node — an `Anchor`, a
`Popover`, whatever — and blocks wraps it in `FieldLabelAside` for alignment + typography, forcing
no styling of its own.
**`description`** — a string renders as the **`instructional`** variant (under the label, always
visible — the default). Pass `{ content, variant: 'contextual' }` to move it **below the control**,
where it is **hidden when the field is invalid** (the error takes its slot). Never mix both kinds on
one field.
**`necessityIndicator="required" | "optional"`** — mark only the exceptions (the few optional fields
in a mostly-required form, or vice versa); never mix both markers in one form.
**Layout — `orientation` / `controlPosition`** — every wrapper defaults to a **vertical** field
(label above the control). Two shared props flip to a side-by-side layout, reusing the DS `Field`
orientation:
- `orientation="horizontal"` puts the label/description beside the control. `orientation="responsive"`
is vertical on narrow screens and horizontal once wide — it needs a `FieldGroup` ancestor (which
provides the `@container/field-group` the responsive variant reads).
- `controlPosition="start" | "end"` picks the control's side. Defaults per control:
`FormSwitchField` → `'start'` (switch left of its label), text/select/etc. → `'end'`.
- `FormSwitchField` **defaults to the inline (horizontal) layout** — a switch stacked under its label
is rarely wanted. A settings toggle: `<FormSwitchField label description>` (switch on the left);
pin the switch to the right with `controlPosition="end"`. Force the old stacked look with
`orientation="vertical"`.
- A responsive text field (label + description on the left, input + error on the right on desktop,
stacked on mobile): `<FormInputField orientation="responsive" label description … />` inside a
`FieldGroup`.
## Fields reference
`@aircall/blocks` ships typed wrappers for all common DS controls; each takes `form`,
`name`, `label`, optional `validators`, optional `description` / `necessityIndicator`
(see Prop mapping above), and a render-prop `(field, control) => …` —
spread the control bundle onto the matching DS primitive.
| Input | Block | Input | Block |
|---|---|---|---|
| text/email/password | `FormInputField` | radio group | `FormRadioGroupField` |
| select | `FormSelectField` | OTP | `FormOTPField` |
| combobox (single) | `FormComboboxField` | switch | `FormSwitchField` |
| combobox (multi) | `FormMultiComboboxField` | number | `FormNumericField` |
| textarea | `FormTextareaField` | slider | `FormSliderField` |
| toggle group | `FormToggleGroupField` | | |
### Building a wrapper for an unlisted primitive (`FormFieldBase`)
For a DS primitive without a shipped `Form*Field`, build one on `FormFieldBase` — it owns the field binding + the shared `Field`/`Label`/`Description`/`Error` shell; you supply a `buildControl` that maps the typed field to the control bundle. This is the shape every shipped wrapper follows (here re-creating `FormSwitchField`, which already ships — copy the pattern for a genuinely new primitive):
```tsx
import { FormFieldBase, type BoundForm, type FormFieldValidators, type TypedField } from '@aircall/blocks';
import { Switch } from '@aircall/ds';
import type { DeepKeysOfType } from '@tanstack/react-form';
type SwitchControl = {
switchProps: { checked: boolean; onCheckedChange: (checked: boolean) => void; onBlur: () => void };
};
function switchControl(field: TypedField<boolean>): SwitchControl {
return {
switchProps: {
checked: field.state.value,
onCheckedChange: checked => field.handleChange(checked),
onBlur: field.handleBlur,
},
};
}
function FormSwitchField<
TForm extends BoundForm,
TName extends DeepKeysOfType<TForm['state']['values'], boolean> // only boolean fields
>(props: {
form: TForm;
name: TName;
label: string;
validators?: FormFieldValidators<TForm['state']['values'], TName>;
children: (field: TypedField<boolean>, control: SwitchControl) => React.ReactNode;
}) {
return <FormFieldBase<boolean, SwitchControl> {...props} buildControl={switchControl} />;
}
```
`DeepKeysOfType<…, boolean>` restricts `name` to fields whose value type matches the control (here `boolean`), so the form stays type-safe.
## Multi-step gotchas
The `Stepper` **unmounts inactive `StepperContent`** (there's no `forceMount`). That's
usually fine — but two consequences bite:
- Field **values** persist in the form store across unmount, and so does field **meta**
(`isDirty` / `isTouched`) — a field's `mount()` cleanup is a no-op. So you can read
`form.state.fieldMeta['step1.field'].isDirty` while on step 3, and edits made on an
earlier step are still there when you submit. Add `forceMount` only if a step must
stay in the DOM.
- **Re-basing a saved draft: do NOT use `form.reset(values)`.** `reset` clears
`isTouched`, and when an unmounted step later remounts its field re-seeds to the
(empty) default — silently wiping the user's value. To mark the current values as
"synced" (e.g. after an autosave / draft save) while keeping them editable, clear
`isDirty` per field instead:
```tsx
for (const path of Object.keys(form.state.fieldMeta))
form.setFieldMeta(path, m => ({ ...m, isDirty: false }));
```
Now `isDirty` marks exactly what changed since the last sync — ideal for sending a
minimal update patch on the next save.
## Field groups for large steps (optional)
For steps with many fields you can extract each into a `withFieldGroup` bound to a
subtree of the form, instead of listing everything in one flat `useForm`. Two things
to know:
- `fields` accepts a **subtree key** (`fields="general"`) **or** a **`FieldsMap`**
(`fields={{ groupField: 'form.path' }}`) — the map lets a group bind onto a flat
model without restructuring it.
- A field group has **no group-level `validators`**. Put cross-field rules on a
field-level validator that reads its sibling via `fieldApi.form.getFieldValue(...)`,
or on the parent `useForm`. Use `group.Subscribe` for reactive reads inside the group
(its `state.values` is the group's subset).
## Common Mistakes
### 1. Fabricating a `Stepper` instance API (`useStepper({steps})`, `nextStep`, `stepper=` prop)
Wrong:
```tsx
const stepper = useStepper({ steps }); // useStepper takes no args
<Stepper stepper={stepper}> // no `stepper` prop
<StepperContent step={steps[0]}>... // no `step` prop
<Button onClick={stepper.nextStep}>Next</Button> // no nextStep
```
Correct:
```tsx
const [step, setStep] = useState(STEPS[0].id);
<Stepper steps={STEPS} value={step} onValueChange={setStep}>
<StepperPanel>
<StepperContent value="details">...</StepperContent>
</StepperPanel>
</Stepper>
<Button type="button" onClick={() => setStep(next.id)}>Next</Button>
```
`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.
Source: `packages/ds/src/components/stepper.tsx`
### 2. One `useForm` per step instead of one for the wizard
Wrong:
```tsx
function DetailsStep() { const form = useForm({ defaultValues: { name: '' }, ... }); }
function RoleStep() { const form = useForm({ defaultValues: { role: '' }, ... }); }
```
Correct:
```tsx
// one form at the wizard level holds every step's fields
const form = useForm({ defaultValues: { name: '', role: '' }, onSubmit });
```
`useFormWizard().data` was one accumulator. Per-step forms fragment the value, drop earlier steps on submit, and prevent cross-step validation.
Source: `packages/blocks/src/form/use-form.ts`
### 3. Advancing without validating the current step
Wrong:
```tsx
<Button onClick={() => setStep(next.id)}>Next</Button> // skips validation
```
Correct:
```tsx
const goNext = async () => {
const r = await Promise.all(STEP_FIELDS[step].map(n => form.validateField(n, 'submit')));
if (r.every(e => e.length === 0)) setStep(next.id);
};
```
`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.
Source: `packages/blocks/src/form/use-form.ts`
### 4. `CardSaveBar`/`SubmitButton` passed `form` as a prop or placed outside `form.AppForm`
Wrong:
```tsx
<CardSaveBar form={form} />
```
Correct:
```tsx
<form.AppForm>
<form.SubmitButton>Create user</form.SubmitButton>
</form.AppForm>
```
`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`.
Source: `packages/blocks/src/form/use-form.ts`
### 5. Re-basing a multi-step draft with `form.reset`
Wrong:
```tsx
await saveDraft(form.state.values);
form.reset(form.state.values); // clears isTouched → remounted steps re-seed to empty
```
Correct:
```tsx
await saveDraft(form.state.values);
for (const path of Object.keys(form.state.fieldMeta))
form.setFieldMeta(path, m => ({ ...m, isDirty: false })); // keeps values + isTouched
```
`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.
Source: `packages/blocks/src/form/use-form.ts`
## Testing (jsdom)
- **Drive navigation one step at a time.** `Next` handlers are async (validation runs
before advancing), so fire the click, **wait for the next step to render**, then
continue. Two back-to-back clicks race the async nav and both land on the same step.
```tsx
fireEvent.click(getByTestId('next'));
await screen.findByText('Role'); // step 2 is on screen
fireEvent.click(getByTestId('next'));
```
- **Assert errors without filling every field.** Because a `'submit'`-cause validation
runs the `onChange` validators (see "Validation UX"), clicking Next on an empty step
surfaces each field's `FieldError` — assert on those directly.
- **Field meta survives step changes**, so a test can edit a field on step 1, navigate
to a later step, submit, and still see the step-1 value in the payload.