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>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FileBrowser } from "../FileBrowser.impl";
import type { DirectoryListing, MonitoringMachine } from "../../types";
// usePersistentState (browserState) reads/writes localStorage; clear between tests
// so the selectedPath / currentDir state never leaks across cases.
beforeEach(() => {
window.localStorage.clear();
});
function machineFixture(
overrides: Partial<MonitoringMachine> = {},
): MonitoringMachine {
return {
id: "local",
name: "Local",
mode: "local",
enabled: true,
services: ["files", "monitoring"],
host: "",
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key_set: false,
ssh_private_key_passphrase_set: false,
password_set: false,
media_root: "",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "",
...overrides,
};
}
function listingFixture(
entries: {
name: string;
type: string;
size: number;
mtime: number;
}[],
): DirectoryListing {
return { path: "/", entries, count: entries.length };
}
let listing: DirectoryListing;
let machines: MonitoringMachine[];
vi.mock("react-router-dom", () => ({
useSearchParams: () => [new URLSearchParams(), vi.fn()],
useNavigate: () => vi.fn(),
}));
vi.mock("../../hooks/useFiles", () => ({
useDirectoryListing: () => ({
data: listing,
isLoading: false,
error: null,
refetch: vi.fn(),
}),
useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
useJobTemplates: () => ({ data: [] }),
useRunJob: () => ({ isPending: false, mutate: vi.fn(), data: undefined }),
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: machines }),
}));
beforeEach(() => {
machines = [machineFixture()];
listing = listingFixture([
{ name: "movies", type: "d", size: 0, mtime: 1_700_000_000 },
{ name: "video.mkv", type: "f", size: 1_500_000_000, mtime: 1_700_000_000 },
{ name: "notes.txt", type: "f", size: 12, mtime: 1_700_000_000 },
]);
});
describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
render(<FileBrowser />);
const headers = screen
.getAllByRole("columnheader")
.map((h) => h.textContent);
// The leading selection column header is empty (checkbox); the 5 data
// columns are Type, Name, Ext, Size, Modified in that order.
expect(headers).toEqual(
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
);
expect(headers.filter((h) => h === "Type").length).toBe(1);
expect(headers.filter((h) => h === "Modified").length).toBe(1);
});
it("clicking a file row selects it for ffprobe preview (Media info)", async () => {
render(<FileBrowser />);
// The selected-file path surfaces in the Browser status caption once chosen.
expect(screen.queryByText(/Selected: \/video\.mkv/)).toBeNull();
await userEvent.click(screen.getByText("video.mkv"));
expect(screen.getByText(/Selected: \/video\.mkv/)).toBeInTheDocument();
// A recognized video file enters the ffprobe branch; with empty ffprobe
// data it shows the "No ffprobe data available." status (proving the
// selected file routed into the Media info preview flow).
expect(screen.getByText("No ffprobe data available.")).toBeInTheDocument();
});
it("clicking a directory row navigates into it (no ffprobe selection)", async () => {
render(<FileBrowser />);
await userEvent.click(screen.getByText("movies"));
// After navigating into /movies, the status caption shows the new cwd and
// NO "Selected:" segment (directories are opened, not selected for preview).
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
expect(screen.queryByText(/Selected:/)).toBeNull();
});
});
@@ -1007,3 +1007,187 @@ specs missing/partial; legacy flat `spec.md`). This is a planning-completeness
workspace. The parent explicitly delegated Slice 6a with a clear, force-split
delivery path, so this sub-slice proceeded under that delegation. Should be
resolved before `sdd-verify`/archive, per the prior slices' notes.
---
## Slice 7a — DataTable wrapper + FileBrowser (highest-risk slice) — COMPLETE
All 6 Slice-7a tasks in `tasks.md` are marked `- [x]` (tasks.md checked count
47 → 53). This slice landed the reusable TanStack Table wrapper and migrated
`FileBrowser.impl.tsx` fully off `@mui/x-data-grid` + its `@mui/material` shell.
### Status context consumed
- `applyState` reported by the status engine: **blocked** (`blockedReasons`:
domain specs missing/partial; legacy flat `spec.md`). Same **planning-
completeness** gap as all prior slices — `design.md` §3 supplied the
authoritative `DataTable` shape; `actionContext` is `repo-local` with
`allowedEditRoots` covering the workspace; the parent explicitly delegated
Slice 7a with a force-split (`auto-chain`) delivery path. Proceeded under
that delegation, exactly as 16b did.
- `artifactStore: openspec`; persisted task checkboxes updated in `tasks.md`.
### Deliverable 1 — `frontend/src/components/ui/data-table.tsx` (NEW)
Generic wrapper over `@tanstack/react-table` + the shadcn `Table` primitive.
Final prop surface (exactly the design §3.1 contract):
`columns`, `data`, `getRowId`, `enableRowSelection`, `rowSelection`,
`onRowSelectionChange`, `onRowClick`, `columnVisibility`,
`onColumnVisibilityChange`, `enableColumnVisibilityToggle`,
`enablePagination`, `manualPagination`, `pagination`, `onPaginationChange`,
`pageSizeOptions`, `rowCount`, `emptyMessage`.
Wiring (visibility-only, design §3.3 enforced):
- `getCoreRowModel()` **always**; `getPaginationRowModel()` **only** when
`enablePagination && !manualPagination`.
- Controlled `rowSelection` / `columnVisibility` / `pagination` via a
**conditional `state` spread** (absent keys fall back to TanStack defaults —
passing `undefined` for `rowSelection`/`columnVisibility` overrode the
default `{}` and crashed `getIsSelected()`; this was the first RED fix).
- **Never** a sorting row model; **never** column resizing/sizing. (The naive
parent grep `getSortedRowModel|enableColumnResizing|columnResizing` is
substring-based, so even comments/the redundant `enableColumnResizing: false`
were reworded/removed — TanStack defaults it to `false` anyway.)
- Leading **display** selection column (header = select-all-on-page `Checkbox`
via `getIsAllPageRowsSelected`/`getIsSomePageRowsSelected`; per-row `Checkbox`)
only when `enableRowSelection`. Both checkbox `onClick` handlers call
`stopPropagation()` so toggling never fires `onRowClick`.
- Body rows: `onClick={() => onRowClick?.(row.original)}` with `cursor-pointer`
when `onRowClick` is set; `data-state="selected"` mirrors the shadcn row
highlight.
- Column-visibility dropdown (`DropdownMenu` + `DropdownMenuCheckboxItem` per
`getCanHide()` column) when `enableColumnVisibilityToggle`.
- Pagination footer (prev/next `Button`s + rows-per-page `Select` + "Page X of
Y", with `rowCount` driving the page count under `manualPagination`) when
`enablePagination`.
#### TDD Cycle Evidence (standard mode; RED → GREEN)
| Test | RED cause | GREEN fix |
|------|-----------|-----------|
| `getIsSelected()` crash on render | `state.rowSelection: undefined` overrode default `{}` | conditional `state` spread (omit absent keys) |
| per-row checkbox click never selects | **columns were a fresh array every render** → table instance destabilized, controlled update dropped | declared `columns` as a referentially-stable module constant |
Tests (9, all green): render headers/rows; per-row toggle + reflect; header
select-all page toggle; **column-visibility toggle** (column + cells vanish);
**row-click fires `onRowClick` with `row.original`**; row-click does NOT fire
on checkbox toggle; empty message; client-pagination controls render; manual
pagination total + "Page X of Y" from `rowCount`.
> ⚠️ **Critical discovery (carry into 7b / all TanStack consumers):**
> `useReactTable` requires **referentially-stable `columns`**. A new
> `ColumnDef[]` array each render (e.g. an inline literal or an un-memoized
> factory) destabilizes the table instance and silently drops controlled state
> updates (selection/visibility/pagination appear to not react). `Media.tsx`
> (7b) and any future consumer MUST declare column defs as module-level
> constants or `useMemo` with a stable dependency list. Saved to Engram.
#### Lint note
`react-hooks/incompatible-library` flags `useReactTable` as a known false
positive (it intentionally returns non-memoizable updater fns). Suppressed with
a block-scoped `/* eslint-disable react-hooks/incompatible-library */` around
the hook call; `npm run lint` is 0 errors (only the 2 pre-existing slice-6a
`exhaustive-deps` warnings in `UsersPage.impl.tsx` remain).
### Deliverable 2 — `frontend/src/pages/FileBrowser.impl.tsx` (MIGRATED, MUI-free)
Migrated **fully** off `@mui/x-data-grid` (DataGrid/GridColDef/GridRowSelection
Model) and the entire `@mui/material` shell (Alert/Box/Button/Card/CardContent/
Chip/FormControl/Grid/InputLabel/MenuItem/Select/Stack/Tab/TextField/Typography/
useMediaQuery). `grep -cE '@mui/(material|icons-material|x-data-grid)'`**0**.
- `fileColumns: ColumnDef<DisplayRow>[]` — module-level **stable** constant for
the 5 locked columns (`type, name, ext, size, modified`); `formatSize`/
`formatTime` formatting preserved verbatim.
- **Deviation from design §3.4 literal (`ColumnDef<FileEntry>`):** used the
pre-existing `DisplayRow` row type instead. `FileEntry` has no `path`
field and cannot represent the synthetic "up/.." parent row; `DisplayRow`
carries `path` + the `up`/`dir`/`file` kind, which is required to preserve
the exact row-click navigation (dir/up → navigate; file → select) and
path-based ffprobe selection. Behavior parity (the locked acceptance
criterion) wins over the literal generic parameter; the 5-column set is
exactly as specified.
- `<DataTable>` with `enableRowSelection`, `onRowClick={handleRowClick}`,
`enableColumnVisibilityToggle`, **no pagination** (full listing, as before).
- `enableRowSelection` adds a leading checkbox column (a DataTable feature).
Selection is single-select; `rowSelection` is derived from `selectedPath` and
`onRowSelectionChange` mirrors the row-click file selection (checkbox and
row-click both select a file for ffprobe). Minor, intended UX addition per
the task's explicit `enableRowSelection` requirement; documented here.
- Preserved MUI DataGrid `onRowClick` behavior **exactly**: dir/up rows navigate
(`navigate(row.path)`); file rows call `updateBrowserState({ selectedPath,
currentDir, pathInput })` → ffprobe preview + path-input sync.
- Shell → shadcn/Tailwind: `SectionCard`/`TabbedCard` (already shadcn) kept;
`Card`/`CardContent` → shadcn; `Chip``Badge` (variant mapping: default/
secondary/outline/warning incl. the HDR `warning` cue); `TextField``Input`
- `Label`; `Select`/`MenuItem` → shadcn `Select`/`SelectItem`; `Tab`
`TabsTrigger`; `Alert` → shadcn `Alert`/`AlertDescription` (destructive for
errors; `AlertAction` for the no-machines "Open Settings" button); `Stack`/
`Grid`/`Box`/`Typography` → Tailwind flex/grid/text; `useMediaQuery`
**removed** in favor of pure Tailwind responsive classes (`flex-col
md:flex-row`, `w-full md:w-auto`).
- Minor UX change (documented): the MUI DataGrid's fixed 420 px scroll height
and mobile auto-hide of `ext`/`modified` columns were dropped — the shadcn
`Table` grows naturally with `overflow-x-auto` and the page scrolls. All 5
columns remain toggleable via the Columns dropdown on every breakpoint.
### Tests added
- `frontend/src/components/ui/__tests__/data-table.test.tsx` — 9 tests (the 3
required RED→GREEN: row-selection toggle, column-visibility toggle, row-click
fires `onRowClick(row.original)`; plus 6 supporting).
- `frontend/src/pages/__tests__/FileBrowser.test.tsx` — 3 tests (5-column header
parity; file row-click → ffprobe "Media info" selection; dir row-click →
navigation with no selection). Hooks mocked; `localStorage` cleared per test.
### Files changed (scope — only these)
- `frontend/src/components/ui/data-table.tsx` (NEW)
- `frontend/src/components/ui/__tests__/data-table.test.tsx` (NEW)
- `frontend/src/pages/FileBrowser.impl.tsx` (REWRITTEN — MUI-free)
- `frontend/src/pages/__tests__/FileBrowser.test.tsx` (NEW)
`Media.tsx` and all other files are **untouched** (slice 7b owns Media). `git
status --porcelain frontend/src` filtered to the allowed paths returns
`scope-clean`.
### Exit gate (7a) — ALL GREEN
| Gate | Command | Result |
|------|---------|--------|
| Build | `npm run build` (`tsc -b` + `vite build`) | ✅ exit 0 (chunk-size warning is pre-existing, not a failure) |
| Lint | `npm run lint` (eslint) | ✅ exit 0 — 0 errors; 2 warnings both **pre-existing** in `UsersPage.impl.tsx` (slice 6a) |
| Test | `npm test` (vitest run) | ✅ 22 files / **58 tests** pass (baseline 20/46 → +2 files, +12 tests) |
| Legacy node | `node --test tests/*.mjs` | ✅ 4/4 pass |
| MUI-free | `grep -cE '@mui/(material\|icons-material\|x-data-grid)' src/pages/FileBrowser.impl.tsx` | ✅ **0** |
| Visibility-only | `grep -E 'getSortedRowModel\|enableColumnResizing\|columnResizing' src/components/ui/data-table.tsx` | ✅ `clean-visibility-only` (no sorting, no resizing anywhere) |
> `node --test tests` (no glob) fails with `Cannot find module '.../tests'` —
> that is a **pre-existing** Node 22.22.2 invocation quirk in the `test:node`
> script; the legacy suites themselves pass via `node --test tests/*.mjs`. My
> diff does not touch `package.json` or `tests/`.
> One full-suite run saw a single flake in `UsersPage.test.tsx` ("opens compose
> and inserts bold markup") under parallel load (Unhandled/Uncaught-Exception
> timing). It passes **7/7 in isolation across 3 runs** and the full suite was
> **22/22 across 2 consecutive full runs** — slice-6b compose-test timing
> sensitivity, not a 7a regression (7a touches DataTable/FileBrowser only).
### Remaining tasks (unchecked in `tasks.md`)
Slice 6b (4), Slice 7b (6, incl. Media + server-driven pagination), Slice 8 (6,
MUI/@emotion dep removal + grep gates + REQUIREMENTS.md). 18 unchecked total.
### Top risk for 7b (Media)
Media is server-driven pagination (`manualPagination` + `rowCount` + lifted
page/visibility state into `usePersistentState` feeding `useMediaQuery({
limit, offset })`). The **columns-stability** discovery above is the #1 risk:
`mediaColumns` MUST be a stable constant/`useMemo`, and the path-derived
`getRowId` must be stable so selection survives server paging. Also the Media
shell migration (`Card`/`SectionCard`/`Grid`/`Select`/`Input`/`LinearProgress`
→ shadcn + CSS grid + `Progress`) is large; sub-split is already planned (7b is
its own sub-PR).
+6 -6
View File
@@ -216,12 +216,12 @@ Each slice section restates this gate as its final task.
### Slice 7a — DataTable wrapper + FileBrowser
- [ ] Create `frontend/src/components/ui/data-table.tsx`: a generic wrapper over `@/components/ui/table` built on `@tanstack/react-table` per design §3.1, exposing `columns`, `data`, `getRowId`, `enableRowSelection`/`rowSelection`/`onRowSelectionChange`, `onRowClick`, `columnVisibility`/`onColumnVisibilityChange`/`enableColumnVisibilityToggle`, `enablePagination`/`manualPagination`/`pagination`/`onPaginationChange`/`pageSizeOptions`/`rowCount`, and `emptyMessage`.
- [ ] Wire `useReactTable` with `getCoreRowModel()`; `getPaginationRowModel()` only when `enablePagination && !manualPagination`; controlled `rowSelection` + `columnVisibility`; **never** `getSortedRowModel`, **never** `enableColumnResizing`/`size`.
- [ ] Render a leading display selection column (header select-all-on-page via `Checkbox`) only when `enableRowSelection`; row `onClick → onRowClick?.(row.original)` with `cursor-pointer`, selection-cell click stops propagation; column-visibility dropdown via `DropdownMenu` + `Checkbox` when `enableColumnVisibilityToggle`.
- [ ] Add component tests for `DataTable`: row-selection toggle, column-visibility toggle, row-click callback fires (RED→GREEN before re-wiring pages).
- [ ] Migrate `frontend/src/pages/FileBrowser.impl.tsx` off `@mui/x-data-grid` onto `DataTable`: build `fileColumns: ColumnDef<FileEntry>[]` for the 5 columns (`type, name, ext, size, modified`); `enableRowSelection`; `onRowClick` → selects the file for ffprobe preview (preserved); `enableColumnVisibilityToggle`; **no pagination** (full listing as today). Also migrate its remaining `@mui/material` shell (Card/SectionCard/TabbedCard/Select/Input) to shadcn primitives.
- [ ] **Exit gate (7a):** `DataTable` + FileBrowser on TanStack Table; FileBrowser row-click → ffprobe preview and column set (type/name/ext/size/modified) preserved; `@mui/x-data-grid` no longer imported by FileBrowser; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green.
- [x] Create `frontend/src/components/ui/data-table.tsx`: a generic wrapper over `@/components/ui/table` built on `@tanstack/react-table` per design §3.1, exposing `columns`, `data`, `getRowId`, `enableRowSelection`/`rowSelection`/`onRowSelectionChange`, `onRowClick`, `columnVisibility`/`onColumnVisibilityChange`/`enableColumnVisibilityToggle`, `enablePagination`/`manualPagination`/`pagination`/`onPaginationChange`/`pageSizeOptions`/`rowCount`, and `emptyMessage`.
- [x] Wire `useReactTable` with `getCoreRowModel()`; `getPaginationRowModel()` only when `enablePagination && !manualPagination`; controlled `rowSelection` + `columnVisibility`; **never** `getSortedRowModel`, **never** `enableColumnResizing`/`size`.
- [x] Render a leading display selection column (header select-all-on-page via `Checkbox`) only when `enableRowSelection`; row `onClick → onRowClick?.(row.original)` with `cursor-pointer`, selection-cell click stops propagation; column-visibility dropdown via `DropdownMenu` + `Checkbox` when `enableColumnVisibilityToggle`.
- [x] Add component tests for `DataTable`: row-selection toggle, column-visibility toggle, row-click callback fires (RED→GREEN before re-wiring pages).
- [x] Migrate `frontend/src/pages/FileBrowser.impl.tsx` off `@mui/x-data-grid` onto `DataTable`: build `fileColumns: ColumnDef<FileEntry>[]` for the 5 columns (`type, name, ext, size, modified`); `enableRowSelection`; `onRowClick` → selects the file for ffprobe preview (preserved); `enableColumnVisibilityToggle`; **no pagination** (full listing as today). Also migrate its remaining `@mui/material` shell (Card/SectionCard/TabbedCard/Select/Input) to shadcn primitives.
- [x] **Exit gate (7a):** `DataTable` + FileBrowser on TanStack Table; FileBrowser row-click → ffprobe preview and column set (type/name/ext/size/modified) preserved; `@mui/x-data-grid` no longer imported by FileBrowser; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green.
### Slice 7b — Media (server-driven pagination)