@aircall/blocks
Version:
Aircall Blocks — higher-level UI compositions built on @aircall/ds
405 lines (334 loc) • 14 kB
Markdown
---
name: aircall-blocks/migrate-dashboard/data-table
description: >
Migrate /library LoadMoreTable, LoadMoreStrategy, LoadMoreTableProps,
and LoadMoreScrollProps to the /ds DataTable (TanStack Table-backed).
Load when a file imports LoadMoreTable or LoadMoreStrategy from /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.
## 1. Architecture change
`LoadMoreTable` from `/library` was a self-contained wrapper around Tractor's
`Table` that bolted on a load-more strategy (`button` or `scroll`) and a loading spinner
overlay. Columns were passed as plain descriptor objects (`{ id, label, renderer? }`).
`DataTable` from `/ds` is built on **TanStack Table v8**. There is no strategy
enum — pagination mode is controlled by a `loadingState` string and an `onFetchMore`
callback. Columns are `ColumnDef<TData>[]` (a bound alias exported from `/ds`). The component
handles both infinite scroll (via IntersectionObserver sentinel) and initial-load
skeletons natively.
## 2. Target mapping
| `/library` | `@aircall/ds` |
|---|---|
| `LoadMoreTable` | `DataTable` |
| `LoadMoreStrategy.Button` | No direct equivalent — use a custom "Load more" button rendered outside `DataTable` (see §4b) |
| `LoadMoreStrategy.Scroll` | `onFetchMore` + `hasMore` (built-in sentinel) |
| `loading={true}` (initial) | `loadingState="loading"` |
| `loadingMore={true}` | `loadingState="loadingMore"` |
| `hasMore` | `hasMore` |
| `onLoadMore` | `onFetchMore` |
| `columns[n].id` | `ColumnDef.accessorKey` (or `id` + `accessorFn`) |
| `columns[n].label` | `ColumnDef.header` |
| `columns[n].renderer` | `ColumnDef.cell: ({ row }) => row.original.<field>` |
| `noDataMessage` | `emptyState` (accepts `ReactNode`) |
| `data-test` | `data-test` (still forwarded via `...props`) |
| `buttonText` / `loadingText` | Not a prop — render your own button outside `DataTable` |
| `scrollThreshold` | Fixed 200 px look-ahead in `DataTable` sentinel — not configurable |
| `h` / flex layout props | Use `fillHeight` prop + flex parent (`flex flex-col min-h-0`) |
## 3. Verified DS exports (`packages/ds/src/index.ts`)
```
DataTable, type ColumnDef, type DataTableLoadingState, type OnChangeFn, type SortingState
Empty, EmptyContent, EmptyTitle, EmptyDescription
Spinner
```
`/ds` **exposes** the TanStack Table types you author — `ColumnDef`, `SortingState`, `OnChangeFn` — as bound aliases, so **import them from `@aircall/ds`**, not `/react-table`. The react-table runtime ships as a regular `@aircall/ds` dependency (auto-installed; `DataTable` owns the table instance internally), so there is nothing to install and no version to keep in sync.
## 4. Before / After
### 4a. Scroll strategy (infinite scroll)
**Before (`/library`):**
```tsx
import { useState } from 'react';
import { LoadMoreTable, LoadMoreStrategy } from '@dashboard/library';
type Row = { id: string; name: string; email: string };
const columns = [
{ id: 'name', label: 'Name' },
{ id: 'email', label: 'Email', renderer: ({ email }: Row) => <a href={`mailto:${email}`}>{email}</a> },
];
function PeopleTable() {
const [data, setData] = useState<Row[]>(initialPage);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const handleLoadMore = async () => {
setLoadingMore(true);
const next = await fetchNextPage();
setData(prev => [...prev, ...next.rows]);
setHasMore(next.hasMore);
setLoadingMore(false);
};
return (
<LoadMoreTable
data={data}
columns={columns}
hasMore={hasMore}
loading={loading}
loadingMore={loadingMore}
h="420px"
onLoadMore={handleLoadMore}
strategy={LoadMoreStrategy.Scroll}
noDataMessage="No people found."
data-test="people-table"
/>
);
}
```
**After (`/ds`):**
```tsx
import { useState } from 'react';
import { DataTable, type DataTableLoadingState, Empty, EmptyContent, EmptyTitle } from '@aircall/ds';
import type { ColumnDef } from '@aircall/ds';
type Row = { id: string; name: string; email: string };
const columns: ColumnDef<Row>[] = [
{ accessorKey: 'name', header: 'Name' },
{
accessorKey: 'email',
header: 'Email',
cell: ({ row }) => <a href={`mailto:${row.original.email}`}>{row.original.email}</a>,
},
];
function PeopleTable() {
const [data, setData] = useState<Row[]>(initialPage);
const [loadingState, setLoadingState] = useState<DataTableLoadingState>('idle');
const [hasMore, setHasMore] = useState(true);
const handleFetchMore = async () => {
setLoadingState('loadingMore');
const next = await fetchNextPage();
setData(prev => [...prev, ...next.rows]);
setHasMore(next.hasMore);
setLoadingState('idle');
};
return (
<div className="flex flex-col min-h-0" style={{ height: 420 }}>
<DataTable
data={data}
columns={columns}
getRowId={row => row.id}
loadingState={loadingState}
hasMore={hasMore}
onFetchMore={handleFetchMore}
fillHeight
emptyState={
<Empty>
<EmptyContent>
<EmptyTitle>No people found</EmptyTitle>
</EmptyContent>
</Empty>
}
data-test="people-table"
/>
</div>
);
}
```
Key changes:
- `loading` / `loadingMore` booleans collapse into a single `loadingState` discriminated string
- `strategy={LoadMoreStrategy.Scroll}` + `onLoadMore` → `onFetchMore` + `hasMore` (sentinel is built-in)
- Column descriptors (`{ id, label, renderer }`) → `ColumnDef<TData>[]` with `accessorKey`, `header`, and `cell`
- `h="420px"` (Tractor Flex prop) → wrap in a flex container with a bounded height + pass `fillHeight`
- `noDataMessage` string → `emptyState` ReactNode
> **Row virtualization (large accumulated lists).** For an infinite-scroll table that
> piles up many pages, also pass `virtualizeRows` so only visible rows are in the DOM
> (requires `fillHeight`). **Tradeoff:** `virtualizeRows` implies `fixedLayout`
> (`table-layout: fixed`) — every column then takes its `size`, and columns with no
> `size` fall back to TanStack's **150px** default. A table that relied on content-based
> auto widths (e.g. a narrow icon column) will visibly snap to 150px columns. Add an
> explicit `size` to each `ColumnDef` to keep the intended widths, and check them in the
> browser (`tsc`/tests won't catch a width regression). See
> `/ds#aircall-ds/migrate-tractor/data-table` §8 for details.
### 4b. Button strategy
`LoadMoreStrategy.Button` has no direct prop equivalent in `DataTable`. Render the
"Load more" button below the table instead:
**Before (`/library`):**
```tsx
import { LoadMoreTable, LoadMoreStrategy } from '@dashboard/library';
<LoadMoreTable
data={data}
columns={columns}
hasMore={hasMore}
loading={loading}
loadingMore={loadingMore}
buttonText="Load more"
loadingText="Loading…"
onLoadMore={handleLoadMore}
strategy={LoadMoreStrategy.Button}
/>
```
**After (`/ds`):**
```tsx
import { DataTable, type DataTableLoadingState, Spinner, Button } from '@aircall/ds';
import type { ColumnDef } from '@aircall/ds';
// Button is from @aircall/ds; import it alongside DataTable.
<div className="flex flex-col gap-4">
<DataTable
data={data}
columns={columns}
loadingState={loadingState === 'loading' ? 'loading' : 'idle'}
/>
{hasMore && (
<div className="flex justify-center">
<Button
variant="outline"
onClick={handleLoadMore}
disabled={loadingState === 'loadingMore'}
>
{loadingState === 'loadingMore' ? <Spinner /> : 'Load more'}
</Button>
</div>
)}
</div>
```
Key changes:
- `buttonText` / `loadingText` / `loadingMore` are not props on `DataTable` — drive the
button's label and disabled state from your own `loadingState` variable
- `Spinner` and `Button` are imported from `/ds` (same package as `DataTable`)
### 4c. Initial loading state
**Before (`/library`):**
```tsx
import { LoadMoreTable } from '@dashboard/library';
<LoadMoreTable
data={data ?? null}
columns={columns}
hasMore={false}
loading={isLoading}
onLoadMore={() => {}}
/>
```
**After (`/ds`):**
```tsx
import { DataTable } from '@aircall/ds';
import type { ColumnDef } from '@aircall/ds';
<DataTable
data={data ?? []}
columns={columns}
loadingState={isLoading ? 'loading' : 'idle'}
/>
```
Key changes:
- `data` is always `TData[]`; pass `[]` while loading (skeleton rows replace the empty body)
- `loading={true}` → `loadingState="loading"` shows skeleton rows automatically
- No `hasMore` or `onLoadMore` needed for a non-paginated table
## 5. Common mistakes
### Mistake 1 — Passing `strategy` or `onLoadMore` to `DataTable`
```tsx
// Wrong — DataTable has no strategy or onLoadMore prop; they are silently ignored
import { DataTable } from '@aircall/ds';
<DataTable
data={data}
columns={columns}
strategy="scroll"
onLoadMore={handleLoadMore}
/>
// Correct — use onFetchMore for scroll-based pagination
<DataTable
data={data}
columns={columns}
hasMore={hasMore}
onFetchMore={handleFetchMore}
/>
```
`LoadMoreStrategy` and `onLoadMore` are `/library` concepts. `DataTable` exposes
`onFetchMore` (triggers when the IntersectionObserver sentinel enters the viewport) and
`hasMore` (hides the sentinel when false). There is no button-strategy prop — render your
own button outside the table for that UX pattern.
Source: `packages/ds/src/components/data-table.tsx`
### Mistake 2 — Keeping `loading` / `loadingMore` as separate boolean props
```tsx
// Wrong — DataTable has no loading or loadingMore props; they are silently ignored
<DataTable
data={data}
columns={columns}
loading={isLoading}
loadingMore={isFetchingMore}
/>
// Correct — collapse both booleans into a single loadingState string
<DataTable
data={data}
columns={columns}
loadingState={isLoading ? 'loading' : isFetchingMore ? 'loadingMore' : 'idle'}
/>
```
DS replaced the dual-boolean pattern with a discriminated `loadingState` string so each
async phase renders the right UX: `'loading'` shows skeleton rows (initial load), while
`'loadingMore'` appends a spinner row at the bottom (pagination). Passing `loading` or
`loadingMore` as props is silently ignored and the table will not show any loading indicator.
Source: `packages/ds/src/components/data-table.tsx`
### Mistake 3 — Using column descriptor objects instead of `ColumnDef`
```tsx
// Wrong — { id, label, renderer } is the @dashboard/library TableColumn shape;
// DataTable does not read these fields
import { DataTable } from '@aircall/ds';
const columns = [
{ id: 'name', label: 'Name' },
{ id: 'email', label: 'Email', renderer: ({ email }) => <a href={`mailto:${email}`}>{email}</a> },
];
<DataTable data={data} columns={columns} />
// Correct — use ColumnDef from @aircall/ds
import type { ColumnDef } from '@aircall/ds';
type Row = { id: string; name: string; email: string };
const columns: ColumnDef<Row>[] = [
{ accessorKey: 'name', header: 'Name' },
{
accessorKey: 'email',
header: 'Email',
cell: ({ row }) => <a href={`mailto:${row.original.email}`}>{row.original.email}</a>,
},
];
```
`DataTable` is built on TanStack Table v8. It reads `ColumnDef` fields (`accessorKey`,
`header`, `cell`, `enableSorting`, etc.) — not `id`, `label`, or `renderer`. Passing the
old descriptor shape compiles (TypeScript can't narrow the column union tightly), but
all columns will render blank cells because no accessor is registered.
Source: `packages/ds/src/components/data-table.tsx`
### Mistake 4 — Passing `noDataMessage` instead of `emptyState`
```tsx
// Wrong — noDataMessage is a @dashboard/library / Tractor prop; DataTable ignores it
<DataTable data={[]} columns={columns} noDataMessage="No records found." />
// Correct — emptyState accepts any ReactNode; use the Empty compound for rich states
import { DataTable, Empty, EmptyContent, EmptyTitle, EmptyDescription } from '@aircall/ds';
<DataTable
data={[]}
columns={columns}
emptyState={
<Empty>
<EmptyContent>
<EmptyTitle>No records found</EmptyTitle>
<EmptyDescription>Try adjusting your filters.</EmptyDescription>
</EmptyContent>
</Empty>
}
/>
```
`DataTable` requires an `emptyState` prop; `noDataMessage` is not defined on `DataTable`
and is silently ignored — the table body renders nothing when data is empty. For plain
text wrap it in `<p className="text-sm text-muted-foreground">`. For a full empty state
with an icon and call-to-action, compose with the `Empty` family from `/ds`.
Source: `packages/ds/src/components/data-table.tsx`
### Mistake 5 — Passing height as a Tractor prop instead of using `fillHeight`
```tsx
// Wrong — h / w are Tractor Flex props; DataTable does not accept them
<DataTable data={data} columns={columns} h="420px" />
// Correct — constrain the parent height with a flex container and pass fillHeight
<div className="flex flex-col min-h-0" style={{ height: 420 }}>
<DataTable data={data} columns={columns} fillHeight />
</div>
```
`LoadMoreTable` forwarded layout props (`h`, `w`, `flex`, etc.) to the Tractor `Flex`
wrapper it rendered internally. `DataTable` has no such props. Use `fillHeight` to pin
the header and scroll the body internally — the parent must provide a bounded height via
a flex column (`flex flex-col` + `min-h-0`). Without a bounded parent, `fillHeight` has
no effect and the table grows to its natural height.
Source: `packages/ds/src/components/data-table.tsx`