e8b0f1144b
Web UI rework. Highest-risk slice, part 1 of 2: - New components/ui/data-table.tsx: generic TanStack Table wrapper on the shadcn Table primitive. Controlled rowSelection/columnVisibility/ pagination, optional selection column (stopPropagation on cell click), row-click, column-visibility dropdown, manual-pagination support. Hard rule honored: NO getSortedRowModel, NO column resizing/sizing. - Migrate pages/FileBrowser.impl.tsx off @mui/x-data-grid + @mui/material onto DataTable: 5 columns (type/name/ext/size/modified), row-click -> ffprobe preview preserved, column-visibility toggle, no pagination. - DataTable + FileBrowser component tests (RED->GREEN). Gate: build + lint + test green (22 files / 58 tests).
343 lines
9.5 KiB
TypeScript
343 lines
9.5 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import {
|
|
type ColumnDef,
|
|
type OnChangeFn,
|
|
type PaginationState,
|
|
type RowSelectionState,
|
|
type Table as TableInstance,
|
|
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 {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
|
|
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 && (
|
|
<DataTablePagination
|
|
table={table}
|
|
pageSizeOptions={pageSizeOptions}
|
|
pageCount={pageCount}
|
|
manual={manualPagination}
|
|
rowCount={rowCount}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface PaginationProps<TData> {
|
|
table: TableInstance<TData>;
|
|
pageSizeOptions: number[];
|
|
pageCount: number;
|
|
manual: boolean;
|
|
rowCount?: number;
|
|
}
|
|
|
|
function DataTablePagination<TData>({
|
|
table,
|
|
pageSizeOptions,
|
|
pageCount,
|
|
manual,
|
|
rowCount,
|
|
}: PaginationProps<TData>) {
|
|
const pageIndex = table.getState().pagination.pageIndex;
|
|
const pageSize = table.getState().pagination.pageSize;
|
|
const visibleRows = table.getRowModel().rows.length;
|
|
const totalRows = manual ? (rowCount ?? 0) : visibleRows;
|
|
|
|
return (
|
|
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
|
|
<div className="text-muted-foreground">
|
|
{`${totalRows} row${totalRows === 1 ? "" : "s"}`}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-muted-foreground">Rows per page</span>
|
|
<Select
|
|
value={String(pageSize)}
|
|
onValueChange={(value) => table.setPageSize(Number(value))}
|
|
>
|
|
<SelectTrigger
|
|
size="sm"
|
|
className="w-[70px]"
|
|
aria-label="Rows per page"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{pageSizeOptions.map((option) => (
|
|
<SelectItem key={option} value={String(option)}>
|
|
{option}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<span className="text-muted-foreground">
|
|
Page {pageIndex + 1} of {pageCount}
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => table.previousPage()}
|
|
disabled={!table.getCanPreviousPage()}
|
|
aria-label="Previous page"
|
|
>
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => table.nextPage()}
|
|
disabled={!table.getCanNextPage()}
|
|
aria-label="Next page"
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|