@aircall/blocks
Version:
Aircall Blocks — higher-level UI compositions built on @aircall/ds
260 lines (221 loc) • 10.8 kB
Markdown
---
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`.