@aircall/blocks 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ ---
2
+ name: aircall-blocks/migrate-dashboard/dashboard-page
3
+ description: >
4
+ Migrate a screen's page shell to @aircall/blocks — DashboardPage (standard in-app page:
5
+ sidebar + header + optional tabs + content) vs DashboardStandalonePage (full-page focused
6
+ flow with no sidebar, e.g. Campaign Creation, Add Contacts). Load when migrating a screen's
7
+ outer layout, a full-page wizard/creation flow, or a page header+content scaffold.
8
+ type: sub-skill
9
+ library: aircall-blocks
10
+ library_version: "0.5.1"
11
+ requires:
12
+ - aircall-blocks/setup
13
+ - aircall-blocks/migrate-dashboard
14
+ sources:
15
+ - "aircall/hydra:packages/blocks/src/components/dashboard-page.tsx"
16
+ - "aircall/hydra:packages/blocks/src/components/dashboard-standalone-page.tsx"
17
+ ---
18
+
19
+ This skill builds on aircall-blocks/migrate-dashboard.
20
+
21
+ ## 1. Which shell?
22
+
23
+ | Screen kind | Use | Why |
24
+ |---|---|---|
25
+ | Standard in-app page (sidebar + header + optional tabs + content) | `DashboardPage` | Lives inside the dashboard chrome |
26
+ | Full-page focused flow — creation wizard, Add Contacts, standalone forms (no sidebar) | `DashboardStandalonePage` | Takes over the viewport; header is just title + actions |
27
+
28
+ Campaign Creation (`CampaignWizardPage`) and Add Contacts (`AddContactsPage`) are **standalone** flows → `DashboardStandalonePage`, with the multi-step form inside (see `@aircall/blocks#aircall-blocks/migrate-dashboard/form-wizard`).
29
+
30
+ ## 2. DashboardStandalonePage (full-page flows)
31
+
32
+ ```tsx
33
+ import {
34
+ DashboardStandalonePage,
35
+ DashboardStandalonePageHeader,
36
+ DashboardStandalonePageTitle,
37
+ DashboardStandalonePageActions,
38
+ DashboardStandalonePageAction,
39
+ DashboardStandalonePageContent
40
+ } from '@aircall/blocks';
41
+
42
+ <DashboardStandalonePage>
43
+ <DashboardStandalonePageHeader>
44
+ <DashboardStandalonePageTitle>Create campaign</DashboardStandalonePageTitle>
45
+ <DashboardStandalonePageActions>
46
+ <DashboardStandalonePageAction onClick={onCancel}>Cancel</DashboardStandalonePageAction>
47
+ </DashboardStandalonePageActions>
48
+ </DashboardStandalonePageHeader>
49
+ <DashboardStandalonePageContent>
50
+ {/* a centered, width-capped Card — see below */}
51
+ </DashboardStandalonePageContent>
52
+ </DashboardStandalonePage>
53
+ ```
54
+
55
+ ### What goes inside `DashboardStandalonePageContent` — a centered, width-capped Card
56
+
57
+ `DashboardStandalonePageContent` is a full-bleed scroll area; it does **not** constrain
58
+ width. The actual content is a centered container (`mx-auto` + a `max-w-*` cap) wrapping
59
+ one or more `Card`s — the consistent pattern across the `DashboardStandalonePage` stories.
60
+ Don't let content run full-width, and don't reach for `Paper`/raw page wrappers.
61
+
62
+ ```tsx
63
+ import { Card, CardHeader, CardContent, CardFooter } from '@aircall/ds';
64
+
65
+ <DashboardStandalonePageContent>
66
+ {/* single-column form / wizard */}
67
+ <div className="mx-auto max-w-2xl pt-4">
68
+ <Card>
69
+ <CardHeader>{/* title, or Stepper progress for a wizard */}</CardHeader>
70
+ <CardContent>{/* fields */}</CardContent>
71
+ <CardFooter className="justify-end gap-2">{/* Back / Next / Submit */}</CardFooter>
72
+ </Card>
73
+ </div>
74
+ </DashboardStandalonePageContent>
75
+ ```
76
+
77
+ Width cap by layout:
78
+ - **Single-column form / wizard** → `mx-auto max-w-2xl pt-4` around one `Card` (CampaignWizard pattern).
79
+ - **Wide / two-column** (form + summary aside) → `mx-auto flex max-w-7xl flex-col gap-6 pt-8 lg:flex-row`, a `Card` per column.
80
+
81
+ ## 3. DashboardPage (standard in-app page)
82
+
83
+ ```tsx
84
+ import {
85
+ DashboardPage,
86
+ DashboardPageContent,
87
+ DashboardPageMain,
88
+ DashboardPageTabs,
89
+ DashboardPageHeader,
90
+ DashboardPageHeaderTitle,
91
+ DashboardPageHeaderActions,
92
+ DashboardPageHeaderAction,
93
+ DashboardSidebarProvider
94
+ } from '@aircall/blocks';
95
+
96
+ <DashboardPage>
97
+ <DashboardSidebarProvider defaultOpen>
98
+ {/* your <DashboardSidebar> */}
99
+ <DashboardPageContent>
100
+ <DashboardPageHeader>
101
+ <DashboardPageHeaderTitle size="lg">Campaigns</DashboardPageHeaderTitle>
102
+ <DashboardPageHeaderActions>
103
+ <DashboardPageHeaderAction variant="default">New campaign</DashboardPageHeaderAction>
104
+ </DashboardPageHeaderActions>
105
+ </DashboardPageHeader>
106
+ {/* optional: <DashboardPageTabs> here, as a sibling of the header */}
107
+ <DashboardPageMain>{/* page content */}</DashboardPageMain>
108
+ </DashboardPageContent>
109
+ </DashboardSidebarProvider>
110
+ </DashboardPage>
111
+ ```
112
+
113
+ `DashboardPageBanner` is available for a page-level banner. The page header itself migrates per `@aircall/blocks#aircall-blocks/migrate-dashboard/page-header`.
114
+
115
+ ### Page-level notices (beta/feature/trial banners) → `NotificationQueue`
116
+
117
+ For app/page-level notices (e.g. a "beta feature" banner), prefer the ds **`NotificationQueue`** over an inline `Banner` — it stacks, dedupes, and orders by priority, and renders wherever you place a `NotificationSlot` (e.g. at the very top of the page, above the header). Mechanism (from `packages/ds/src/components/notification-queue.tsx`):
118
+
119
+ ```tsx
120
+ import { NotificationQueueProvider, NotificationSlot, useNotification } from '@aircall/ds';
121
+
122
+ // 1. Wrap the screen (or app) once:
123
+ <NotificationQueueProvider>
124
+ <NotificationSlot slot="page" /> {/* render output at the top of the page */}
125
+ {/* …DashboardPage / DashboardStandalonePage… */}
126
+ </NotificationQueueProvider>
127
+
128
+ // 2. Declare the notice (lifecycle-bound) from any descendant:
129
+ useNotification({
130
+ id: 'campaigns-beta',
131
+ slot: 'page',
132
+ priority: 'promotional', // 'error' | 'warning' | 'success' | 'info' | 'promotional'
133
+ render: () => <>You're using a beta feature 🚧 …</>
134
+ });
135
+ ```
136
+
137
+ Use `useNotification(...)` for persistent/conditional notices (beta banner); `useNotificationQueue().push(...)` for imperative, event-driven ones. `error` priority renders without a dismiss CTA.
138
+
139
+ Source: `packages/ds/src/components/notification-queue.tsx`
140
+
141
+ ## 4. Common Mistakes
142
+
143
+ ### HIGH — Using `DashboardPage` (with sidebar) for a full-page creation flow
144
+
145
+ Wrong:
146
+ ```tsx
147
+ <DashboardPage>
148
+ <DashboardSidebarProvider>{/* sidebar */}
149
+ <DashboardPageContent>{/* Campaign Creation wizard */}</DashboardPageContent>
150
+ </DashboardSidebarProvider>
151
+ </DashboardPage>
152
+ ```
153
+
154
+ Correct:
155
+ ```tsx
156
+ <DashboardStandalonePage>
157
+ <DashboardStandalonePageHeader>...</DashboardStandalonePageHeader>
158
+ <DashboardStandalonePageContent>{/* wizard */}</DashboardStandalonePageContent>
159
+ </DashboardStandalonePage>
160
+ ```
161
+
162
+ Focused flows (creation, Add Contacts) take over the viewport and have no sidebar. Wrapping them in `DashboardPage` renders the dashboard chrome/sidebar around the flow and needlessly pulls in `DashboardSidebarProvider`.
163
+
164
+ Source: `packages/blocks/src/components/dashboard-standalone-page.tsx`
165
+
166
+ ### HIGH — Putting the header outside `DashboardPageContent`
167
+
168
+ Wrong:
169
+ ```tsx
170
+ <DashboardPage>
171
+ <DashboardPageHeader>...</DashboardPageHeader> {/* direct child of DashboardPage */}
172
+ <DashboardPageContent>...</DashboardPageContent>
173
+ </DashboardPage>
174
+ ```
175
+
176
+ Correct: the header lives **inside** `DashboardPageContent`, alongside `DashboardPageMain`.
177
+
178
+ `DashboardPageContent` owns the header/main grid (and a `:has()` rule manages the header border). A header outside it loses that layout and the border handling.
179
+
180
+ Source: `packages/blocks/src/components/dashboard-page.tsx`
181
+
182
+ ### MEDIUM — Nesting `DashboardPageTabs` inside the header
183
+
184
+ Wrong: `<DashboardPageHeader><DashboardPageTabs/></DashboardPageHeader>`.
185
+
186
+ Correct: `DashboardPageTabs` is a **sibling** of `DashboardPageHeader` inside `DashboardPageContent`.
187
+
188
+ `DashboardPageTabs` provides its own `border-b`; nesting it in the header double-borders and misplaces the tab strip.
189
+
190
+ Source: `packages/blocks/src/components/dashboard-page.tsx`
191
+
192
+ ### MEDIUM — Re-creating page chrome with raw `div`/`Paper` instead of the shell
193
+
194
+ Wrong: a hand-rolled `<div className="page">` + a `Paper`/`Card` page header.
195
+
196
+ Correct: use the `DashboardPage` / `DashboardStandalonePage` shell so spacing, borders, and the header/content grid match the rest of the dashboard.
197
+
198
+ Source: `packages/blocks/src/components/dashboard-page.tsx`
@@ -0,0 +1,395 @@
1
+ ---
2
+ name: aircall-blocks/migrate-dashboard/data-table
3
+ description: >
4
+ Migrate @dashboard/library LoadMoreTable, LoadMoreStrategy, LoadMoreTableProps,
5
+ and LoadMoreScrollProps to the @aircall/ds DataTable (TanStack Table-backed).
6
+ Load when a file imports LoadMoreTable or LoadMoreStrategy from @dashboard/library.
7
+ type: sub-skill
8
+ library: aircall-blocks
9
+ library_version: "0.5.1"
10
+ requires:
11
+ - aircall-blocks/setup
12
+ - aircall-blocks/migrate-dashboard
13
+ sources:
14
+ - "aircall/hydra:packages/ds/src/index.ts"
15
+ ---
16
+
17
+ This skill builds on aircall-blocks/migrate-dashboard.
18
+
19
+ ## 1. Architecture change
20
+
21
+ `LoadMoreTable` from `@dashboard/library` was a self-contained wrapper around Tractor's
22
+ `Table` that bolted on a load-more strategy (`button` or `scroll`) and a loading spinner
23
+ overlay. Columns were passed as plain descriptor objects (`{ id, label, renderer? }`).
24
+
25
+ `DataTable` from `@aircall/ds` is built on **TanStack Table v8**. There is no strategy
26
+ enum — pagination mode is controlled by a `loadingState` string and an `onFetchMore`
27
+ callback. Columns are `ColumnDef<TData>[]` from `@tanstack/react-table`. The component
28
+ handles both infinite scroll (via IntersectionObserver sentinel) and initial-load
29
+ skeletons natively.
30
+
31
+ ## 2. Target mapping
32
+
33
+ | `@dashboard/library` | `@aircall/ds` |
34
+ |---|---|
35
+ | `LoadMoreTable` | `DataTable` |
36
+ | `LoadMoreStrategy.Button` | No direct equivalent — use a custom "Load more" button rendered outside `DataTable` (see §4b) |
37
+ | `LoadMoreStrategy.Scroll` | `onFetchMore` + `hasMore` (built-in sentinel) |
38
+ | `loading={true}` (initial) | `loadingState="loading"` |
39
+ | `loadingMore={true}` | `loadingState="loadingMore"` |
40
+ | `hasMore` | `hasMore` |
41
+ | `onLoadMore` | `onFetchMore` |
42
+ | `columns[n].id` | `ColumnDef.accessorKey` (or `id` + `accessorFn`) |
43
+ | `columns[n].label` | `ColumnDef.header` |
44
+ | `columns[n].renderer` | `ColumnDef.cell: ({ row }) => row.original.<field>` |
45
+ | `noDataMessage` | `emptyState` (accepts `ReactNode`) |
46
+ | `data-test` | `data-test` (still forwarded via `...props`) |
47
+ | `buttonText` / `loadingText` | Not a prop — render your own button outside `DataTable` |
48
+ | `scrollThreshold` | Fixed 200 px look-ahead in `DataTable` sentinel — not configurable |
49
+ | `h` / flex layout props | Use `fillHeight` prop + flex parent (`flex flex-col min-h-0`) |
50
+
51
+ ## 3. Verified DS exports (`packages/ds/src/index.ts`)
52
+
53
+ ```
54
+ DataTable, type DataTableLoadingState
55
+ Empty, EmptyContent, EmptyTitle, EmptyDescription
56
+ Spinner
57
+ ```
58
+
59
+ `@aircall/ds` **re-exports** the TanStack Table types you author — `ColumnDef`, `SortingState`, `RowSelectionState`, `OnChangeFn` — so **import them from `@aircall/ds`**, not `@tanstack/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.
60
+
61
+ ## 4. Before / After
62
+
63
+ ### 4a. Scroll strategy (infinite scroll)
64
+
65
+ **Before (`@dashboard/library`):**
66
+ ```tsx
67
+ import { useState } from 'react';
68
+ import { LoadMoreTable, LoadMoreStrategy } from '@dashboard/library';
69
+
70
+ type Row = { id: string; name: string; email: string };
71
+
72
+ const columns = [
73
+ { id: 'name', label: 'Name' },
74
+ { id: 'email', label: 'Email', renderer: ({ email }: Row) => <a href={`mailto:${email}`}>{email}</a> },
75
+ ];
76
+
77
+ function PeopleTable() {
78
+ const [data, setData] = useState<Row[]>(initialPage);
79
+ const [loading, setLoading] = useState(false);
80
+ const [loadingMore, setLoadingMore] = useState(false);
81
+ const [hasMore, setHasMore] = useState(true);
82
+
83
+ const handleLoadMore = async () => {
84
+ setLoadingMore(true);
85
+ const next = await fetchNextPage();
86
+ setData(prev => [...prev, ...next.rows]);
87
+ setHasMore(next.hasMore);
88
+ setLoadingMore(false);
89
+ };
90
+
91
+ return (
92
+ <LoadMoreTable
93
+ data={data}
94
+ columns={columns}
95
+ hasMore={hasMore}
96
+ loading={loading}
97
+ loadingMore={loadingMore}
98
+ h="420px"
99
+ onLoadMore={handleLoadMore}
100
+ strategy={LoadMoreStrategy.Scroll}
101
+ noDataMessage="No people found."
102
+ data-test="people-table"
103
+ />
104
+ );
105
+ }
106
+ ```
107
+
108
+ **After (`@aircall/ds`):**
109
+ ```tsx
110
+ import { useState } from 'react';
111
+ import { DataTable, type DataTableLoadingState, Empty, EmptyContent, EmptyTitle } from '@aircall/ds';
112
+ import type { ColumnDef } from '@aircall/ds';
113
+
114
+ type Row = { id: string; name: string; email: string };
115
+
116
+ const columns: ColumnDef<Row>[] = [
117
+ { accessorKey: 'name', header: 'Name' },
118
+ {
119
+ accessorKey: 'email',
120
+ header: 'Email',
121
+ cell: ({ row }) => <a href={`mailto:${row.original.email}`}>{row.original.email}</a>,
122
+ },
123
+ ];
124
+
125
+ function PeopleTable() {
126
+ const [data, setData] = useState<Row[]>(initialPage);
127
+ const [loadingState, setLoadingState] = useState<DataTableLoadingState>('idle');
128
+ const [hasMore, setHasMore] = useState(true);
129
+
130
+ const handleFetchMore = async () => {
131
+ setLoadingState('loadingMore');
132
+ const next = await fetchNextPage();
133
+ setData(prev => [...prev, ...next.rows]);
134
+ setHasMore(next.hasMore);
135
+ setLoadingState('idle');
136
+ };
137
+
138
+ return (
139
+ <div className="flex flex-col min-h-0" style={{ height: 420 }}>
140
+ <DataTable
141
+ data={data}
142
+ columns={columns}
143
+ getRowId={row => row.id}
144
+ loadingState={loadingState}
145
+ hasMore={hasMore}
146
+ onFetchMore={handleFetchMore}
147
+ fillHeight
148
+ emptyState={
149
+ <Empty>
150
+ <EmptyContent>
151
+ <EmptyTitle>No people found</EmptyTitle>
152
+ </EmptyContent>
153
+ </Empty>
154
+ }
155
+ data-test="people-table"
156
+ />
157
+ </div>
158
+ );
159
+ }
160
+ ```
161
+
162
+ Key changes:
163
+ - `loading` / `loadingMore` booleans collapse into a single `loadingState` discriminated string
164
+ - `strategy={LoadMoreStrategy.Scroll}` + `onLoadMore` → `onFetchMore` + `hasMore` (sentinel is built-in)
165
+ - Column descriptors (`{ id, label, renderer }`) → `ColumnDef<TData>[]` with `accessorKey`, `header`, and `cell`
166
+ - `h="420px"` (Tractor Flex prop) → wrap in a flex container with a bounded height + pass `fillHeight`
167
+ - `noDataMessage` string → `emptyState` ReactNode
168
+
169
+ ### 4b. Button strategy
170
+
171
+ `LoadMoreStrategy.Button` has no direct prop equivalent in `DataTable`. Render the
172
+ "Load more" button below the table instead:
173
+
174
+ **Before (`@dashboard/library`):**
175
+ ```tsx
176
+ import { LoadMoreTable, LoadMoreStrategy } from '@dashboard/library';
177
+
178
+ <LoadMoreTable
179
+ data={data}
180
+ columns={columns}
181
+ hasMore={hasMore}
182
+ loading={loading}
183
+ loadingMore={loadingMore}
184
+ buttonText="Load more"
185
+ loadingText="Loading…"
186
+ onLoadMore={handleLoadMore}
187
+ strategy={LoadMoreStrategy.Button}
188
+ />
189
+ ```
190
+
191
+ **After (`@aircall/ds`):**
192
+ ```tsx
193
+ import { DataTable, type DataTableLoadingState, Spinner, Button } from '@aircall/ds';
194
+ import type { ColumnDef } from '@aircall/ds';
195
+
196
+ // Button is from @aircall/ds; import it alongside DataTable.
197
+ <div className="flex flex-col gap-4">
198
+ <DataTable
199
+ data={data}
200
+ columns={columns}
201
+ loadingState={loadingState === 'loading' ? 'loading' : 'idle'}
202
+ />
203
+ {hasMore && (
204
+ <div className="flex justify-center">
205
+ <Button
206
+ variant="outline"
207
+ onClick={handleLoadMore}
208
+ disabled={loadingState === 'loadingMore'}
209
+ >
210
+ {loadingState === 'loadingMore' ? <Spinner /> : 'Load more'}
211
+ </Button>
212
+ </div>
213
+ )}
214
+ </div>
215
+ ```
216
+
217
+ Key changes:
218
+ - `buttonText` / `loadingText` / `loadingMore` are not props on `DataTable` — drive the
219
+ button's label and disabled state from your own `loadingState` variable
220
+ - `Spinner` and `Button` are imported from `@aircall/ds` (same package as `DataTable`)
221
+
222
+ ### 4c. Initial loading state
223
+
224
+ **Before (`@dashboard/library`):**
225
+ ```tsx
226
+ import { LoadMoreTable } from '@dashboard/library';
227
+
228
+ <LoadMoreTable
229
+ data={data ?? null}
230
+ columns={columns}
231
+ hasMore={false}
232
+ loading={isLoading}
233
+ onLoadMore={() => {}}
234
+ />
235
+ ```
236
+
237
+ **After (`@aircall/ds`):**
238
+ ```tsx
239
+ import { DataTable } from '@aircall/ds';
240
+ import type { ColumnDef } from '@aircall/ds';
241
+
242
+ <DataTable
243
+ data={data ?? []}
244
+ columns={columns}
245
+ loadingState={isLoading ? 'loading' : 'idle'}
246
+ />
247
+ ```
248
+
249
+ Key changes:
250
+ - `data` is always `TData[]`; pass `[]` while loading (skeleton rows replace the empty body)
251
+ - `loading={true}` → `loadingState="loading"` shows skeleton rows automatically
252
+ - No `hasMore` or `onLoadMore` needed for a non-paginated table
253
+
254
+ ## 5. Common mistakes
255
+
256
+ ### Mistake 1 — Passing `strategy` or `onLoadMore` to `DataTable`
257
+
258
+ ```tsx
259
+ // Wrong — DataTable has no strategy or onLoadMore prop; they are silently ignored
260
+ import { DataTable } from '@aircall/ds';
261
+
262
+ <DataTable
263
+ data={data}
264
+ columns={columns}
265
+ strategy="scroll"
266
+ onLoadMore={handleLoadMore}
267
+ />
268
+
269
+ // Correct — use onFetchMore for scroll-based pagination
270
+ <DataTable
271
+ data={data}
272
+ columns={columns}
273
+ hasMore={hasMore}
274
+ onFetchMore={handleFetchMore}
275
+ />
276
+ ```
277
+
278
+ `LoadMoreStrategy` and `onLoadMore` are `@dashboard/library` concepts. `DataTable` exposes
279
+ `onFetchMore` (triggers when the IntersectionObserver sentinel enters the viewport) and
280
+ `hasMore` (hides the sentinel when false). There is no button-strategy prop — render your
281
+ own button outside the table for that UX pattern.
282
+
283
+ Source: `packages/ds/src/components/data-table.tsx`
284
+
285
+ ### Mistake 2 — Keeping `loading` / `loadingMore` as separate boolean props
286
+
287
+ ```tsx
288
+ // Wrong — DataTable has no loading or loadingMore props; they are silently ignored
289
+ <DataTable
290
+ data={data}
291
+ columns={columns}
292
+ loading={isLoading}
293
+ loadingMore={isFetchingMore}
294
+ />
295
+
296
+ // Correct — collapse both booleans into a single loadingState string
297
+ <DataTable
298
+ data={data}
299
+ columns={columns}
300
+ loadingState={isLoading ? 'loading' : isFetchingMore ? 'loadingMore' : 'idle'}
301
+ />
302
+ ```
303
+
304
+ DS replaced the dual-boolean pattern with a discriminated `loadingState` string so each
305
+ async phase renders the right UX: `'loading'` shows skeleton rows (initial load), while
306
+ `'loadingMore'` appends a spinner row at the bottom (pagination). Passing `loading` or
307
+ `loadingMore` as props is silently ignored and the table will not show any loading indicator.
308
+
309
+ Source: `packages/ds/src/components/data-table.tsx`
310
+
311
+ ### Mistake 3 — Using column descriptor objects instead of `ColumnDef`
312
+
313
+ ```tsx
314
+ // Wrong — { id, label, renderer } is the @dashboard/library TableColumn shape;
315
+ // DataTable does not read these fields
316
+ import { DataTable } from '@aircall/ds';
317
+
318
+ const columns = [
319
+ { id: 'name', label: 'Name' },
320
+ { id: 'email', label: 'Email', renderer: ({ email }) => <a href={`mailto:${email}`}>{email}</a> },
321
+ ];
322
+
323
+ <DataTable data={data} columns={columns} />
324
+
325
+ // Correct — use ColumnDef from @tanstack/react-table
326
+ import type { ColumnDef } from '@aircall/ds';
327
+
328
+ type Row = { id: string; name: string; email: string };
329
+
330
+ const columns: ColumnDef<Row>[] = [
331
+ { accessorKey: 'name', header: 'Name' },
332
+ {
333
+ accessorKey: 'email',
334
+ header: 'Email',
335
+ cell: ({ row }) => <a href={`mailto:${row.original.email}`}>{row.original.email}</a>,
336
+ },
337
+ ];
338
+ ```
339
+
340
+ `DataTable` is built on TanStack Table v8. It reads `ColumnDef` fields (`accessorKey`,
341
+ `header`, `cell`, `enableSorting`, etc.) — not `id`, `label`, or `renderer`. Passing the
342
+ old descriptor shape compiles (TypeScript can't narrow the column union tightly), but
343
+ all columns will render blank cells because no accessor is registered.
344
+
345
+ Source: `packages/ds/src/components/data-table.tsx`
346
+
347
+ ### Mistake 4 — Passing `noDataMessage` instead of `emptyState`
348
+
349
+ ```tsx
350
+ // Wrong — noDataMessage is a @dashboard/library / Tractor prop; DataTable ignores it
351
+ <DataTable data={[]} columns={columns} noDataMessage="No records found." />
352
+
353
+ // Correct — emptyState accepts any ReactNode; use the Empty compound for rich states
354
+ import { DataTable, Empty, EmptyContent, EmptyTitle, EmptyDescription } from '@aircall/ds';
355
+
356
+ <DataTable
357
+ data={[]}
358
+ columns={columns}
359
+ emptyState={
360
+ <Empty>
361
+ <EmptyContent>
362
+ <EmptyTitle>No records found</EmptyTitle>
363
+ <EmptyDescription>Try adjusting your filters.</EmptyDescription>
364
+ </EmptyContent>
365
+ </Empty>
366
+ }
367
+ />
368
+ ```
369
+
370
+ `DataTable` requires an `emptyState` prop; `noDataMessage` is not defined on `DataTable`
371
+ and is silently ignored — the table body renders nothing when data is empty. For plain
372
+ text wrap it in `<p className="text-sm text-muted-foreground">`. For a full empty state
373
+ with an icon and call-to-action, compose with the `Empty` family from `@aircall/ds`.
374
+
375
+ Source: `packages/ds/src/components/data-table.tsx`
376
+
377
+ ### Mistake 5 — Passing height as a Tractor prop instead of using `fillHeight`
378
+
379
+ ```tsx
380
+ // Wrong — h / w are Tractor Flex props; DataTable does not accept them
381
+ <DataTable data={data} columns={columns} h="420px" />
382
+
383
+ // Correct — constrain the parent height with a flex container and pass fillHeight
384
+ <div className="flex flex-col min-h-0" style={{ height: 420 }}>
385
+ <DataTable data={data} columns={columns} fillHeight />
386
+ </div>
387
+ ```
388
+
389
+ `LoadMoreTable` forwarded layout props (`h`, `w`, `flex`, etc.) to the Tractor `Flex`
390
+ wrapper it rendered internally. `DataTable` has no such props. Use `fillHeight` to pin
391
+ the header and scroll the body internally — the parent must provide a bounded height via
392
+ a flex column (`flex flex-col` + `min-h-0`). Without a bounded parent, `fillHeight` has
393
+ no effect and the table grows to its natural height.
394
+
395
+ Source: `packages/ds/src/components/data-table.tsx`