wulf-pulse/components/admin/DataTable.tsx
lorentz c97e5fc45c feat: status popover + CSV export
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>
2026-05-03 10:20:27 -04:00

455 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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';
}