UNPKG

@aircall/blocks

Version:

Aircall Blocks — higher-level UI compositions built on @aircall/ds

405 lines (334 loc) 14 kB
--- name: aircall-blocks/migrate-dashboard/data-table description: > Migrate @dashboard/library LoadMoreTable, LoadMoreStrategy, LoadMoreTableProps, and LoadMoreScrollProps to the @aircall/ds DataTable (TanStack Table-backed). Load when a file imports LoadMoreTable or LoadMoreStrategy 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. ## 1. Architecture change `LoadMoreTable` from `@dashboard/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 `@aircall/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 `@aircall/ds`). The component handles both infinite scroll (via IntersectionObserver sentinel) and initial-load skeletons natively. ## 2. Target mapping | `@dashboard/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 ``` `@aircall/ds` **exposes** the TanStack Table types you author — `ColumnDef`, `SortingState`, `OnChangeFn` — as bound aliases, 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. ## 4. Before / After ### 4a. Scroll strategy (infinite scroll) **Before (`@dashboard/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 (`@aircall/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 > `@aircall/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 (`@dashboard/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 (`@aircall/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 `@aircall/ds` (same package as `DataTable`) ### 4c. Initial loading state **Before (`@dashboard/library`):** ```tsx import { LoadMoreTable } from '@dashboard/library'; <LoadMoreTable data={data ?? null} columns={columns} hasMore={false} loading={isLoading} onLoadMore={() => {}} /> ``` **After (`@aircall/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 1Passing `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 `@dashboard/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 2Keeping `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 3Using 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 4Passing `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 `@aircall/ds`. Source: `packages/ds/src/components/data-table.tsx` ### Mistake 5Passing 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`