From e8b0f1144b8d1448c588956d57f78c4f5d86bbbf Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 17 Jun 2026 18:02:47 +0000 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20slice=207a=20=E2=80=94=20Data?= =?UTF-8?q?Table=20wrapper=20+=20FileBrowser=20(TanStack=20Table)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../ui/__tests__/data-table.test.tsx | 185 ++++ frontend/src/components/ui/data-table.tsx | 342 +++++++ frontend/src/pages/FileBrowser.impl.tsx | 967 ++++++++---------- .../src/pages/__tests__/FileBrowser.test.tsx | 127 +++ .../changes/web-ui-rework/apply-progress.md | 184 ++++ openspec/changes/web-ui-rework/tasks.md | 12 +- 6 files changed, 1296 insertions(+), 521 deletions(-) create mode 100644 frontend/src/components/ui/__tests__/data-table.test.tsx create mode 100644 frontend/src/components/ui/data-table.tsx create mode 100644 frontend/src/pages/__tests__/FileBrowser.test.tsx diff --git a/frontend/src/components/ui/__tests__/data-table.test.tsx b/frontend/src/components/ui/__tests__/data-table.test.tsx new file mode 100644 index 0000000..a8f009c --- /dev/null +++ b/frontend/src/components/ui/__tests__/data-table.test.tsx @@ -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[] = [ + { + 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; +}) { + const [selection, setSelection] = + useState>(initialSelection); + const [visibility, setVisibility] = useState>({}); + return ( + 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(); + 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(); + // 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(); + 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(); + + // 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(); + + 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(); + + 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( + , + ); + expect(screen.getByText("No files in this directory.")).toBeInTheDocument(); + }); + + it("renders client pagination controls when enabled", () => { + render( + , + ); + 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( + , + ); + expect(screen.getByText("42 rows")).toBeInTheDocument(); + expect(screen.getByText(/Page 1 of 21/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/data-table.tsx b/frontend/src/components/ui/data-table.tsx new file mode 100644 index 0000000..a311ab5 --- /dev/null +++ b/frontend/src/components/ui/data-table.tsx @@ -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 { + columns: ColumnDef[]; + 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; + onRowClick?: (row: TData) => void; + columnVisibility?: VisibilityState; + onColumnVisibilityChange?: OnChangeFn; + enableColumnVisibilityToggle?: boolean; + /** Pagination (Media only; FileBrowser does not paginate). */ + enablePagination?: boolean; + manualPagination?: boolean; + pagination?: PaginationState; + onPaginationChange?: OnChangeFn; + 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({ + 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) { + 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[]>(() => { + if (!enableRowSelection) return columns; + const selectColumn: ColumnDef = { + id: "__select__", + enableSorting: false, + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + onClick={(e) => e.stopPropagation()} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + onClick={(e) => e.stopPropagation()} + /> + ), + enableHiding: false, + }; + return [selectColumn as ColumnDef, ...columns]; + }, [columns, enableRowSelection]); + + /* eslint-disable react-hooks/incompatible-library -- TanStack's + useReactTable intentionally returns non-memoizable updater fns (controlled state). */ + const table = useReactTable({ + 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 ( +
+
+ {enableColumnVisibilityToggle && ( + + + + + + Toggle columns + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + onSelect={(e) => e.preventDefault()} + > + {column.id} + + ); + })} + + + )} +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + onRowClick?.(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + {emptyMessage} + + + )} + +
+
+ + {enablePagination && ( + + )} +
+ ); +} + +interface PaginationProps { + table: TableInstance; + pageSizeOptions: number[]; + pageCount: number; + manual: boolean; + rowCount?: number; +} + +function DataTablePagination({ + table, + pageSizeOptions, + pageCount, + manual, + rowCount, +}: PaginationProps) { + 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 ( +
+
+ {`${totalRows} row${totalRows === 1 ? "" : "s"}`} +
+
+
+ Rows per page + +
+ + Page {pageIndex + 1} of {pageCount} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/pages/FileBrowser.impl.tsx b/frontend/src/pages/FileBrowser.impl.tsx index c9abfaf..f2b873c 100644 --- a/frontend/src/pages/FileBrowser.impl.tsx +++ b/frontend/src/pages/FileBrowser.impl.tsx @@ -1,25 +1,22 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { DataGrid } from "@mui/x-data-grid"; -import type { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid"; +import type { ColumnDef, RowSelectionState } from "@tanstack/react-table"; + +import { DataTable } from "@/components/ui/data-table"; +import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { - Alert, - Box, - Button, - Card, - CardContent, - Chip, - FormControl, - Grid, - InputLabel, - MenuItem, Select, - Stack, - Tab, - TextField, - Typography, - useMediaQuery, -} from "@mui/material"; + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { TabsTrigger } from "@/components/ui/tabs"; import { useDirectoryListing, useFfprobe, @@ -152,6 +149,39 @@ function isVideoFile(name: string): boolean { return exts.some((ext) => name.toLowerCase().endsWith(ext)); } +// Design §3.2: referentially-stable column defs (a new array each render would +// destabilize the TanStack table instance and drop controlled selection). +// Visibility-only: no sorting, no sizing/resizing (design §3.3). +const fileColumns: ColumnDef[] = [ + { + accessorKey: "type", + header: () => "Type", + cell: ({ row }) => ( + {row.original.type} + ), + }, + { + accessorKey: "name", + header: () => "Name", + cell: ({ row }) => {row.original.name}, + }, + { + accessorKey: "ext", + header: () => "Ext", + cell: ({ row }) => row.original.ext, + }, + { + accessorKey: "size", + header: () => "Size", + cell: ({ row }) => row.original.size, + }, + { + accessorKey: "modified", + header: () => "Modified", + cell: ({ row }) => row.original.modified, + }, +]; + const FILE_BROWSER_STATE_KEY = "manage.files.browserState"; type FileBrowserState = { @@ -170,6 +200,20 @@ function defaultFileBrowserState(): FileBrowserState { }; } +function FfprobeChip({ + children, + variant = "outline", +}: { + children: React.ReactNode; + variant?: "outline" | "secondary" | "warning" | "default"; +}) { + return {children}; +} + +function StreamBlock({ children }: { children: React.ReactNode }) { + return
{children}
; +} + function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) { const format = data.format ?? {}; const streams = data.streams ?? []; @@ -184,186 +228,136 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) { ); return ( - - - - ffprobe details - - - {path} - - +
+
+
ffprobe details
+
{path}
+
- - - - Container / format - - - - - Format: {fieldLabel("format", format.format_name)} - - - Long name:{" "} + + +
Container / format
+
+
+
+ Format:{" "} + {fieldLabel("format", format.format_name)} +
+
+ Long name:{" "} {fieldLabel("format_long_name", format.format_long_name)} - - - Duration: {humanDuration(format.duration)} - - - - - Size: {humanBytes(format.size)} - - - Bitrate: {humanRate(format.bit_rate)} - - - Filename: {fieldLabel("filename", format.filename)} - - - +
+
+ Duration:{" "} + {humanDuration(format.duration)} +
+
+
+
+ Size:{" "} + {humanBytes(format.size)} +
+
+ Bitrate:{" "} + {humanRate(format.bit_rate)} +
+
+ Filename:{" "} + {fieldLabel("filename", format.filename)} +
+
+
- - - - Streams - - - {videoStreams.length > 0 && ( - - - Video streams - - - {videoStreams.map((stream, index) => ( - - - - - + + +
Streams
+ + {videoStreams.length > 0 && ( +
+
Video streams
+
+ {videoStreams.map((stream, index) => { + const isHdr = + (stream.color_transfer ?? "") + .toLowerCase() + .includes("2084") || + (stream.color_transfer ?? "") + .toLowerCase() + .includes("b67") || + (stream.color_space ?? "") + .toLowerCase() + .includes("bt2020") || + (stream.color_primaries ?? "") + .toLowerCase() + .includes("bt2020"); + return ( + +
+ #{stream.index ?? index} + + {stream.codec_type ?? "video"} + + + {stream.codec_name ?? "unknown codec"} + {stream.codec_long_name && ( - + + {stream.codec_long_name} + )} {stream.profile && ( - + + {stream.profile} + )} {stream.bit_rate && ( - + + {humanRate(stream.bit_rate)} + )} {stream.duration && ( - + + {humanDuration(stream.duration)} + )} {stream.width && stream.height && ( - + + {`${stream.width}×${stream.height}`} + )} {stream.pix_fmt && ( - + + {stream.pix_fmt} + )} {stream.display_aspect_ratio && ( - + + {`DAR ${stream.display_aspect_ratio}`} + )} {stream.sample_aspect_ratio && ( - + + {`SAR ${stream.sample_aspect_ratio}`} + )} {stream.level !== undefined && stream.level !== null && ( - + {`L${stream.level}`} )} {stream.field_order && stream.field_order !== "unknown" && ( - + + {stream.field_order} + )} {(stream.color_range || stream.color_space || stream.color_transfer || stream.color_primaries) && ( - + {[ stream.color_range, stream.color_space, stream.color_transfer, @@ -371,198 +365,145 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) { ] .filter(Boolean) .join(" / ")} - /> + )} - - +
+
{stream.tags?.language ? `Language: ${stream.tags.language}. ` : ""} {stream.tags?.title ? `Title: ${stream.tags.title}.` : ""} - - - ))} - - - )} +
+
+ ); + })} +
+
+ )} - {audioStreams.length > 0 && ( - - - Audio streams - - - {audioStreams.map((stream, index) => ( - - - - - - {stream.channels && ( - - )} - {stream.sample_rate && ( - - )} - {stream.bit_rate && ( - - )} - {stream.duration && ( - - )} - - - {stream.codec_long_name - ? `${stream.codec_long_name}. ` - : ""} - {stream.channel_layout - ? `Layout: ${stream.channel_layout}. ` - : ""} - {stream.tags?.language - ? `Language: ${stream.tags.language}. ` - : ""} - {stream.tags?.title - ? `Title: ${stream.tags.title}.` - : ""} - - - ))} - - - )} + {audioStreams.length > 0 && ( +
+
Audio streams
+
+ {audioStreams.map((stream, index) => ( + +
+ #{stream.index ?? index} + + {stream.codec_type ?? "audio"} + + + {stream.codec_name ?? "unknown codec"} + + {stream.channels && ( + {`${stream.channels} ch`} + )} + {stream.sample_rate && ( + {`${stream.sample_rate} Hz`} + )} + {stream.bit_rate && ( + + {humanRate(stream.bit_rate)} + + )} + {stream.duration && ( + + {humanDuration(stream.duration)} + + )} +
+
+ {stream.codec_long_name + ? `${stream.codec_long_name}. ` + : ""} + {stream.channel_layout + ? `Layout: ${stream.channel_layout}. ` + : ""} + {stream.tags?.language + ? `Language: ${stream.tags.language}. ` + : ""} + {stream.tags?.title ? `Title: ${stream.tags.title}.` : ""} +
+
+ ))} +
+
+ )} - {subtitleStreams.length > 0 && ( - - - Subtitle streams - - - {subtitleStreams.map((stream, index) => ( - - - - - - {stream.tags?.language && ( - - )} - {stream.tags?.title && ( - - )} - - - ))} - - - )} + {subtitleStreams.length > 0 && ( +
+
+ Subtitle streams +
+
+ {subtitleStreams.map((stream, index) => ( + +
+ #{stream.index ?? index} + + {stream.codec_type ?? "subtitle"} + + + {stream.codec_name ?? "unknown codec"} + + {stream.tags?.language && ( + + {stream.tags.language} + + )} + {stream.tags?.title && ( + + {stream.tags.title} + + )} +
+
+ ))} +
+
+ )} - {streams.length === 0 && ( - - No streams found. - - )} -
+ {streams.length === 0 && ( +
+ No streams found. +
+ )}
{Object.keys(format.tags ?? {}).length > 0 && ( - - - - Tags - - + + +
Tags
+
{Object.entries(format.tags ?? {}).map(([key, value]) => ( - + + {`${key}: ${value}`} + ))} - +
)} -
+
+ ); +} + +function InfoAlert({ children }: { children: React.ReactNode }) { + return ( + + {children} + ); } export function FileBrowser() { const [searchParams, setSearchParams] = useSearchParams(); - const isMobile = useMediaQuery("(max-width: 900px)"); + const [columnVisibility, setColumnVisibility] = useState< + Record + >({}); const { data: machines } = useMonitoringSettings(); const fileMachines = useMemo( () => @@ -675,122 +616,134 @@ export function FileBrowser() { } } - const columns: GridColDef[] = [ - { field: "type", headerName: "Type", width: 90 }, - { field: "name", headerName: "Name", flex: 1.2, minWidth: 220 }, - { field: "ext", headerName: "Ext", width: 90 }, - { field: "size", headerName: "Size", width: 120 }, - { field: "modified", headerName: "Modified", width: 190 }, - ]; + // Preserved row-click behavior (MUI DataGrid onRowClick): dir/up rows navigate; + // file rows select the file for ffprobe preview (also feeds pathInput). + const handleRowClick = (row: DisplayRow) => { + if (row.type === "dir" || row.type === "up") { + navigate(row.path); + return; + } + updateBrowserState({ + selectedPath: row.path, + currentDir, + pathInput: row.path, + }); + }; + + // Single-select checkbox behavior (DataTable adds a selection column under + // enableRowSelection): mirrors the row-click selection for file rows. + const rowSelection: RowSelectionState = selectedPath + ? { [selectedPath]: true } + : {}; + const handleSelectionChange = ( + updater: + | RowSelectionState + | ((prev: RowSelectionState) => RowSelectionState), + ) => { + const next = + typeof updater === "function" ? updater(rowSelection) : updater; + const selectedIds = Object.keys(next).filter((id) => next[id]); + const id = selectedIds[selectedIds.length - 1]; + if (!id) { + updateBrowserState({ selectedPath: null }); + return; + } + const target = rows.find((row) => row.id === id); + if (target && target.type === "file") { + updateBrowserState({ selectedPath: target.path, pathInput: target.path }); + } else { + updateBrowserState({ selectedPath: null }); + } + }; - const rowSelectionModel: GridRowSelectionModel = selectedPath - ? { type: "include", ids: new Set([selectedPath]) } - : { type: "include", ids: new Set() }; const selectedTemplate = templates?.find((t) => t.key === selectedJob); return ( - - - File Browser - - +
+
+

File Browser

+ + {fileMachines.length + ? `${fileMachines.length} machine${fileMachines.length === 1 ? "" : "s"}` + : "No file machines"} + +
0 ? selectedMachineId : ""} onChange={setMachine} tabs={fileMachines.map((machine) => ( - + + {`${machine.name} · ${machine.mode}`} + ))} > {fileMachines.length > 0 ? ( - +
- - - - updateBrowserState({ pathInput: e.target.value }) - } - onKeyDown={handlePathSubmit} - /> - - - - - Current: {currentDir}{" "} - {selectedPath ? `| Selected: ${selectedPath}` : ""}{" "} +
+
+
+ + + updateBrowserState({ pathInput: e.target.value }) + } + onKeyDown={handlePathSubmit} + /> +
+
+ + +
+
+
+ {`Current: ${currentDir} `} + {selectedPath ? `| Selected: ${selectedPath} ` : ""} {listing ? `| Entries: ${listing.count}` : ""} - - {error && {String(error)}} - - + {error && ( + + {String(error)} + + )} +
+ row.id} + enableRowSelection + rowSelection={rowSelection} + onRowSelectionChange={handleSelectionChange} + onRowClick={handleRowClick} + enableColumnVisibilityToggle + columnVisibility={columnVisibility} + onColumnVisibilityChange={setColumnVisibility} + emptyMessage={ + isLoading + ? "Loading directory..." + : "This directory is empty." } - hideFooter - sx={{ - "& .MuiDataGrid-columnHeaders": { - fontWeight: 700, - backgroundColor: "action.hover", - }, - }} - onRowClick={(params) => { - const row = params.row as DisplayRow; - if (row.type === "dir" || row.type === "up") - navigate(row.path); - else - updateBrowserState({ - selectedPath: row.path, - currentDir, - pathInput: row.path, - }); - }} /> - - +
+
{String(ffprobeError)} + + + {String(ffprobeError)} + + ) : ffprobeLoading && !ffprobeData ? ( - Loading ffprobe data... + Loading ffprobe data... ) : ffprobeData ? ( ) : ( - No ffprobe data available. + No ffprobe data available. ) ) : ( - + Select a video file to view ffprobe details. - + ) ) : ( - + Select a file in Browser to view ffprobe details. - + )} @@ -828,98 +785,78 @@ export function FileBrowser() { description="Run predefined safe jobs against the selected file." > {selectedPath && templates && templates.length > 0 ? ( - - - - - Job template - - - - - +
+
+ + +
+
+ + {selectedTemplate && ( +
+ {selectedTemplate.description} +
+ )} +
+
{runJob.data && ( - - Exit: {runJob.data.exit_status} +
+											{`Exit: ${runJob.data.exit_status}`}
 											{"\n"}
 											{runJob.data.stdout}
 											{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
-										
+										
)} -
+
) : ( - - Select a file in Browser to run jobs. - + Select a file in Browser to run jobs. )}
- +
) : ( - + + No file-capable machines are configured yet. + + - } - > - No file-capable machines are configured yet. + )}
- +
); } diff --git a/frontend/src/pages/__tests__/FileBrowser.test.tsx b/frontend/src/pages/__tests__/FileBrowser.test.tsx new file mode 100644 index 0000000..52dd6b2 --- /dev/null +++ b/frontend/src/pages/__tests__/FileBrowser.test.tsx @@ -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 { + 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(); + + 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(); + + // 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(); + + 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(); + }); +}); diff --git a/openspec/changes/web-ui-rework/apply-progress.md b/openspec/changes/web-ui-rework/apply-progress.md index 11789e4..5aa4e36 100644 --- a/openspec/changes/web-ui-rework/apply-progress.md +++ b/openspec/changes/web-ui-rework/apply-progress.md @@ -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 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[]` — 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`):** 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. +- `` 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). diff --git a/openspec/changes/web-ui-rework/tasks.md b/openspec/changes/web-ui-rework/tasks.md index f8902d2..d6aa02d 100644 --- a/openspec/changes/web-ui-rework/tasks.md +++ b/openspec/changes/web-ui-rework/tasks.md @@ -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[]` 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[]` 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)