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:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
workspace. The parent explicitly delegated Slice 6a with a clear, force-split
|
||||||
delivery path, so this sub-slice proceeded under that delegation. Should be
|
delivery path, so this sub-slice proceeded under that delegation. Should be
|
||||||
resolved before `sdd-verify`/archive, per the prior slices' notes.
|
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 1–6b 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).
|
||||||
|
|||||||
@@ -216,12 +216,12 @@ Each slice section restates this gate as its final task.
|
|||||||
|
|
||||||
### Slice 7a — DataTable wrapper + FileBrowser
|
### 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`.
|
- [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`.
|
||||||
- [ ] Wire `useReactTable` with `getCoreRowModel()`; `getPaginationRowModel()` only when `enablePagination && !manualPagination`; controlled `rowSelection` + `columnVisibility`; **never** `getSortedRowModel`, **never** `enableColumnResizing`/`size`.
|
- [x] 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`.
|
- [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`.
|
||||||
- [ ] Add component tests for `DataTable`: row-selection toggle, column-visibility toggle, row-click callback fires (RED→GREEN before re-wiring pages).
|
- [x] 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.
|
- [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.
|
||||||
- [ ] **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] **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)
|
### Slice 7b — Media (server-driven pagination)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user