@aircall/blocks
Version:
Aircall Blocks — higher-level UI compositions built on @aircall/ds
347 lines (278 loc) • 12 kB
Markdown
---
name: aircall-blocks/migrate-dashboard/empty-states
description: >
Migrate @dashboard/library MessageScreen family (ComingSoonScreen,
RestrictedAccessScreen, NotFoundScreen, NoDataScreen, NoSupportScreen,
UnknownErrorScreen) to @aircall/blocks composable empty-state blocks.
Load when a file imports any of those from @dashboard/library.
type: sub-skill
library: aircall-blocks
requires:
- aircall-blocks/setup
- aircall-blocks/migrate-dashboard
sources:
- "aircall/hydra:docs/migration-guides/tractor-to-ds/recipes/empty-states.md"
---
This skill builds on aircall-blocks/migrate-dashboard.
## 1. Variant mapping
| `@dashboard/library` | `@aircall/blocks` root | Old asset | New icon |
| --- | --- | --- | --- |
| `ComingSoonScreen` | `ComingSoonEmptyState` | `aircall-placeholder.svg` | `Sprout` |
| `RestrictedAccessScreen` | `RestrictedAccessEmptyState` | `lock.svg` | `LockKeyhole` |
| `NotFoundScreen` | `NotFoundEmptyState` | `not-found.svg` | `Search` |
| `NoDataScreen` | `NoDataEmptyState` | `no-data.svg` | `Search` |
| `NoSupportScreen` | `NoSupportEmptyState` | `settings.svg` | `Settings` |
| `UnknownErrorScreen` | `UnknownErrorEmptyState` | `warning.svg` | `TriangleAlert` |
Each preset exports four individually importable parts — `*EmptyState` (root), `*Media`, `*Title`, `*Description` — all from `@aircall/blocks`.
## 2. Prop mapping
| `MessageScreen` prop | New equivalent |
| --- | --- |
| `title` | children of `*Title` — omit to use the baked-in design copy |
| `subtitle` (string or element) | children of `*Description` |
| `buttonText` + `onClick` | `EmptyStateButton` inside `EmptyStateActions` |
| `image` | fixed per variant — drop it |
| `data-test` | pass to the root: `<NotFoundEmptyState data-test="…">` |
| `w` / `h` / `backgroundColor` | drop — constrain the parent instead |
## 3. Before / After examples
### 3a. NotFoundScreen → NotFoundEmptyState
**Before (`@dashboard/library`):**
```tsx
import { NotFoundScreen } from '@dashboard/library';
<NotFoundScreen
title="We couldn't find that page"
subtitle="It may have been moved, deleted, or the link may be incorrect."
buttonText="Go to inbox"
onClick={goToInbox}
/>
```
**After (`@aircall/blocks`):**
```tsx
import {
NotFoundEmptyState,
NotFoundMedia,
NotFoundTitle,
NotFoundDescription,
EmptyStateHeader,
EmptyStateActions,
EmptyStateButton
} from '@aircall/blocks';
<NotFoundEmptyState>
<EmptyStateHeader>
<NotFoundMedia />
<NotFoundTitle />
<NotFoundDescription />
</EmptyStateHeader>
<EmptyStateActions>
<EmptyStateButton variant="outline" onClick={goToInbox}>
Go to inbox
</EmptyStateButton>
</EmptyStateActions>
</NotFoundEmptyState>
```
The `*Title` and `*Description` ship the Figma copy as defaults — drop `title`/`subtitle` props to adopt them, or override by passing children.
### 3b. ComingSoonScreen → ComingSoonEmptyState (two buttons)
**Before (`@dashboard/library`):**
```tsx
import { ComingSoonScreen } from '@dashboard/library';
<ComingSoonScreen
title="Coming soon!"
subtitle="You'll be able to access this feature in the near future."
buttonText="Go back"
onClick={goBack}
/>
```
**After (`@aircall/blocks`):**
```tsx
import {
ComingSoonEmptyState,
ComingSoonMedia,
ComingSoonTitle,
ComingSoonDescription,
EmptyStateHeader,
EmptyStateActions,
EmptyStateButton,
EmptyStateExternalLink
} from '@aircall/blocks';
<ComingSoonEmptyState>
<EmptyStateHeader>
<ComingSoonMedia />
<ComingSoonTitle />
<ComingSoonDescription />
</EmptyStateHeader>
<EmptyStateActions>
<EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
</EmptyStateActions>
<EmptyStateExternalLink href={docsUrl} />
</ComingSoonEmptyState>
```
`EmptyStateExternalLink` is a sibling of `EmptyStateActions` (not inside it) so it sits on its own line below the buttons.
### 3c. UnknownErrorScreen → UnknownErrorEmptyState (error state)
**Before (`@dashboard/library`):**
```tsx
import { UnknownErrorScreen } from '@dashboard/library';
<UnknownErrorScreen
title="Something went wrong"
subtitle="Please try again."
buttonText="Try again"
onClick={reload}
/>
```
**After (`@aircall/blocks`):**
```tsx
import {
UnknownErrorEmptyState,
UnknownErrorMedia,
UnknownErrorTitle,
UnknownErrorDescription,
EmptyStateHeader,
EmptyStateActions,
EmptyStateButton
} from '@aircall/blocks';
<UnknownErrorEmptyState>
<EmptyStateHeader>
<UnknownErrorMedia />
<UnknownErrorTitle />
<UnknownErrorDescription />
</EmptyStateHeader>
<EmptyStateActions>
<EmptyStateButton variant="default" onClick={reload}>Try again</EmptyStateButton>
</EmptyStateActions>
</UnknownErrorEmptyState>
```
`UnknownErrorEmptyState` carries `role="alert"` — it announces assertively to screen readers when it mounts. `NoDataEmptyState` carries `role="status"` (polite). The others are static page content with no live region.
### 3d. RestrictedAccessScreen → RestrictedAccessEmptyState (learn more link)
**Before (`@dashboard/library`):**
```tsx
import { RestrictedAccessScreen } from '@dashboard/library';
<RestrictedAccessScreen
title="You don't have access to this page."
subtitle="Contact an admin on your team to get access."
buttonText="Learn more"
onClick={() => window.open(docsUrl)}
/>
```
**After (`@aircall/blocks`):**
```tsx
import {
RestrictedAccessEmptyState,
RestrictedAccessMedia,
RestrictedAccessTitle,
RestrictedAccessDescription,
EmptyStateHeader,
EmptyStateExternalLink
} from '@aircall/blocks';
<RestrictedAccessEmptyState>
<EmptyStateHeader>
<RestrictedAccessMedia />
<RestrictedAccessTitle />
<RestrictedAccessDescription />
</EmptyStateHeader>
<EmptyStateExternalLink href={docsUrl} />
</RestrictedAccessEmptyState>
```
Use `EmptyStateExternalLink` (renders a real `<a target="_blank">`) instead of an `onClick` that calls `window.open`.
### 3e. Custom / one-off empty state (no variant)
When migrating a bespoke `MessageScreen` usage (custom `image` prop), use the generic family directly:
```tsx
import {
EmptyState,
EmptyStateHeader,
EmptyStateMedia,
EmptyStateTitle,
EmptyStateDescription,
EmptyStateActions,
EmptyStateButton
} from '@aircall/blocks';
import { Sparkles } from '@aircall/react-icons';
<EmptyState>
<EmptyStateHeader>
<EmptyStateMedia>
<Sparkles />
</EmptyStateMedia>
<EmptyStateTitle>No integrations yet</EmptyStateTitle>
<EmptyStateDescription>Connect your first integration to get started.</EmptyStateDescription>
</EmptyStateHeader>
<EmptyStateActions>
<EmptyStateButton variant="default" onClick={openCatalog}>Browse catalog</EmptyStateButton>
</EmptyStateActions>
</EmptyState>
```
Icons must come from `@aircall/react-icons`, never from `lucide-react` directly.
## 4. Common Mistakes
### Mistake 1: Passing `title`/`subtitle` as props to the root
```tsx
// WRONG — the root block has no title/subtitle props; they are silently ignored
<NotFoundEmptyState title="Page not found" subtitle="Check the URL." />
// CORRECT — pass custom copy as children of the *Title / *Description parts
<NotFoundEmptyState>
<EmptyStateHeader>
<NotFoundMedia />
<NotFoundTitle>Page not found</NotFoundTitle>
<NotFoundDescription>Check the URL.</NotFoundDescription>
</EmptyStateHeader>
</NotFoundEmptyState>
```
Mechanism: the old `MessageScreen` was a single component that accepted flat string props. The new blocks are a composable family; the root only accepts layout-level props (`data-test`, `role`, `className`, …). Title and description are separate sub-components.
Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyState.Props` extends `React.ComponentProps<typeof Empty>`, which has no `title` or `subtitle`.
---
### Mistake 2: Placing `EmptyStateExternalLink` inside `EmptyStateActions`
```tsx
// WRONG — the link ends up in the same flex row as the buttons
<EmptyStateActions>
<EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
<EmptyStateExternalLink href={docsUrl} />
</EmptyStateActions>
// CORRECT — place it as a sibling after EmptyStateActions
<EmptyStateActions>
<EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
</EmptyStateActions>
<EmptyStateExternalLink href={docsUrl} />
```
Mechanism: `EmptyStateActions` is a flex row (`flex-row flex-wrap justify-center gap-3`). Placing the link inside it makes it appear inline with the buttons at the same level, breaking the stacked design where the link sits on its own line below.
Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyStateActions` renders `EmptyContent` with `flex-row` class; `EmptyStateExternalLink` is meant to be a sibling in the `EmptyState` root's column flow.
---
### Mistake 3: Setting `size` on `EmptyStateButton`
```tsx
// WRONG — size is locked; passing it is a type error and overrides the design spec
<EmptyStateButton variant="outline" size="sm" onClick={goBack}>Go back</EmptyStateButton>
// CORRECT — omit size; only variant is yours to choose
<EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
```
Mechanism: `EmptyStateButtonProps` is `Omit<React.ComponentProps<typeof Button>, 'size'>` — `size` is explicitly removed from the type. The button always renders at `size="default"` to keep all empty states visually consistent regardless of the surrounding UI.
Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyStateButton` wraps `Button` with `size="default"` hardcoded; the `size` key is stripped from the exported props type.
---
### Mistake 4: Carrying over `w`, `h`, or `backgroundColor` from `Gap` props
```tsx
// WRONG — these props do not exist on the new blocks root
<NotFoundEmptyState w="100%" h="100%" backgroundColor="neutral-100" />
// CORRECT — the block fills its container and centers automatically; constrain the parent
<div className="h-full w-full">
<NotFoundEmptyState />
</div>
```
Mechanism: the old `MessageScreen` extended `GapProps` (Tractor) which exposed `w`/`h`/`backgroundColor` to fill the viewport. The new `*EmptyState` roots extend `React.ComponentProps<typeof Empty>` (DS), which is a plain `<div>` with Tailwind layout — these props do not exist.
Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyStateProps` extends `React.ComponentProps<typeof Empty>`; `packages/blocks/src/components/coming-soon.tsx` — `ComingSoonEmptyState` is `ComingSoon.Root` with the same props shape.
---
### Mistake 5: Importing icons from `lucide-react` directly in generic empty states
```tsx
// WRONG — imports lucide-react directly, bypassing Aircall's icon layer
import { Sparkles } from 'lucide-react';
import { EmptyState, EmptyStateHeader, EmptyStateMedia, EmptyStateTitle } from '@aircall/blocks';
<EmptyState>
<EmptyStateHeader>
<EmptyStateMedia><Sparkles /></EmptyStateMedia>
<EmptyStateTitle>No integrations yet</EmptyStateTitle>
</EmptyStateHeader>
</EmptyState>
// CORRECT — always import from @aircall/react-icons
import { Sparkles } from '@aircall/react-icons';
import { EmptyState, EmptyStateHeader, EmptyStateMedia, EmptyStateTitle } from '@aircall/blocks';
<EmptyState>
<EmptyStateHeader>
<EmptyStateMedia><Sparkles /></EmptyStateMedia>
<EmptyStateTitle>No integrations yet</EmptyStateTitle>
</EmptyStateHeader>
</EmptyState>
```
Mechanism: `@aircall/react-icons` re-exports all lucide icons plus Aircall custom icons; importing from `lucide-react` directly bypasses any Aircall overrides and breaks the single icon source-of-truth. All six built-in presets source their icons through `@aircall/react-icons` — custom media slots should follow the same convention.
Source: `packages/blocks/src/components/coming-soon.tsx` — imports `Sprout` from `@aircall/react-icons`, not from `lucide-react`.