@aircall/blocks
Version:
Aircall Blocks — higher-level UI compositions built on @aircall/ds
337 lines (285 loc) • 10.6 kB
Markdown
---
name: aircall-blocks/migrate-dashboard/card
description: >
Migrate @dashboard/library Paper and PaperForm to @aircall/ds Card primitives
(Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter)
and @aircall/blocks CardSaveBar for save/discard bars. Load when a file imports
Paper or PaperForm from @dashboard/library.
type: sub-skill
library: aircall-blocks
requires:
- aircall-blocks/setup
- aircall-blocks/migrate-dashboard
sources:
- "aircall/hydra:packages/ds/src/index.ts"
---
This skill builds on aircall-blocks/migrate-dashboard.
> **Migrating a settings-page `Paper` / `PaperForm`?** Prefer `SettingCard` — load
> `@aircall/blocks#aircall-blocks/migrate-dashboard/setting-card`. It is purpose-built for
> the title / description / toggle / save-bar layout and gets the level backgrounds and
> level-aware title for free. Use the raw `Card` primitives below only for **non-settings**
> card surfaces (tiles, generic containers with no title/toggle semantics).
## 1. Component mapping
| @dashboard/library | @aircall/ds / @aircall/blocks |
| --- | --- |
| `Paper` (root surface) | `Card` (`@aircall/ds`) |
| `Paper` `title` prop | `CardTitle` inside `CardHeader` (`@aircall/ds`) |
| `Paper` `subtitle` prop | `CardDescription` inside `CardHeader` (`@aircall/ds`) |
| `Paper` `titleSide` prop | `CardAction` inside `CardHeader` (`@aircall/ds`) |
| `Paper` `children` (body) | `CardContent` (`@aircall/ds`) |
| `Paper` `footer` prop (generic) | `CardFooter` (`@aircall/ds`) |
| `Paper` `footer` prop (save/discard bar) | `CardSaveBar` (`@aircall/blocks`) |
| `PaperForm` (form wrapper with save bar) | `Card` + `useForm` + `CardSaveBar` (`@aircall/blocks`) |
| `Paper` `banner` prop | Inline `Banner` before `CardHeader` inside `Card` (`@aircall/ds`) |
| `Paper` `disabled` / `disabledText` props | `className="opacity-50 cursor-not-allowed"` on `Card` + custom label |
| `Paper` `fluid` prop | `className="w-full"` on `CardContent` (default is already full-width) |
`PaperForm` is a compound: it wires a `react-final-form` form, renders `Paper`, and
appends a `SaveBar` footer. The replacement is `Card` with TanStack Form (`useForm` from
`@aircall/blocks`) and `CardSaveBar` as a direct child of `Card` (it is the footer itself).
## 2. Imports
```tsx
// DS primitives — structural card shell
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Banner,
BannerTitle,
BannerDescription
} from '@aircall/ds';
// blocks — TanStack Form + animated save bar
import { useForm, CardSaveBar } from '@aircall/blocks';
```
## 3. Before / After
### 3a. Basic Paper — read-only surface
**Before (`@dashboard/library`):**
```tsx
import { Paper } from '@dashboard/library';
function SettingsSection() {
return (
<Paper
title="General settings"
subtitle="Manage your workspace preferences."
>
<p>Section content here.</p>
</Paper>
);
}
```
**After (`@aircall/ds`):**
```tsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@aircall/ds';
function SettingsSection() {
return (
<Card>
<CardHeader>
<CardTitle>General settings</CardTitle>
<CardDescription>Manage your workspace preferences.</CardDescription>
</CardHeader>
<CardContent>
<p>Section content here.</p>
</CardContent>
</Card>
);
}
```
Key changes:
- `Paper` `title` → `CardTitle` inside `CardHeader`.
- `Paper` `subtitle` → `CardDescription` inside `CardHeader`.
- `Paper` `children` → wrapped in `CardContent`.
- Drop `BoxProps` spread (`maxWidth`, `borderColor`, etc.) — `Card` owns the surface.
### 3b. Paper with a header action (titleSide)
**Before (`@dashboard/library`):**
```tsx
import { Paper } from '@dashboard/library';
import { Button } from '@aircall/tractor';
function IntegrationCard() {
return (
<Paper
title="Integrations"
subtitle="Connect your tools."
titleSide={<Button variant="primary" size="small">Add integration</Button>}
>
<p>Integration list here.</p>
</Paper>
);
}
```
**After (`@aircall/ds`):**
```tsx
import { Button, Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@aircall/ds';
function IntegrationCard() {
return (
<Card>
<CardHeader>
<CardTitle>Integrations</CardTitle>
<CardDescription>Connect your tools.</CardDescription>
<CardAction>
<Button variant="default" size="sm">Add integration</Button>
</CardAction>
</CardHeader>
<CardContent>
<p>Integration list here.</p>
</CardContent>
</Card>
);
}
```
`CardAction` must be inside `CardHeader` — the header grid (`grid-cols-[1fr_auto]`) positions it top-right automatically.
### 3c. PaperForm — form with save/discard bar
**Before (`@dashboard/library`):**
```tsx
import { PaperForm } from '@dashboard/library';
function ProfileForm() {
return (
<PaperForm
title="Profile"
subtitle="Update your display name."
formProps={{
initialValues: { name: '' },
onSubmit: async (values) => { /* submit */ }
}}
submitButtonText="Save"
undoButtonText="Discard"
>
{({ values }) => (
<input name="name" defaultValue={values.name} />
)}
</PaperForm>
);
}
```
**After (`@aircall/blocks`):**
```tsx
import { useForm, CardSaveBar } from '@aircall/blocks';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@aircall/ds';
const form = useForm({
defaultValues: { name: '' },
onSubmit: async ({ value }) => { /* submit */ }
});
function ProfileForm() {
return (
<form.AppForm>
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>Update your display name.</CardDescription>
</CardHeader>
<CardContent>
<form.AppField name="name">
{(field) => (
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
</form.AppField>
</CardContent>
<CardSaveBar submitLabel="Save" resetLabel="Discard" />
</Card>
</form>
</form.AppForm>
);
}
```
Key changes:
- `PaperForm` `formProps` + `react-final-form` render-prop → `useForm` from `@aircall/blocks` (TanStack Form).
- `PaperForm` `footer` (implicit `SaveBar`) → `CardSaveBar` as a **direct child of `Card`** (do NOT wrap it in a `CardFooter` — it already carries `data-slot="card-footer"` itself).
- **No `className` needed on `Card`**: it already has `overflow-hidden` built in, and `CardSaveBar`'s `data-slot="card-footer"` makes the card auto-collapse its bottom padding (`has-data-[slot=card-footer]:pb-0`) so the bar sits flush.
- `CardSaveBar` reads `isDirty` / `canSubmit` / `isSubmitting` from form context automatically — no extra props needed.
**Submit error banner (`getErrorMessage` / `submitError`).** `PaperForm` surfaced a submit failure via `getErrorMessage` + `submitError`. There is no built-in equivalent — catch and store it in `onSubmit`, then render an inline `Alert` above `CardContent`:
```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 CardContent:
{submitError && <Alert variant="error">{submitError}</Alert>}
```
> DS `Alert`'s error variant is `error` (not Tractor's `critical`/`destructive`). Full set: `default` / `info` / `success` / `warning` / `error`.
### 3d. Paper with a generic footer
**Before (`@dashboard/library`):**
```tsx
import { Paper } from '@dashboard/library';
import { Button } from '@aircall/tractor';
function BillingCard() {
return (
<Paper
title="Billing"
footer={
<div style={{ padding: 16 }}>
<Button variant="primary">Upgrade plan</Button>
</div>
}
>
<p>Current plan: Free</p>
</Paper>
);
}
```
**After (`@aircall/ds`):**
```tsx
import { Button, Card, CardContent, CardFooter, CardHeader, CardTitle } from '@aircall/ds';
function BillingCard() {
return (
<Card>
<CardHeader>
<CardTitle>Billing</CardTitle>
</CardHeader>
<CardContent>
<p>Current plan: Free</p>
</CardContent>
<CardFooter>
<Button variant="default">Upgrade plan</Button>
</CardFooter>
</Card>
);
}
```
`CardFooter` applies `border-t bg-muted/50 p-4` by default. For a transparent footer pass `className="bg-transparent border-none"`.
---
## 4. Common mistakes
### Mistake 1 — Spreading BoxProps onto Card
```tsx
// ❌ Wrong — tractor/dashboard BoxProps (maxWidth, borderRadius, backgroundColor…) on Card
<Card maxWidth={1372} borderRadius="sm" backgroundColor="white">
<CardContent>Content</CardContent>
</Card>
// ✅ Correct — Card owns the surface; use className for true one-offs only
<Card className="max-w-[1372px]">
<CardContent>Content</CardContent>
</Card>
```
`Paper` accepted arbitrary `BoxProps` from `@aircall/tractor`. `Card` extends `React.ComponentProps<'div'>` — it accepts `className`, not tractor spacing/color tokens. Spread them and React will warn on unknown HTML attributes.
Source: `packages/ds/src/components/card.tsx`
### Mistake 2 — Wrapping CardSaveBar in a CardFooter (or adding overflow-hidden/pb-0)
```tsx
// ❌ Wrong — CardSaveBar already IS the footer; a CardFooter around it doubles
// the top border and padding, and the className hints are redundant
<Card className="overflow-hidden pb-0">
<CardContent>…</CardContent>
<CardFooter className="p-0">
<CardSaveBar />
</CardFooter>
</Card>
// ✅ Correct — drop CardSaveBar in as a direct child; no wrapper, no className
<Card>
<CardContent>…</CardContent>
<CardSaveBar />
</Card>
```
`CardSaveBar` renders its own `border-t` + padding and carries `data-slot="card-footer"`, so it already behaves as the footer. `Card` has `overflow-hidden` built in (clips the slide-in), and the `data-slot="card-footer"` triggers `has-data-[slot=card-footer]:pb-0`, so the bar sits flush with no manual `overflow-hidden`, `pb-0`, or `CardFooter` wrapper.
Source: `packages/blocks/src/components/card-save-bar.tsx`