UNPKG

@aircall/blocks

Version:

Aircall Blocks — higher-level UI compositions built on @aircall/ds

442 lines (365 loc) 19.5 kB
--- 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.