Two follow-ons after the ⌘K palette:
StatusIndicator → Popover
- The top-bar status light is no longer a direct link to /status.
Clicking it opens a popover with grouped issues (failing
integrations, expired tokens, expiring tokens) so a quick glance
answers "what's broken" without leaving the current page. A "View
full status" link at the bottom routes to /status when needed.
- The trigger keeps the same color rollup so the visual hint is
visible without opening the popover.
DataTable → CSV export
- Optional `exportable` + `exportFilename` props add an "Export CSV"
button next to the search bar. Default behavior exports the current
page; pass `onExportAll` for server-side full-result downloads.
- Built client-side from column defs (label → header, raw value →
cell). BOM-prefixed UTF-8 so Excel decodes correctly. Quoting +
escape handled.
- Enabled on /admin/data-browser/{companies,tickets} as initial demos.
Other data-browser pages opt in by adding two props.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
/* DataTable — paginated, sortable, optionally expandable list table.
|
||
*
|
||
* Backed by @tanstack/react-table v8 in **manual** mode: the parent owns
|
||
* data fetching, sort + search dispatch, and the page/pageSize state.
|
||
* The table itself just renders what it's given and emits user-input
|
||
* callbacks.
|
||
*
|
||
* External API is intentionally stable from the previous custom
|
||
* implementation:
|
||
*
|
||
* <DataTable
|
||
* columns={[{ key, label, sortable?, render? }]}
|
||
* data={rows}
|
||
* totalCount={n}
|
||
* page={1} pageSize={25}
|
||
* onPageChange={fn}
|
||
* onSort={(col, dir) => …} // optional
|
||
* onSearch={(q) => …} // optional
|
||
* onRowClick={(row) => …} // optional
|
||
* isLoading={false} // optional
|
||
*
|
||
* // NEW since the TanStack rewrite:
|
||
* getRowCanExpand={(row) => boolean} // optional, default false
|
||
* renderSubRow={(row) => <…>} // optional; required when expandable
|
||
* />
|
||
*
|
||
* The Column shape is the same as before (key/label/sortable/render).
|
||
* Internally we translate to ColumnDef so existing consumers keep working
|
||
* without code changes. */
|
||
|
||
'use client';
|
||
|
||
import { useMemo, useState } from 'react';
|
||
import {
|
||
type ColumnDef,
|
||
type ExpandedState,
|
||
type Row,
|
||
type SortingState,
|
||
flexRender,
|
||
getCoreRowModel,
|
||
getExpandedRowModel,
|
||
useReactTable,
|
||
} from '@tanstack/react-table';
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from '@/components/ui/table';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Skeleton } from '@/components/ui/skeleton';
|
||
import { EmptyState } from '@/components/ui/empty-state';
|
||
import {
|
||
ArrowDown,
|
||
ArrowUp,
|
||
ArrowUpDown,
|
||
ChevronDown,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ChevronsLeft,
|
||
ChevronsRight,
|
||
Download,
|
||
Loader2,
|
||
Search,
|
||
} from 'lucide-react';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
export interface Column<TData = any> {
|
||
key: string;
|
||
label: string;
|
||
sortable?: boolean;
|
||
render?: (value: any, row: TData) => React.ReactNode;
|
||
}
|
||
|
||
export interface DataTableProps<TData = any> {
|
||
columns: Column<TData>[];
|
||
data: TData[];
|
||
totalCount: number;
|
||
page: number;
|
||
pageSize: number;
|
||
onPageChange: (page: number) => void;
|
||
onSort?: (column: string, direction: 'asc' | 'desc') => void;
|
||
onSearch?: (query: string) => void;
|
||
onRowClick?: (row: TData) => void;
|
||
isLoading?: boolean;
|
||
/** Per-row gate for expansion. Return true to enable a chevron toggle. */
|
||
getRowCanExpand?: (row: TData) => boolean;
|
||
/** Renders the expanded sub-row body when a row is open. */
|
||
renderSubRow?: (row: TData) => React.ReactNode;
|
||
/** Empty-state slot. Defaults to a neutral "No results" message. */
|
||
emptyTitle?: string;
|
||
emptyDescription?: string;
|
||
/** Pin the first column when the table scrolls horizontally. Default true. */
|
||
stickyFirstColumn?: boolean;
|
||
/** When true, render an "Export CSV" button. Exports the current page only
|
||
* unless `onExportAll` is provided (which receives all records server-side). */
|
||
exportable?: boolean;
|
||
/** Filename prefix for the CSV download (e.g. "tickets" → "tickets-2026-05-03.csv"). */
|
||
exportFilename?: string;
|
||
/** Optional async callback for full-result export — called instead of building
|
||
* the CSV from `data`. Should resolve with the full row set. */
|
||
onExportAll?: () => Promise<TData[]>;
|
||
}
|
||
|
||
export default function DataTable<TData = any>({
|
||
columns,
|
||
data,
|
||
totalCount,
|
||
page,
|
||
pageSize,
|
||
onPageChange,
|
||
onSort,
|
||
onSearch,
|
||
onRowClick,
|
||
isLoading = false,
|
||
getRowCanExpand,
|
||
renderSubRow,
|
||
emptyTitle = 'No data found',
|
||
emptyDescription = 'Try adjusting your search or filters.',
|
||
stickyFirstColumn = true,
|
||
exportable = false,
|
||
exportFilename = 'export',
|
||
onExportAll,
|
||
}: DataTableProps<TData>) {
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [sorting, setSorting] = useState<SortingState>([]);
|
||
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||
|
||
const expandable = !!renderSubRow;
|
||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||
|
||
// Translate the legacy Column shape into TanStack ColumnDef.
|
||
const tanstackColumns = useMemo<ColumnDef<TData>[]>(() => {
|
||
const cols: ColumnDef<TData>[] = [];
|
||
|
||
// Lead expansion column when expansion is enabled.
|
||
if (expandable) {
|
||
cols.push({
|
||
id: '__expand',
|
||
header: () => null,
|
||
cell: ({ row }) =>
|
||
row.getCanExpand() ? (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
row.toggleExpanded();
|
||
}}
|
||
aria-label={row.getIsExpanded() ? 'Collapse row' : 'Expand row'}
|
||
className="inline-flex h-6 w-6 items-center justify-center rounded-sm hover:bg-muted/60"
|
||
>
|
||
{row.getIsExpanded() ? (
|
||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||
) : (
|
||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||
)}
|
||
</button>
|
||
) : null,
|
||
size: 32,
|
||
});
|
||
}
|
||
|
||
for (const c of columns) {
|
||
cols.push({
|
||
id: c.key,
|
||
accessorKey: c.key,
|
||
enableSorting: !!c.sortable,
|
||
header: c.label,
|
||
cell: ({ row, getValue }) =>
|
||
c.render ? c.render(getValue(), row.original) : (getValue() as React.ReactNode),
|
||
});
|
||
}
|
||
|
||
return cols;
|
||
}, [columns, expandable]);
|
||
|
||
const table = useReactTable<TData>({
|
||
data,
|
||
columns: tanstackColumns,
|
||
state: { sorting, expanded },
|
||
onSortingChange: (updater) => {
|
||
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
||
setSorting(next);
|
||
// Defer to caller for actual data fetch.
|
||
if (onSort && next.length > 0) {
|
||
onSort(next[0].id, next[0].desc ? 'desc' : 'asc');
|
||
}
|
||
},
|
||
onExpandedChange: setExpanded,
|
||
getRowCanExpand: getRowCanExpand
|
||
? (row) => getRowCanExpand(row.original)
|
||
: () => expandable,
|
||
getCoreRowModel: getCoreRowModel(),
|
||
getExpandedRowModel: getExpandedRowModel(),
|
||
manualPagination: true,
|
||
manualSorting: true,
|
||
pageCount: totalPages,
|
||
});
|
||
|
||
const handleSearch = () => {
|
||
onSearch?.(searchQuery);
|
||
};
|
||
|
||
const [exporting, setExporting] = useState(false);
|
||
async function handleExport() {
|
||
setExporting(true);
|
||
try {
|
||
const rows = onExportAll ? await onExportAll() : data;
|
||
const csv = toCsv(columns, rows);
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${exportFilename}-${today}.csv`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{(onSearch || exportable) && (
|
||
<div className="flex gap-2">
|
||
{onSearch && (
|
||
<>
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="Search…"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||
className="pl-10"
|
||
/>
|
||
</div>
|
||
<Button onClick={handleSearch} disabled={isLoading}>
|
||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||
<span className="ml-2">Search</span>
|
||
</Button>
|
||
</>
|
||
)}
|
||
{exportable && (
|
||
<Button variant="outline" onClick={handleExport} disabled={exporting || data.length === 0} className="ml-auto">
|
||
{exporting ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<Download className="w-4 h-4" />
|
||
)}
|
||
<span className="ml-2">Export CSV</span>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="border rounded-md overflow-hidden bg-card">
|
||
<Table stickyFirstColumn={stickyFirstColumn}>
|
||
<TableHeader>
|
||
{table.getHeaderGroups().map((headerGroup) => (
|
||
<TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50">
|
||
{headerGroup.headers.map((header) => {
|
||
const sortable = header.column.getCanSort();
|
||
const sortDir = header.column.getIsSorted();
|
||
return (
|
||
<TableHead
|
||
key={header.id}
|
||
style={header.column.columnDef.size ? { width: header.column.columnDef.size } : undefined}
|
||
className="font-semibold"
|
||
>
|
||
{header.isPlaceholder ? null : sortable ? (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={header.column.getToggleSortingHandler()}
|
||
className="h-8 -ml-3"
|
||
>
|
||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||
<SortIcon dir={sortDir === 'asc' ? 'asc' : sortDir === 'desc' ? 'desc' : null} />
|
||
</Button>
|
||
) : (
|
||
flexRender(header.column.columnDef.header, header.getContext())
|
||
)}
|
||
</TableHead>
|
||
);
|
||
})}
|
||
</TableRow>
|
||
))}
|
||
</TableHeader>
|
||
|
||
<TableBody>
|
||
{isLoading ? (
|
||
renderLoadingRows(table)
|
||
) : table.getRowModel().rows.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={tanstackColumns.length} className="py-8">
|
||
<EmptyState icon={Search} title={emptyTitle} description={emptyDescription} size="sm" />
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
table.getRowModel().rows.map((row) => (
|
||
<ExpandableRow
|
||
key={row.id}
|
||
row={row}
|
||
onRowClick={onRowClick}
|
||
renderSubRow={renderSubRow}
|
||
colSpan={tanstackColumns.length}
|
||
/>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-2">
|
||
<div className="text-sm text-muted-foreground">
|
||
Showing <span className="font-medium text-foreground num">
|
||
{totalCount === 0 ? 0 : Math.min((page - 1) * pageSize + 1, totalCount)}
|
||
</span>{' '}
|
||
to <span className="font-medium text-foreground num">
|
||
{Math.min(page * pageSize, totalCount)}
|
||
</span>{' '}
|
||
of <span className="font-medium text-foreground num">{totalCount}</span> results
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={() => onPageChange(1)}
|
||
disabled={page <= 1 || isLoading}
|
||
className="h-8 w-8"
|
||
aria-label="First page"
|
||
>
|
||
<ChevronsLeft className="w-4 h-4" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={() => onPageChange(page - 1)}
|
||
disabled={page <= 1 || isLoading}
|
||
className="h-8 w-8"
|
||
aria-label="Previous page"
|
||
>
|
||
<ChevronLeft className="w-4 h-4" />
|
||
</Button>
|
||
<span className="px-3 text-sm font-medium num">
|
||
Page {page} of {totalPages}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={() => onPageChange(page + 1)}
|
||
disabled={page >= totalPages || isLoading}
|
||
className="h-8 w-8"
|
||
aria-label="Next page"
|
||
>
|
||
<ChevronRight className="w-4 h-4" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={() => onPageChange(totalPages)}
|
||
disabled={page >= totalPages || isLoading}
|
||
className="h-8 w-8"
|
||
aria-label="Last page"
|
||
>
|
||
<ChevronsRight className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ExpandableRow<TData>({
|
||
row,
|
||
onRowClick,
|
||
renderSubRow,
|
||
colSpan,
|
||
}: {
|
||
row: Row<TData>;
|
||
onRowClick?: (row: TData) => void;
|
||
renderSubRow?: (row: TData) => React.ReactNode;
|
||
colSpan: number;
|
||
}) {
|
||
const clickable = !!onRowClick;
|
||
return (
|
||
<>
|
||
<TableRow
|
||
className={cn(clickable && 'cursor-pointer')}
|
||
onClick={() => onRowClick?.(row.original)}
|
||
>
|
||
{row.getVisibleCells().map((cell) => (
|
||
<TableCell key={cell.id}>
|
||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
{row.getIsExpanded() && renderSubRow && (
|
||
<TableRow className="bg-muted/20 hover:bg-muted/20">
|
||
<TableCell colSpan={colSpan} className="px-6 py-3">
|
||
{renderSubRow(row.original)}
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SortIcon({ dir }: { dir: 'asc' | 'desc' | null }) {
|
||
if (dir === 'asc') return <ArrowUp className="ml-2 h-4 w-4" />;
|
||
if (dir === 'desc') return <ArrowDown className="ml-2 h-4 w-4" />;
|
||
return <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />;
|
||
}
|
||
|
||
function renderLoadingRows<TData>(table: ReturnType<typeof useReactTable<TData>>) {
|
||
const cols = table.getAllLeafColumns().length;
|
||
return Array.from({ length: 5 }).map((_, i) => (
|
||
<TableRow key={`loading-${i}`}>
|
||
{Array.from({ length: cols }).map((_, j) => (
|
||
<TableCell key={j}>
|
||
<Skeleton className="h-5 w-full" />
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
));
|
||
}
|
||
|
||
/**
|
||
* Build a CSV string from columns + rows. Cell values come from
|
||
* `row[col.key]` (raw, not column.render — render returns ReactNode
|
||
* which doesn't serialize). Quotes are doubled, fields containing
|
||
* comma / quote / newline are wrapped in quotes; everything else is
|
||
* left bare. Booleans render true/false, null/undefined render empty.
|
||
*/
|
||
function toCsv<T = any>(columns: Column<T>[], rows: T[]): string {
|
||
const escape = (v: unknown): string => {
|
||
if (v === null || v === undefined) return '';
|
||
const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||
};
|
||
const header = columns.map((c) => escape(c.label)).join(',');
|
||
const body = rows
|
||
.map((row) =>
|
||
columns.map((c) => escape((row as Record<string, unknown>)[c.key])).join(','),
|
||
)
|
||
.join('\n');
|
||
// BOM so Excel reads UTF-8 correctly.
|
||
return '' + header + '\n' + body + '\n';
|
||
}
|