Files
manage/frontend/src/components/ui/data-table.tsx
T
Developer 32fa01cc12 Extract shared TablePagination (dedupe DataTable + Media mobile)
Pull the duplicated pagination footer into a single shared component at
frontend/src/components/ui/table-pagination.tsx. Both the desktop
DataTable (which had an internal DataTablePagination driven by a TanStack
table instance) and the Media mobile card list (which had a standalone
MediaMobilePagination driven by raw PaginationState) now consume it.

The shared component takes the raw primitives (pageIndex, pageSize,
pageCount, totalRows, pageSizeOptions, onPaginationChange, optional
className) so it backs both an adapter view (DataTable extracts state
from its table instance and passes table.setPagination) and a direct
state view (Media passes its pagination state directly). Includes the
44px mobile-touch-target on prev/next buttons (previously only on the
Media mobile variant).

Removes ~90 lines of duplication across data-table.tsx and Media.tsx;
adds the focused 122-line shared component. The DataTable Select imports
are dropped (now unused). 122 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #5.
2026-06-26 15:58:12 +00:00

265 lines
7.9 KiB
TypeScript

"use client";
import * as React from "react";
import {
type ColumnDef,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
type VisibilityState,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Columns3 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { TablePagination } from "@/components/ui/table-pagination";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export interface DataTableProps<TData, TValue = unknown> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
/** Stable row identity; Media derives it from `path` so selection survives paging. */
getRowId?: (row: TData, index: number) => string;
/** Visibility-only feature set (no sorting, no resizing — locked, design §3.3). */
enableRowSelection?: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
onRowClick?: (row: TData) => void;
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
enableColumnVisibilityToggle?: boolean;
/** Pagination (Media only; FileBrowser does not paginate). */
enablePagination?: boolean;
manualPagination?: boolean;
pagination?: PaginationState;
onPaginationChange?: OnChangeFn<PaginationState>;
pageSizeOptions?: number[];
/** Server total for Media (manual pagination). */
rowCount?: number;
emptyMessage?: string;
}
/**
* Reusable TanStack Table wrapper built on the shadcn `Table` primitive.
*
* Visibility-only feature scope (locked, design §3): pagination, row selection,
* row click, column visibility. A sorting row model is deliberately never
* wired and column resizing/sizing is never enabled — both are explicit
* non-goals.
*/
export function DataTable<TData, TValue = unknown>({
columns,
data,
getRowId,
enableRowSelection = false,
rowSelection,
onRowSelectionChange,
onRowClick,
columnVisibility,
onColumnVisibilityChange,
enableColumnVisibilityToggle = false,
enablePagination = false,
manualPagination = false,
pagination,
onPaginationChange,
pageSizeOptions = [10, 20, 30, 50],
rowCount,
emptyMessage = "No results.",
}: DataTableProps<TData, TValue>) {
const pageSize = pagination?.pageSize ?? pageSizeOptions[0] ?? 10;
// Selection column is a *display* column (no accessor); only rendered when
// the consumer opts in. Its checkbox handlers stopPropagation so toggling a
// row never also fires onRowClick navigation.
const tableColumns = React.useMemo<ColumnDef<TData, TValue>[]>(() => {
if (!enableRowSelection) return columns;
const selectColumn: ColumnDef<TData, TValue> = {
id: "__select__",
enableSorting: false,
header: ({ table }) => (
<Checkbox
aria-label="Select all rows on this page"
checked={
table.getIsAllPageRowsSelected()
? true
: table.getIsSomePageRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
onClick={(e) => e.stopPropagation()}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => e.stopPropagation()}
/>
),
enableHiding: false,
};
return [selectColumn as ColumnDef<TData, TValue>, ...columns];
}, [columns, enableRowSelection]);
/* eslint-disable react-hooks/incompatible-library -- TanStack's
useReactTable intentionally returns non-memoizable updater fns (controlled state). */
const table = useReactTable<TData>({
data,
columns: tableColumns,
getRowId,
enableRowSelection,
onRowSelectionChange,
onColumnVisibilityChange,
manualPagination: enablePagination ? manualPagination : false,
rowCount: enablePagination && manualPagination ? rowCount : undefined,
getCoreRowModel: getCoreRowModel(),
// Client pagination model ONLY when paginating locally (FileBrowser does
// not paginate; Media drives the page from the server via limit/offset).
getPaginationRowModel:
enablePagination && !manualPagination
? getPaginationRowModel()
: undefined,
state: {
...(rowSelection !== undefined ? { rowSelection } : {}),
...(columnVisibility !== undefined ? { columnVisibility } : {}),
...(enablePagination
? { pagination: pagination ?? { pageIndex: 0, pageSize } }
: {}),
},
onPaginationChange,
// Visibility-only: deliberately NO sorting model / sorting state.
});
const pageCount =
enablePagination && rowCount !== undefined && pageSize > 0
? Math.max(1, Math.ceil(rowCount / pageSize))
: table.getPageCount();
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-end gap-2">
{enableColumnVisibilityToggle && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Columns3 className="size-4" />
Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
onSelect={(e) => e.preventDefault()}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="hover:bg-transparent">
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() ? "selected" : undefined}
className={cn(onRowClick && "cursor-pointer")}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={tableColumns.length}
className="h-24 text-center text-muted-foreground"
>
{emptyMessage}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{enablePagination && (
<TablePagination
pageIndex={table.getState().pagination.pageIndex}
pageSize={table.getState().pagination.pageSize}
pageSizeOptions={pageSizeOptions}
totalRows={manualPagination ? (rowCount ?? 0) : table.getRowModel().rows.length}
pageCount={pageCount}
onPaginationChange={table.setPagination}
/>
)}
</div>
);
}
// DataTablePagination was extracted into the shared TablePagination component
// (frontend/src/components/ui/table-pagination.tsx). Both the desktop DataTable
// and the Media mobile card list consume it.