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
@@ -0,0 +1,185 @@
import { describe, it, expect, vi } from "vitest";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "../data-table";
interface Row {
id: string;
name: string;
role: string;
}
const rows: Row[] = [
{ id: "1", name: "Alice", role: "Admin" },
{ id: "2", name: "Bob", role: "Editor" },
{ id: "3", name: "Carol", role: "Viewer" },
];
const columns: ColumnDef<Row>[] = [
{
accessorKey: "name",
header: () => "Name",
cell: ({ row }) => row.original.name,
},
{
accessorKey: "role",
header: () => "Role",
cell: ({ row }) => row.original.role,
},
];
/** Wrapper so the DataTable's controlled state can update during interaction. */
function Harness({
onRowClick,
initialSelection = {},
}: {
onRowClick?: (row: Row) => void;
initialSelection?: Record<string, boolean>;
}) {
const [selection, setSelection] =
useState<Record<string, boolean>>(initialSelection);
const [visibility, setVisibility] = useState<Record<string, boolean>>({});
return (
<DataTable
columns={columns}
data={rows}
getRowId={(row) => row.id}
enableRowSelection
rowSelection={selection}
onRowSelectionChange={setSelection}
onRowClick={onRowClick}
enableColumnVisibilityToggle
columnVisibility={visibility}
onColumnVisibilityChange={setVisibility}
/>
);
}
describe("DataTable (slice 7a — TanStack wrapper)", () => {
it("renders the column headers and rows", () => {
render(<Harness />);
expect(screen.getByText("Name")).toBeInTheDocument();
expect(screen.getByText("Role")).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Carol")).toBeInTheDocument();
});
it("toggles row selection via the per-row checkbox and reflects state", async () => {
render(<Harness />);
// Header select-all checkbox + one per-row checkbox exist before rows.
expect(screen.getAllByRole("checkbox", { name: "Select row" }).length).toBe(
rows.length,
);
const aliceCheckbox = screen.getAllByRole("checkbox", {
name: "Select row",
})[0];
await userEvent.click(aliceCheckbox);
expect(aliceCheckbox).toBeChecked();
// Toggling again un-selects (controlled membership flips).
await userEvent.click(aliceCheckbox);
expect(aliceCheckbox).not.toBeChecked();
});
it("selects all page rows via the header select-all checkbox", async () => {
render(<Harness />);
const selectAll = screen.getByRole("checkbox", {
name: "Select all rows on this page",
});
await userEvent.click(selectAll);
for (const cb of screen.getAllByRole("checkbox", { name: "Select row" })) {
expect(cb).toBeChecked();
}
await userEvent.click(selectAll);
for (const cb of screen.getAllByRole("checkbox", { name: "Select row" })) {
expect(cb).not.toBeChecked();
}
});
it("toggles column visibility via the Columns dropdown (column disappears)", async () => {
render(<Harness />);
// Role column header present initially.
expect(screen.getByText("Role")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /Columns/ }));
await userEvent.click(
screen.getByRole("menuitemcheckbox", { name: "role" }),
);
// Role header + all role cells vanish from the table.
expect(screen.queryByText("Role")).toBeNull();
expect(screen.queryByText("Admin")).toBeNull();
expect(screen.queryByText("Viewer")).toBeNull();
// Name column is unaffected.
expect(screen.getByText("Name")).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
});
it("fires onRowClick with row.original when a row body is clicked", async () => {
const onRowClick = vi.fn();
render(<Harness onRowClick={onRowClick} />);
await userEvent.click(screen.getByText("Bob"));
expect(onRowClick).toHaveBeenCalledTimes(1);
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: "2", name: "Bob", role: "Editor" }),
);
});
it("does NOT fire onRowClick when the selection checkbox is toggled", async () => {
const onRowClick = vi.fn();
render(<Harness onRowClick={onRowClick} />);
const firstCheckbox = screen.getAllByRole("checkbox", {
name: "Select row",
})[0];
await userEvent.click(firstCheckbox);
expect(onRowClick).not.toHaveBeenCalled();
});
it("renders the empty message when data is empty", () => {
render(
<DataTable
columns={columns}
data={[]}
emptyMessage="No files in this directory."
/>,
);
expect(screen.getByText("No files in this directory.")).toBeInTheDocument();
});
it("renders client pagination controls when enabled", () => {
render(
<DataTable
columns={columns}
data={rows}
enablePagination
pageSizeOptions={[2, 10]}
/>,
);
expect(screen.getByText(/Page 1 of/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Previous page" }),
).toBeDisabled();
expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled();
});
it("renders the manual pagination total when rowCount is supplied", () => {
render(
<DataTable
columns={columns}
data={rows.slice(0, 2)}
enablePagination
manualPagination
rowCount={42}
pagination={{ pageIndex: 0, pageSize: 2 }}
/>,
);
expect(screen.getByText("42 rows")).toBeInTheDocument();
expect(screen.getByText(/Page 1 of 21/)).toBeInTheDocument();
});
});
+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>
);
}