@aircall/blocks
Version:
Aircall Blocks — higher-level UI compositions built on @aircall/ds
480 lines (409 loc) • 13 kB
Markdown
---
name: aircall-blocks/migrate-dashboard/tile
description: >
Migrate @dashboard/library Tile, TileHeader, TileValue (+ TileBody, TileActions,
TileExtra, TileNumber, TileDuration, TileText) to @aircall/blocks KpiCard
compositions (KpiCard, KpiValue, KpiDescription, KpiValueSkeleton,
KpiDescriptionSkeleton) plus DS CardHeader / CardTitle / CardContent / CardAction.
Load when a file imports Tile, TileHeader, or TileValue from @dashboard/library.
type: sub-skill
library: aircall-blocks
requires:
- aircall-blocks/setup
- aircall-blocks/migrate-dashboard
sources:
- "aircall/hydra:packages/blocks/src/components/kpi.tsx"
- "aircall/hydra:packages/blocks/src/helpers/format-kpi.ts"
- "aircall/hydra:packages/ds/src/components/card.tsx"
---
This skill builds on aircall-blocks/migrate-dashboard.
## 1. Component mapping
| @dashboard/library | Target |
| --- | --- |
| `Tile` (root container) | `KpiCard` (`@aircall/blocks`) — DS `Card` wrapper, `size="sm"` by default |
| `Tile` `disabled` prop | `KpiCard disabled` — applies `opacity-50` + `cursor-not-allowed` and cascades to nested `KpiValue`s |
| `TileHeader` `label` prop | DS `CardTitle` inside DS `CardHeader` |
| `TileHeader` `tooltipTexts` prop | inline DS `Tooltip` / `TooltipTrigger` / `TooltipContent` next to `CardTitle` |
| `TileBody` (metric row wrapper) | DS `CardContent` |
| `TileActions` (top-right action overlay) | DS `CardAction` inside `CardHeader` |
| `TileValue` (number / duration / text / percent) | `KpiValue` (`@aircall/blocks`) — use `kind`, `durationStyle`, `unit`, `onClick`, `active` |
| `TileValue loading` | `KpiValueSkeleton` (and `KpiDescriptionSkeleton` when a comparison line loads) |
| `TileExtra` `secondaryText` prop | `KpiDescription` children |
| `TileExtra` `trend` prop | `KpiDescription` `trend` + `tone` (`up` / `down` / `neutral` × `positive` / `negative` / `neutral`) |
| `TileProvider` (internal context) | removed — `KpiCard` provides disabled context |
`TILE_VALUE` enum and `TileValueSizes` type are internal to `@dashboard/library`; delete them from the import when migrating. Map former types via `KpiValue` `kind`:
| Former `TILE_VALUE` / type | `KpiValue` props |
| --- | --- |
| number | `kind="number"` (default for numeric `value`) |
| percent | `kind="percent"` |
| duration (long / verbose) | `kind="duration"` (`durationStyle="long"` default) |
| duration clock HH:MM | `kind="duration" durationStyle="clock" durationScale="hours"` |
| duration clock MM:SS | `kind="duration" durationStyle="clock" durationScale="minutes"` |
| opaque string | string `value` (or `kind="text"`) |
## 2. Imports
```tsx
// Blocks — KPI composition
import {
KpiCard,
KpiValue,
KpiDescription,
KpiValueSkeleton,
KpiDescriptionSkeleton
} from '@aircall/blocks';
// DS — card slots + optional tooltip / actions
import {
Button,
CardAction,
CardContent,
CardHeader,
CardTitle,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@aircall/ds';
```
## 3. Before / After
### 3a. Basic tile — number value
**Before (`@dashboard/library`):**
```tsx
import { Tile, TileBody, TileHeader, TileValue } from '@dashboard/library';
function AnsweredTile() {
return (
<Tile>
<TileHeader label="Answered" />
<TileBody>
<TileValue value={123} />
</TileBody>
</Tile>
);
}
```
**After (`@aircall/blocks` + `@aircall/ds`):**
```tsx
import { CardContent, CardHeader, CardTitle } from '@aircall/ds';
import { KpiCard, KpiValue } from '@aircall/blocks';
function AnsweredTile() {
return (
<KpiCard className="min-w-36">
<CardHeader>
<CardTitle>Answered</CardTitle>
</CardHeader>
<CardContent>
<KpiValue value={123} kind="number" />
</CardContent>
</KpiCard>
);
}
```
Key changes:
- `Tile` → `KpiCard`. Optional `minWidth` (default 144 px) becomes `className="min-w-36"`.
- `TileHeader label` → `CardTitle` inside `CardHeader`.
- `TileBody` → `CardContent`.
- `TileValue` → `KpiValue` (formatting + typography owned by the block).
### 3b. Tile with loading state
**Before (`@dashboard/library`):**
```tsx
import { Tile, TileBody, TileHeader, TileValue } from '@dashboard/library';
function LoadingTile() {
return (
<Tile>
<TileHeader label="Answered" />
<TileBody>
<TileValue loading />
</TileBody>
</Tile>
);
}
```
**After:**
```tsx
import { CardContent, CardHeader, CardTitle } from '@aircall/ds';
import { KpiCard, KpiValueSkeleton } from '@aircall/blocks';
function LoadingTile() {
return (
<KpiCard className="min-w-36">
<CardHeader>
<CardTitle>Answered</CardTitle>
</CardHeader>
<CardContent>
<KpiValueSkeleton />
</CardContent>
</KpiCard>
);
}
```
When a comparison line also loads, pair with `KpiDescriptionSkeleton` inside
`CardContent className="flex flex-col gap-1"`.
### 3c. Tile with tooltip on header
**Before (`@dashboard/library`):**
```tsx
import { Tile, TileBody, TileHeader, TileValue } from '@dashboard/library';
function InfoTile() {
return (
<Tile>
<TileHeader label="Answered" tooltipTexts={{ info: 'Calls answered in the period' }} />
<TileBody>
<TileValue value={123} />
</TileBody>
</Tile>
);
}
```
**After:**
```tsx
import {
CardContent,
CardHeader,
CardTitle,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@aircall/ds';
import { InformationCircleIcon } from '@aircall/react-icons';
import { KpiCard, KpiValue } from '@aircall/blocks';
function InfoTile() {
return (
<KpiCard className="min-w-36">
<CardHeader>
<CardTitle className="flex items-center gap-1">
Answered
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={<button type="button" className="inline-flex" aria-label="More info" />}
>
<InformationCircleIcon className="h-3 w-3 cursor-default" aria-hidden />
</TooltipTrigger>
<TooltipContent>Calls answered in the period</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardTitle>
</CardHeader>
<CardContent>
<KpiValue value={123} />
</CardContent>
</KpiCard>
);
}
```
### 3d. Tile with actions + clickable value
**Before (`@dashboard/library`):**
```tsx
import { Gap, Tile, TileActions, TileBody, TileHeader, TileValue } from '@dashboard/library';
import { IconButton } from '@aircall/tractor';
import { SettingsFilled, MenuVerticalFilled } from '@aircall/icons';
function ActionTile() {
return (
<Tile>
<TileActions>
<Gap gap="xxxs">
<IconButton component={SettingsFilled} size={16} onClick={() => null} type="button" />
<IconButton component={MenuVerticalFilled} size={16} onClick={() => null} type="button" />
</Gap>
</TileActions>
<TileHeader label="Answered" />
<TileBody>
<TileValue onClick={() => null} value={123} />
</TileBody>
</Tile>
);
}
```
**After:**
```tsx
import { Button, CardAction, CardContent, CardHeader, CardTitle } from '@aircall/ds';
import { EllipsisVertical, Settings } from '@aircall/react-icons';
import { KpiCard, KpiValue } from '@aircall/blocks';
function ActionTile() {
return (
<KpiCard className="min-w-36">
<CardHeader>
<CardTitle>Answered</CardTitle>
<CardAction className="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" aria-label="Settings" onClick={() => null}>
<Settings />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="More options" onClick={() => null}>
<EllipsisVertical />
</Button>
</CardAction>
</CardHeader>
<CardContent>
<KpiValue value={123} onClick={() => null} />
</CardContent>
</KpiCard>
);
}
```
Key changes:
- `TileActions` → `CardAction` inside `CardHeader`.
- `TileValue onClick` → `KpiValue onClick` (dotted underline; `active` for solid underline).
### 3e. Disabled tile
**Before (`@dashboard/library`):**
```tsx
import { Tile, TileBody, TileHeader, TileValue } from '@dashboard/library';
function DisabledTile() {
return (
<Tile disabled>
<TileHeader label="Answered" />
<TileBody>
<TileValue onClick={() => null} value={123} />
</TileBody>
</Tile>
);
}
```
**After:**
```tsx
import { CardContent, CardHeader, CardTitle } from '@aircall/ds';
import { KpiCard, KpiValue } from '@aircall/blocks';
function DisabledTile() {
return (
<KpiCard disabled className="min-w-36">
<CardHeader>
<CardTitle>Answered</CardTitle>
</CardHeader>
<CardContent>
<KpiValue value={123} onClick={() => null} />
</CardContent>
</KpiCard>
);
}
```
`KpiCard disabled` applies `opacity-50` + `cursor-not-allowed` and cascades so nested
`KpiValue` buttons cannot be activated — no need to strip `onClick` or style the value by hand.
### 3f. Comparison / trend line (TileExtra)
**Before:** `TileExtra` with `secondaryText` + `trend`.
**After:**
```tsx
import { CardContent, CardHeader, CardTitle } from '@aircall/ds';
import { KpiCard, KpiDescription, KpiValue } from '@aircall/blocks';
function OutboundCallsTile() {
return (
<KpiCard className="min-w-36">
<CardHeader>
<CardTitle>Outbound calls</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1">
<KpiValue value={87} onClick={() => null} />
<KpiDescription trend="up" tone="positive">
+16.3% vs previous period
</KpiDescription>
</CardContent>
</KpiCard>
);
}
```
`trend` is icon direction only; `tone` is color only — an upward change that is bad uses
`trend="up" tone="negative"`.
### 3g. Duration values
```tsx
{/* Long: "1h 2min 3s" */}
<KpiValue value={3723} kind="duration" />
{/* Clock hours: "01:38 hr/min" */}
<KpiValue value={5880} kind="duration" durationStyle="clock" durationScale="hours" />
{/* Clock minutes: "05:04 min/sec" */}
<KpiValue value={304} kind="duration" durationStyle="clock" durationScale="minutes" />
{/* Duration + unit label */}
<KpiValue value={112} kind="duration" unit="avg" />
```
Do not reimplement `formatSecondsToDuration` in the app — `KpiValue` owns formatting.
---
## 4. Common mistakes
### Mistake 1 — Using raw DS `Card` instead of `KpiCard`
```tsx
// ❌ Wrong — loses disabled cascade and KPI defaults (size="sm", gap)
<Card size="sm">
<CardHeader>
<CardTitle>Answered</CardTitle>
</CardHeader>
<CardContent>
<KpiValue value={123} />
</CardContent>
</Card>
// ✅ Correct
<KpiCard>
<CardHeader>
<CardTitle>Answered</CardTitle>
</CardHeader>
<CardContent>
<KpiValue value={123} />
</CardContent>
</KpiCard>
```
### Mistake 2 — Hand-formatting values or using raw `Skeleton`
```tsx
// ❌ Wrong
<span className="text-3xl font-bold">{formatDuration(123)}</span>
<Skeleton className="h-6 w-18" />
// ✅ Correct
<KpiValue value={123} kind="duration" />
<KpiValueSkeleton />
```
### Mistake 3 — Passing Tile/TileBody flex props to KpiCard / CardContent
```tsx
// ❌ Wrong — tractor FlexProps on the card
<KpiCard minWidth="228px" px="xxs" py="xxs">
<CardContent alignItems="center" gap="xxxs">…</CardContent>
</KpiCard>
// ✅ Correct — Tailwind via className
<KpiCard className="min-w-[228px]">
<CardContent className="flex flex-col gap-1">…</CardContent>
</KpiCard>
```
### Mistake 4 — Placing CardAction outside CardHeader
```tsx
// ❌ Wrong
<KpiCard>
<CardAction>…</CardAction>
<CardHeader>
<CardTitle>Answered</CardTitle>
</CardHeader>
<CardContent>…</CardContent>
</KpiCard>
// ✅ Correct — CardAction must be a child of CardHeader
<KpiCard>
<CardHeader>
<CardTitle>Answered</CardTitle>
<CardAction>…</CardAction>
</CardHeader>
<CardContent>…</CardContent>
</KpiCard>
```
### Mistake 5 — Keeping TILE_VALUE / TileValue imports after migration
```tsx
// ❌ Wrong
import { TILE_VALUE, Tile, TileHeader, TileValue } from '@dashboard/library';
<TileValue type={TILE_VALUE.DURATION} value={123} />
// ✅ Correct
import { KpiCard, KpiValue } from '@aircall/blocks';
import { CardContent, CardHeader, CardTitle } from '@aircall/ds';
<KpiCard>
<CardHeader>
<CardTitle>AHT</CardTitle>
</CardHeader>
<CardContent>
<KpiValue value={123} kind="duration" />
</CardContent>
</KpiCard>
```
### Mistake 6 — Using CardTitle for the metric value instead of the label
```tsx
// ❌ Wrong — CardTitle used for the big number
<KpiCard>
<CardHeader>
<CardTitle>123</CardTitle>
</CardHeader>
</KpiCard>
// ✅ Correct — CardTitle = label; KpiValue = metric
<KpiCard>
<CardHeader>
<CardTitle>Answered</CardTitle>
</CardHeader>
<CardContent>
<KpiValue value={123} />
</CardContent>
</KpiCard>
```