UNPKG

@aircall/blocks

Version:

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

260 lines (221 loc) 10.8 kB
--- name: aircall-blocks/migrate-dashboard/setting-card description: > Migrate @dashboard/library Paper and PaperForm (settings-page surfaces) to the @aircall/blocks SettingCard family (SettingCard, SettingCardHeader, SettingCardTitle, SettingCardDescription, SettingCardAction, SettingCardContent) plus useForm + CardSaveBar for the save bar. Load when a file imports Paper or PaperForm from @dashboard/library and the surface is a settings section (title / description / optional toggle or action / optional save bar). The errorBoundary is handled OUTSIDE the card — it does not migrate. type: sub-skill library: aircall-blocks requires: - aircall-blocks/setup - aircall-blocks/migrate-dashboard sources: - "aircall/hydra:packages/blocks/src/components/setting-card.tsx" - "aircall/hydra:packages/blocks/src/components/card-save-bar.tsx" --- This skill builds on aircall-blocks/migrate-dashboard. `Paper` / `PaperForm` are the `@dashboard/library` settings-page surfaces (title, subtitle, optional header action, content, optional save-bar footer). Their target is **`SettingCard`** (`@aircall/blocks`) — a thin composition over the DS `Card` that adds the settings level backgrounds and the level-aware title. For a **non-settings** card (a tile or generic container with no title/toggle semantics) use the raw `Card` primitives instead — load `@aircall/blocks#aircall-blocks/migrate-dashboard/card`. ## 1. Component mapping | @dashboard/library | @aircall/blocks / @aircall/ds | | --- | --- | | `Paper` (root surface) | `SettingCard` | | `Paper` `title` prop | `SettingCardTitle` inside `SettingCardHeader` | | `Paper` `subtitle` prop | `SettingCardDescription` inside `SettingCardHeader` | | `Paper` `titleSide` prop | `SettingCardAction` inside `SettingCardHeader` | | `Paper` `children` (body) | `SettingCardContent` | | `Paper` `footer` (save/discard bar) | `useForm` + `CardSaveBar` (form on the outer level) | | `Paper` `footer` (generic) | plain children after `SettingCardContent`, or a DS `CardFooter` | | `Paper` `banner` prop | inline DS `Banner` before `SettingCardHeader` | | `Paper` `disabled` | `SettingCard` `disabled` — dims the card and makes the subtree `inert`, so nested controls need no `disabled` of their own | | `Paper` `disabledText` | app-level — no Figma spec for a lock label; compose one into `SettingCardHeader` if the screen needs it | | `Paper` `fluid` prop | content is already full-width — drop it | | `Paper` `errorBoundary` prop | **stays in the app, OUTSIDE `SettingCard`** — see §3. Does NOT migrate. | | `Paper` `BoxProps` spread | drop — `className` for genuine one-offs only | | `PaperForm` (form + save bar) | `<form>` (outer) + `useForm` + `SettingCard` + `CardSaveBar` | | `PaperForm` `formProps` (`react-final-form`) | `useForm` (TanStack Form) — `defaultValues`, `onSubmit: async ({ value })` | | `PaperForm` `getErrorMessage` / `submitError` | catch in `onSubmit`, store in state, render an `Alert` above the content | | `PaperForm` `shouldBlockNavigation` | app-level (navigation blocker) — not part of blocks | | `PaperForm` `submitButtonText` / `undoButtonText` | `CardSaveBar` `submitLabel` / `resetLabel` | ## 2. Imports ```tsx // blocks — SettingCard family + TanStack Form + save bar import { SettingCard, SettingCardHeader, SettingCardTitle, SettingCardDescription, SettingCardAction, SettingCardContent, useForm, CardSaveBar } from '@aircall/blocks'; // DS primitives used inside (Button, Switch, Alert, Banner, fields …) import { Button, Switch, Alert } from '@aircall/ds'; ``` ## 3. The errorBoundary stays OUTSIDE SettingCard `Paper` wrapped **its children** in `ScopedErrorBoundary` (plus an optional `ErrorContextProvider`) via the `errorBoundary` prop. Those are **app-level** `@dashboard/library` components — `ScopedErrorBoundary` is react-router-aware and reads `ErrorContext`; neither migrates to blocks/ds. `SettingCard` is a presentational layout primitive and deliberately has **no** `errorBoundary` prop. So keep the boundary in the app and wrap the **content** (not the whole card), matching `Paper`'s scope — a content crash still leaves the card title/description visible: ```tsx import { ScopedErrorBoundary, ErrorContextProvider } from '@dashboard/library'; <SettingCard> <SettingCardHeader> <SettingCardTitle>Redaction</SettingCardTitle> <SettingCardDescription>Mask sensitive data in transcripts.</SettingCardDescription> </SettingCardHeader> <SettingCardContent> {/* errorBoundary lives here, around the fallible content — NOT on SettingCard */} <ScopedErrorBoundary message="Couldn't load redaction settings" showRetryButton> <RedactionCategories /> </ScopedErrorBoundary> </SettingCardContent> </SettingCard> ``` If `Paper`'s `errorBoundary` config carried a `context`, keep the `ErrorContextProvider` wrapper too (same nesting as `Paper` did — provider outside the boundary): ```tsx <ErrorContextProvider value={ctx}> <ScopedErrorBoundary {...cfg}>{content}</ScopedErrorBoundary> </ErrorContextProvider> ``` > If the whole section (header included) should be replaced on error, wrap the entire > `<SettingCard>` instead. Default to wrapping only the content — that is what `Paper` did. ## 4. Before / After ### 4a. Basic Paper — settings surface **Before:** ```tsx import { Paper } from '@dashboard/library'; <Paper title="General settings" subtitle="Manage your workspace preferences."> <WorkspacePreferences /> </Paper> ``` **After:** ```tsx import { SettingCard, SettingCardHeader, SettingCardTitle, SettingCardDescription, SettingCardContent } from '@aircall/blocks'; <SettingCard> <SettingCardHeader> <SettingCardTitle>General settings</SettingCardTitle> <SettingCardDescription>Manage your workspace preferences.</SettingCardDescription> </SettingCardHeader> <SettingCardContent> <WorkspacePreferences /> </SettingCardContent> </SettingCard> ``` - `title``SettingCardTitle`, `subtitle``SettingCardDescription`, both inside `SettingCardHeader`. - Body → `SettingCardContent`. Drop the `BoxProps` spread — `SettingCard` owns the surface. ### 4b. Paper with a header action / toggle (titleSide) **Before:** ```tsx <Paper title="Call recording" subtitle="Record inbound and outbound calls." titleSide={<Switch checked={enabled} onChange={setEnabled} />} > {enabled && <RecordingOptions />} </Paper> ``` **After:** ```tsx <SettingCard> <SettingCardHeader> <SettingCardTitle>Call recording</SettingCardTitle> <SettingCardDescription>Record inbound and outbound calls.</SettingCardDescription> <SettingCardAction> <Switch checked={enabled} onCheckedChange={setEnabled} /> </SettingCardAction> </SettingCardHeader> {enabled && ( <SettingCardContent> <RecordingOptions /> </SettingCardContent> )} </SettingCard> ``` - `titleSide``SettingCardAction` (inside `SettingCardHeader`; the header grid pins it top-right). - DS `Switch` uses `onCheckedChange`, not tractor's `onChange`. - **Progressive disclosure is consumer-controlled**: render `SettingCardContent` only when the toggle is on (`{enabled && …}`). `SettingCard` has no internal toggle→content coupling, so gated data/fields never mount while off. ### 4c. Nested settings (levels are automatic) A `SettingCard` rendered inside another card's `SettingCardContent` auto-detects **level 2** (white background + border, 16px medium title); the top card is **level 1** (muted, borderless, 18px bold). No prop needed. For a rare 3rd level, force `level={1}` on the deepest card to reuse the level-1 background (its title stays 16px medium automatically). ### 4d. PaperForm — form with save/discard bar **Before:** ```tsx import { PaperForm } from '@dashboard/library'; <PaperForm title="Profile" subtitle="Update your display name." formProps={{ initialValues: { name: '' }, onSubmit: async (values) => save(values) }} submitButtonText="Save" undoButtonText="Discard" > {({ values }) => <input name="name" defaultValue={values.name} />} </PaperForm> ``` **After:** ```tsx import { SettingCard, SettingCardHeader, SettingCardTitle, SettingCardDescription, SettingCardContent, useForm, FormInputField } from '@aircall/blocks'; import { FieldGroup, Input } from '@aircall/ds'; function ProfileForm() { const form = useForm({ defaultValues: { name: '' }, onSubmit: async ({ value }) => { await save(value); } }); return ( // Form on the OUTER level — SettingCard stays presentational. <form onSubmit={e => { e.preventDefault(); void form.handleSubmit(); }}> <SettingCard> <SettingCardHeader> <SettingCardTitle>Profile</SettingCardTitle> <SettingCardDescription>Update your display name.</SettingCardDescription> </SettingCardHeader> <SettingCardContent> <FieldGroup> <FormInputField form={form} name="name" label="Display name"> {(_field, { inputProps }) => <Input {...inputProps} />} </FormInputField> </FieldGroup> </SettingCardContent> <form.AppForm> <form.CardSaveBar submitLabel="Save" resetLabel="Discard" /> </form.AppForm> </SettingCard> </form> ); } ``` Key changes: - `react-final-form` `formProps` + render-prop → `useForm` from `@aircall/blocks` (TanStack Form): `defaultValues` (not `initialValues`), `onSubmit` receives `{ value }`. - Hand-wired inputs → `Form*Field` wrappers driven by the form (never per-field `useState`). - Put the `<form>` on the **outer level** (wrapping `SettingCard`), and drop **`form.CardSaveBar`** in as the last child of `SettingCard`. It reads `isDirty`/`canSubmit`/`isSubmitting` from context, slides in when dirty, and carries `data-slot="card-footer"` so the card auto-collapses its bottom padding and the bar sits flush. **No `className` is needed on any part** - the card's own layout (row gap + auto `pb-0`) handles both the clean and dirty states. - `submitButtonText`/`undoButtonText``CardSaveBar` `submitLabel`/`resetLabel`. **Submit error (`getErrorMessage` / `submitError`).** No built-in equivalent — catch in `onSubmit`, store it, and render an `Alert` above the content: ```tsx const [submitError, setSubmitError] = useState<string | null>(null); const form = useForm({ defaultValues: { name: '' }, onSubmit: async ({ value }) => { try { await save(value); setSubmitError(null); } catch (e) { setSubmitError(getErrorMessage(e)); } } }); // In JSX, above the fields inside SettingCardContent: {submitError && <Alert variant="error">{submitError}</Alert>} ``` > DS `Alert`'s error variant is `error` (not tractor's `critical`). Full set: `default` / `info` / `success` / `warning` / `error`.