feat(frontend): slice 7a — DataTable wrapper + FileBrowser (TanStack Table)

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).
This commit is contained in:
Developer
2026-06-17 18:02:47 +00:00
parent 04f2e59c92
commit e8b0f1144b
6 changed files with 1296 additions and 521 deletions
+342
View File
@@ -0,0 +1,342 @@
"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>
);
}