---
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.
