From 3dc1b31fc39b5c57146aa7b57c26de306c48af37 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 17 Jun 2026 18:33:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20slice=207b=20=E2=80=94=20Medi?= =?UTF-8?q?a=20on=20TanStack=20Table=20(server=20pagination)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web UI rework. Completes the DataGrid migration (7a + 7b): - pages/Media.tsx off @mui/x-data-grid + @mui/material onto DataTable: 15 locked columns (title/series/season/episode/type/year/runtime_min/ size/bitrate/hdr/video/resolution/date_added/library/path); enablePagination + manualPagination + rowCount from queryResult.total; page state (pageIndex/pageSize) -> offset/limit into useMediaQuery; onRowClick -> navigate('/files?path=...') preserved; stable path-derived getRowId so selection survives server paging; column-visibility toggle. Hard rule honored: NO sorting, NO resizing (visibility-only). - Migrate Media shell (Select/Input/Progress/Card/grid/Typography/Tabs). - Media component tests (column set + row-click nav). - Harness fix: polyfill ResizeObserver in test/setup.ts — jsdom lacks it and Radix primitives (Select/ScrollArea/etc.) reference it; was causing cross-test failures once Media pulled shadcn Select into the pool. Gate: build + lint + test green (23 files / 64 tests). --- frontend/src/pages/Media.tsx | 681 ++++++++++-------- frontend/src/pages/__tests__/Media.test.tsx | 259 +++++++ frontend/src/test/setup.ts | 19 + .../changes/web-ui-rework/apply-progress.md | 198 +++++ openspec/changes/web-ui-rework/tasks.md | 12 +- 5 files changed, 852 insertions(+), 317 deletions(-) create mode 100644 frontend/src/pages/__tests__/Media.test.tsx diff --git a/frontend/src/pages/Media.tsx b/frontend/src/pages/Media.tsx index 4390c71..0808431 100644 --- a/frontend/src/pages/Media.tsx +++ b/frontend/src/pages/Media.tsx @@ -1,24 +1,28 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { DataGrid } from "@mui/x-data-grid"; -import type { GridColDef } from "@mui/x-data-grid"; +import type { + ColumnDef, + OnChangeFn, + PaginationState, + RowSelectionState, + VisibilityState, +} from "@tanstack/react-table"; + +import { DataTable } from "@/components/ui/data-table"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +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 { Progress } from "@/components/ui/progress"; import { - Alert, - Box, - Button, - Card, - CardContent, - LinearProgress, - FormControl, - Grid, - InputLabel, - MenuItem, Select, - Stack, - TextField, - Typography, - useMediaQuery, -} from "@mui/material"; + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + import { useMediaStatus, useMediaQuery as useMediaDataQuery, @@ -42,7 +46,49 @@ function formatDuration(seconds: number | null | undefined): string { return `${secs}s`; } +// Design §3.2 + §3.4: the 15 locked media columns. Module-level constant so the +// TanStack table instance stays stable — an unstable columns array drops the +// controlled selection/visibility state (7a discovery). Visibility-only parity +// (design §3.3): NO sorting, NO sizing/resizing is wired anywhere. +const mediaColumns: ColumnDef[] = [ + { accessorKey: "title", header: "Title" }, + { accessorKey: "series", header: "Series" }, + { accessorKey: "season", header: "Season" }, + { accessorKey: "episode", header: "Episode" }, + { accessorKey: "type", header: "Type" }, + { accessorKey: "year", header: "Year" }, + { accessorKey: "runtime_min", header: "Runtime" }, + { accessorKey: "size", header: "Size" }, + { accessorKey: "bitrate", header: "Bitrate" }, + { accessorKey: "hdr", header: "HDR" }, + { accessorKey: "video", header: "Video codec" }, + { accessorKey: "resolution", header: "Resolution" }, + { accessorKey: "date_added", header: "Date added" }, + { accessorKey: "library", header: "Library" }, + { accessorKey: "path", header: "Path" }, +]; + +// Stable path-derived identity so row selection survives server-driven paging +// (design §3.4): the id is the item's filesystem path, which is stable across +// limit/offset page changes. +function getMediaRowId(row: MediaItem): string { + return row.path; +} + const MEDIA_TAB_STATE_KEY = "manage.media.tabState"; +const SMALL_BREAKPOINT = "(max-width: 900px)"; +// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override. +const MOBILE_HIDDEN_COLUMNS = [ + "series", + "season", + "episode", + "bitrate", + "video", + "resolution", + "date_added", + "library", + "path", +]; type MediaTabState = { search: string; @@ -51,6 +97,8 @@ type MediaTabState = { sortKey: string; sortOrder: string; offset: number; + pageSize: number; + columnVisibility: Record; }; function defaultMediaTabState(): MediaTabState { @@ -61,13 +109,75 @@ function defaultMediaTabState(): MediaTabState { sortKey: "title", sortOrder: "Ascending", offset: 0, + pageSize: 100, + columnVisibility: {}, }; } +function usePrefersSmallScreen(): boolean { + const supportsMatchMedia = + typeof window !== "undefined" && typeof window.matchMedia === "function"; + const [small, setSmall] = useState(() => + supportsMatchMedia ? window.matchMedia(SMALL_BREAKPOINT).matches : false, + ); + useEffect(() => { + if (!supportsMatchMedia) return; + const mql = window.matchMedia(SMALL_BREAKPOINT); + const onChange = () => setSmall(mql.matches); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, [supportsMatchMedia]); + return small; +} + +function FilterSelect({ + id, + label, + value, + onChange, + options, +}: { + id: string; + label: string; + value: string; + onChange: (value: string) => void; + options: { value: string; label: string }[]; +}) { + return ( +
+ + +
+ ); +} + +// LinearProgress → Progress: determinate value drives the shadcn Progress; the +// indeterminate (null) case renders a pulsing bar, preserving the pre-rework +// "indeterminate" affordance for unknown build progress. +function BuildProgress({ value }: { value: number | null }) { + if (value == null) { + return ( +
+ ); + } + return ; +} + export function Media() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - const isMobile = useMediaQuery("(max-width: 900px)"); + const isSmall = usePrefersSmallScreen(); const { data: machines } = useMonitoringSettings(); const jellyfinMachines = useMemo( () => @@ -87,14 +197,22 @@ export function Media() { selectedMachineId || undefined, ); - const [mediaState, setMediaState] = usePersistentState( + const [rawMediaState, setMediaState] = usePersistentState( MEDIA_TAB_STATE_KEY, defaultMediaTabState, ); - const { search, types, hdrFilter, sortKey, sortOrder, offset } = mediaState; + // Backward-compat: merge defaults so older persisted state (pre-7b shape, + // without pageSize/columnVisibility) never yields undefined fields. + const mediaState: MediaTabState = { + ...defaultMediaTabState(), + ...rawMediaState, + }; + const { search, types, hdrFilter, sortKey, sortOrder, offset, pageSize } = + mediaState; const updateMediaState = (patch: Partial) => setMediaState((current) => ({ ...current, ...patch })); - const limit = 100; + + const [rowSelection, setRowSelection] = useState({}); useEffect(() => { if (!searchParams.get("machine_id") && selectedMachineId) { @@ -115,43 +233,64 @@ export function Media() { hdr_filter: hdrFilter, sort_key: sortKey, sort_order: sortOrder, - limit, + limit: pageSize, offset, machineId: selectedMachineId || undefined, enabled: status?.exists ?? false, }); - const columns: GridColDef[] = [ - { field: "title", headerName: "Title", minWidth: 180, flex: 1.2 }, - { field: "series", headerName: "Series", minWidth: 140, flex: 1 }, - { field: "season", headerName: "Season", width: 90 }, - { field: "episode", headerName: "Episode", width: 100 }, - { field: "type", headerName: "Type", width: 100 }, - { field: "year", headerName: "Year", width: 90 }, - { field: "runtime_min", headerName: "Runtime", width: 110 }, - { field: "size", headerName: "Size", width: 120 }, - { field: "bitrate", headerName: "Bitrate", width: 130 }, - { field: "hdr", headerName: "HDR", width: 80 }, - { field: "video", headerName: "Video codec", width: 130 }, - { field: "resolution", headerName: "Resolution", width: 120 }, - { field: "date_added", headerName: "Date added", width: 120 }, - { field: "library", headerName: "Library", width: 140 }, - { field: "path", headerName: "Path", minWidth: 240, flex: 1.2 }, - ]; + // Server-driven pagination (design §3.4): pageIndex/pageSize lift into the + // persistent media state and drive useMediaQuery { limit, offset }. + const pageIndex = Math.floor(offset / pageSize); + const pagination: PaginationState = { pageIndex, pageSize }; - const rows = useMemo( - () => - (queryResult?.items ?? []).map((item) => ({ - ...item, - id: item.id || item.path, - })), - [queryResult], - ); + const handlePaginationChange: OnChangeFn = (updater) => { + const next = + typeof updater === "function" + ? updater({ pageIndex, pageSize }) + : updater; + const nextPageSize = next.pageSize || pageSize; + // Restart at page 0 whenever the page size changes (keeps offset sane + // under server-driven paging). + const nextOffset = + nextPageSize !== pageSize ? 0 : next.pageIndex * nextPageSize; + setMediaState((current) => ({ + ...current, + offset: nextOffset, + pageSize: nextPageSize, + })); + }; + + const handleColumnVisibilityChange: OnChangeFn = ( + updater, + ) => { + setMediaState((current) => { + const prev = current.columnVisibility ?? {}; + const next = typeof updater === "function" ? updater(prev) : updater; + return { ...current, columnVisibility: next }; + }); + }; + + // On small screens force the same set of columns hidden as the pre-rework + // DataGrid `columnVisibilityModel` mobile override; on desktop the user + // toggles freely (the toggleable set still equals the locked 15). + const effectiveColumnVisibility = useMemo(() => { + const base = mediaState.columnVisibility ?? {}; + if (!isSmall) return base; + const merged = { ...base }; + for (const key of MOBILE_HIDDEN_COLUMNS) merged[key] = false; + return merged; + }, [mediaState.columnVisibility, isSmall]); + + // Preserved exactly from the DataGrid onRowClick: opens the file browser at + // the item's path. + const handleRowClick = (row: MediaItem) => { + navigate(`/files?path=${encodeURIComponent(row.path)}`); + }; + + const total = queryResult?.total ?? 0; + const totalPages = queryResult ? Math.max(1, Math.ceil(total / pageSize)) : 1; - const page = Math.floor(offset / limit) + 1; - const totalPages = queryResult - ? Math.max(1, Math.ceil(queryResult.total / limit)) - : 1; const buildRunning = status?.build_running ?? false; const buildProgress = status?.build_progress ?? null; const buildLibraryProgress = status?.build_library_progress ?? null; @@ -180,58 +319,61 @@ export function Media() { : "Current library"); return ( - - - Jellyfin - - Machine +
+
+

Jellyfin

+
+ - +
+ {status?.exists ? ( - +

Index: {status.item_count.toLocaleString()} items {status.updated_at_label ? ` | updated ${status.updated_at_label}` : ""} - +

) : ( - - No index built yet. + + No index built yet. )} + {counts && ( - +

Library stats: {counts.movies.toLocaleString()} movies ·{" "} {counts.series.toLocaleString()} series ·{" "} {counts.episodes.toLocaleString()} episodes ·{" "} {(libraries?.length ?? 0).toLocaleString()} libraries - +

)} + )} - {(buildRunning || status?.build_error) && ( - - - - {buildLabel || - (buildRunning - ? "Building media index..." - : status?.build_error || "")} - +
- - - Overall:{" "} - {buildProgress != null - ? `${Math.round(buildProgress * 100)}%` - : "pending"} - {buildRunning - ? ` • elapsed ${elapsedLabel} • eta ${etaLabel}` - : ""} - - - - {status?.build_items_processed?.toLocaleString() ?? 0}/ - {status?.build_items_total?.toLocaleString() ?? 0} items - - + {(buildRunning || status?.build_error) && ( +
+

+ {buildLabel || + (buildRunning + ? "Building media index..." + : status?.build_error || "")} +

- - - Current: {libraryLabel} - {buildRunning - ? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}` - : ""} - - - - {status?.build_library_items_processed?.toLocaleString() ?? 0} - /{status?.build_library_items_total?.toLocaleString() ?? 0}{" "} - items - - - - - )} - +
+

+ Overall:{" "} + {buildProgress != null + ? `${Math.round(buildProgress * 100)}%` + : "pending"} + {buildRunning + ? ` • elapsed ${elapsedLabel} • eta ${etaLabel}` + : ""} +

+ +

+ {status?.build_items_processed?.toLocaleString() ?? 0}/ + {status?.build_items_total?.toLocaleString() ?? 0} items +

+
- - - - - { - updateMediaState({ search: e.target.value, offset: 0 }); - }} - /> - - - - Types - - - - - - HDR - - - - - - Sort - - - - - - Order - - - - +
+

+ Current: {libraryLabel} + {buildRunning + ? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}` + : ""} +

+ +

+ {status?.build_library_items_processed?.toLocaleString() ?? 0}/ + {status?.build_library_items_total?.toLocaleString() ?? 0} items +

+
+
+ )} + + + +
+ + + updateMediaState({ search: e.target.value, offset: 0 }) + } + /> +
+
+ + updateMediaState({ types: value, offset: 0 }) + } + options={[ + { value: "Movie,Episode", label: "Movies + Episodes" }, + { value: "Movie", label: "Movies only" }, + { value: "Episode", label: "Episodes only" }, + { value: "Movie,Episode,Video", label: "All video" }, + ]} + /> +
+
+ + updateMediaState({ hdrFilter: value, offset: 0 }) + } + options={[ + { value: "All", label: "All" }, + { value: "HDR only", label: "HDR only" }, + { value: "SDR/unknown only", label: "SDR/unknown only" }, + ]} + /> +
+
+ updateMediaState({ sortKey: value })} + options={[ + { value: "title", label: "Title" }, + { value: "series", label: "Series" }, + { value: "size", label: "Size" }, + { value: "bitrate", label: "Bitrate" }, + { value: "runtime", label: "Runtime" }, + { value: "year", label: "Year" }, + { value: "date_added", label: "Date added" }, + { value: "resolution", label: "Resolution" }, + ]} + /> +
+
+ updateMediaState({ sortOrder: value })} + options={[ + { value: "Ascending", label: "Ascending" }, + { value: "Descending", label: "Descending" }, + ]} + /> +
{queryResult && ( - - Showing {queryResult.items.length} of{" "} - {queryResult.total.toLocaleString()} items | Page {page} of{" "} - {totalPages} - +

+ Showing {queryResult.items.length} of {total.toLocaleString()} items | + Page {pageIndex + 1} of {totalPages} +

)} {status?.exists && ( - - { - const row = params.row as MediaItem; - navigate(`/files?path=${encodeURIComponent(row.path)}`); - }} - pageSizeOptions={[100]} - columnVisibilityModel={ - isMobile - ? { - series: false, - season: false, - episode: false, - bitrate: false, - video: false, - resolution: false, - date_added: false, - library: false, - path: false, - } - : undefined +
+ - +
)} - - {queryResult && totalPages > 1 && ( - - - - Page {page} / {totalPages} - - - - )} - +
); } diff --git a/frontend/src/pages/__tests__/Media.test.tsx b/frontend/src/pages/__tests__/Media.test.tsx new file mode 100644 index 0000000..40e74af --- /dev/null +++ b/frontend/src/pages/__tests__/Media.test.tsx @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Media } from "../Media"; +import type { + MediaIndexStatus, + MediaItem, + MediaQueryResponse, + MonitoringMachine, +} from "../../types"; + +// Shared navigate mock so the row-click test can assert the call. The vi.mock +// factory is hoisted above this const, but it only closes over `navigate` +// lazily (the arrow runs at render time, well after init) — no TDZ access. +const navigate = vi.fn(); + +function machineFixture( + overrides: Partial = {}, +): MonitoringMachine { + return { + id: "local", + name: "Local", + mode: "local", + enabled: true, + services: ["jellyfin", "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 statusFixture( + overrides: Partial = {}, +): MediaIndexStatus { + return { + exists: true, + item_count: 2, + updated_at: 1, + updated_at_label: "now", + build_duration_seconds: null, + build_running: false, + build_stage: "", + build_message: "", + build_progress: null, + build_items_processed: 0, + build_items_total: 0, + build_current_library: "", + build_library_index: 0, + build_libraries_total: 0, + build_library_progress: null, + build_library_items_processed: 0, + build_library_items_total: 0, + build_elapsed_seconds: null, + build_eta_seconds: null, + build_library_elapsed_seconds: null, + build_library_eta_seconds: null, + build_cancel_requested: false, + build_pid: null, + build_error: "", + ...overrides, + }; +} + +function mediaItem(overrides: Partial = {}): MediaItem { + return { + id: "1", + title: "Inception", + series: "", + season: "", + episode: null, + type: "Movie", + year: 2010, + runtime_min: 148, + size: "12.4 GB", + bitrate: "35.0 Mbps", + hdr: "HDR10", + video: "HEVC", + resolution: "4K", + date_added: "2024-01-01", + library: "Movies", + path: "/media/movies/Inception.mkv", + ...overrides, + }; +} + +let status: MediaIndexStatus; +let queryResult: MediaQueryResponse; + +vi.mock("react-router-dom", () => ({ + useNavigate: () => navigate, + useSearchParams: () => [new URLSearchParams("machine_id=local"), vi.fn()], +})); + +vi.mock("../../hooks/useMedia", () => ({ + useMediaStatus: () => ({ data: status }), + useMediaQuery: () => ({ data: queryResult, isLoading: false }), + useBuildIndex: () => ({ isPending: false, mutate: vi.fn() }), + useStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }), + useForceStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }), +})); + +vi.mock("../../hooks/useSettings", () => ({ + useMonitoringSettings: () => ({ data: [machineFixture()] }), +})); + +vi.mock("../../hooks/useDashboard", () => ({ + useCounts: () => ({ data: undefined }), + useLibraries: () => ({ data: undefined }), +})); + +// usePersistentState reads/writes localStorage; clear between tests so the +// offset/pageSize/columnVisibility state never leaks across cases. +beforeEach(() => { + window.localStorage.clear(); + navigate.mockClear(); + status = statusFixture(); + queryResult = { + items: [ + mediaItem({ + id: "1", + title: "Inception", + path: "/media/movies/Inception.mkv", + }), + mediaItem({ + id: "2", + title: "Matrix", + path: "/media/movies/Matrix.mkv", + }), + ], + total: 2, + limit: 100, + offset: 0, + }; +}); + +describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => { + it("exposes exactly the 15 locked toggleable columns", async () => { + render(); + + await userEvent.click(screen.getByRole("button", { name: /Columns/ })); + + const toggleable = screen + .getAllByRole("menuitemcheckbox") + .map((item) => (item.textContent ?? "").trim()); + expect([...toggleable].sort()).toEqual( + [ + "title", + "series", + "season", + "episode", + "type", + "year", + "runtime_min", + "size", + "bitrate", + "hdr", + "video", + "resolution", + "date_added", + "library", + "path", + ].sort(), + ); + // The leading selection column is never toggleable (enableHiding=false). + expect(toggleable).toHaveLength(15); + expect(toggleable).not.toContain("__select__"); + }); + + it("renders the 15 data column headers", () => { + render(); + const headers = screen + .getAllByRole("columnheader") + .map((h) => (h.textContent ?? "").trim()); + for (const expected of [ + "Title", + "Series", + "Season", + "Episode", + "Type", + "Year", + "Runtime", + "Size", + "Bitrate", + "HDR", + "Video codec", + "Resolution", + "Date added", + "Library", + "Path", + ]) { + expect(headers).toContain(expected); + } + }); + + it("navigates to the file browser at the item path on row click", async () => { + render(); + + await userEvent.click(screen.getByText("Inception")); + + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith( + `/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`, + ); + }); + + it("does NOT navigate when toggling a row selection checkbox", async () => { + render(); + + const firstCheckbox = screen.getAllByRole("checkbox", { + name: "Select row", + })[0]; + await userEvent.click(firstCheckbox); + expect(firstCheckbox).toBeChecked(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("renders the server-driven pagination total + page controls", () => { + render(); + + // DataTable manual-pagination footer surfaces the server total + pager. + // ("Page 1 of 1" also appears in the page caption, so match all and assert + // the pager footer text is present alongside the unique total.) + expect(screen.getByText("2 rows")).toBeInTheDocument(); + expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0); + expect( + screen.getByRole("button", { name: "Previous page" }), + ).toBeDisabled(); + }); + + it("disables Build index while a build is running", () => { + status = statusFixture({ build_running: true }); + + render(); + + expect(screen.getByRole("button", { name: "Building..." })).toBeDisabled(); + // Stop + Force stop surface only while running. + expect( + screen.getByRole("button", { name: "Stop build" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Force stop" }), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 3cd47f1..b31dcae 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -3,3 +3,22 @@ // The `/vitest` entry both registers the matchers at runtime and provides the // TypeScript module augmentation for vitest's `expect` so tsc typechecks them. import "@testing-library/jest-dom/vitest"; + +// jsdom does not implement ResizeObserver, but several Radix primitives that +// shadcn wraps (ScrollArea, Select via react-popper/react-use-size, DropdownMenu, +// Tabs, etc.) reference it at module-load or render time. Without a stub, any +// component test whose render tree pulls one of these in fails with +// `ReferenceError: ResizeObserver is not defined`. Stub a no-op observer so the +// whole suite (and future component tests) is resilient to cross-test module +// loading in the Vitest pool. +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + +globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver; + +// Radix popper also probes `requestAnimationFrame`; jsdom provides it, but some +// primitives defer layout reads through rAF that never flush in jsdom. Keep the +// default rAF; this guard is intentionally minimal. diff --git a/openspec/changes/web-ui-rework/apply-progress.md b/openspec/changes/web-ui-rework/apply-progress.md index 5aa4e36..2f7b3f4 100644 --- a/openspec/changes/web-ui-rework/apply-progress.md +++ b/openspec/changes/web-ui-rework/apply-progress.md @@ -1191,3 +1191,201 @@ limit, offset })`). The **columns-stability** discovery above is the #1 risk: 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). + +## Slice 7b — Media: DataGrid → TanStack Table with server-driven pagination (DONE) + +Migrated `frontend/src/pages/Media.tsx` **fully** off `@mui/x-data-grid` + +`@mui/material` onto the frozen 7a `DataTable` wrapper (reused, not rewritten) +with **server-driven** pagination, row selection, row-click → file browser, and +visibility-only column parity. All 6 Slice-7b task lines in `tasks.md` are now +`- [x]`. Cumulative change task progress: 53 → **59/71** (the 7a rows were +already `[x]`; this run marks the 6 7b rows). + +### Status context consumed + +- `applyState` reported by the status engine: **blocked** (`blockedReasons`: + domain specs missing/partial; legacy flat `spec.md` present without domain + specs). Same planning-completeness gap as slices 1–7a — **not** a safety or + `actionContext` blocker. +- `actionContext`: `mode: repo-local`, `workspaceRoot: /home/user/Manage_01`, + `allowedEditRoots: ["/home/user/Manage_01"]`, `warnings: []` — safe. +- This run executed the explicitly delegated **Slice 7b** scope per the parent + acceptance contract (which also supplied the resolved delivery path: 7b-only + sub-PR, reuse the 7a `DataTable`). `design.md` §3.1–§3.4 (DataTable contract + + Media consumer contract) and §1 (component mapping) are authoritative and do + not depend on the missing domain specs. +- `artifactStore: openspec`; persisted task checkboxes updated in `tasks.md` + (Slice 7b: 0 → 6 `[x]`). + +### Completed tasks (persisted checkboxes updated) + +- [x] **15 locked columns** — `mediaColumns: ColumnDef[]` is a + **module-level constant** (stable — see 7a columns-stability discovery) with + exactly the 15 columns via `accessorKey`: `title, series, season, episode, + type, year, runtime_min, size, bitrate, hdr, video, resolution, date_added, + library, path`. Column `id` == `accessorKey`, so the visibility dropdown lists + exactly these 15. Headers match the pre-rework headerNames (incl. `video` → + "Video codec"). Cells rely on TanStack's default accessor rendering (the + backend `display_media_row` already formats `size`/`bitrate`/`date_added` as + strings; `runtime_min` shows the raw minute int — same as the DataGrid). +- [x] **DataTable wiring** — `enableRowSelection` (controlled `rowSelection` + + `setRowSelection`), `enablePagination` + `manualPagination` + + `rowCount={queryResult.total}`, `onRowClick={handleRowClick}` → + `navigate('/files?path=${encodeURIComponent(row.path)}')` (PRESERVED exact + pre-rework row-click → file-browser behavior), `enableColumnVisibilityToggle`. +- [x] **Lifted state + server-driven paging** — added `offset` (kept), + `pageSize` (new, default `100`), and `columnVisibility` (new, default `{}`) to + the `MediaTabState` persisted via the existing `usePersistentState` + (`manage.media.tabState`). `pagination = { pageIndex: Math.floor(offset / + pageSize), pageSize }`; `onPaginationChange` maps back to `offset`/`pageSize` + (and resets `offset` to 0 on page-size change). `useMediaQuery({ limit: + pageSize, offset, … })`. `getRowId = getMediaRowId(row) => row.path` + (module-level, stable, **path-derived** so selection survives server-driven + paging). Old persisted state without `pageSize`/`columnVisibility` is + back-compat-merged with defaults on read. +- [x] **Shell migrated** — `Card`/`CardContent` → shadcn `Card`/`CardContent`; + `Grid` → `grid grid-cols-1 md:grid-cols-12 gap-4` (filter row: search + `md:col-span-4`, types/hdr/sort/order `md:col-span-2` each); MUI + `FormControl`/`InputLabel`/`MenuItem`/`Select` → shadcn `Select` family (file- + local `FilterSelect` helper, no empty-value items so no sentinel needed); + `TextField` → `Input` + `Label`; `LinearProgress` → shadcn `Progress` + (determinate) / pulsing bar (indeterminate — preserves the pre-rework + `variant="indeterminate"` affordance) via a file-local `BuildProgress`; + `Stack`/`Box` → `flex flex-col gap-*`; `Typography` → semantic text utilities; + `Alert` (the "No index built yet.") → shadcn `Alert` + `AlertDescription` + (`destructive`). Index-build controls + progress (Build / Stop=`destructive` / + Force stop=`outline`+`text-chart-3` cue, overall + per-library progress, items + processed/total, elapsed/eta, cancel-requested) preserved verbatim. MUI's + `useMediaQuery("(max-width:900px)")` is replaced by a local + `usePrefersSmallScreen` (`window.matchMedia`) that re-applies the pre-rework + mobile column-visibility override (forces the same 9 columns hidden on small + screens); the hook is defensive (`matchMedia` absent → desktop) so jsdom never + crashes. +- [x] **Component tests** — `src/pages/__tests__/Media.test.tsx` (6 tests): + (1) the toggleable column set equals the locked 15 (open the Columns dropdown, + collect `menuitemcheckbox` names, assert exactly the 15 + excludes + `__select__`); (2) the 15 data column headers render (incl. "Video codec"); + (3) row-click navigates to `/files?path=` (mocked `navigate`); + (4) toggling a row checkbox does NOT navigate (selection-cell stopPropagation + parity); (5) server-driven pagination total + page controls render + (`rowCount`-driven "2 rows", "Page 1 of 1", disabled Previous); (6) Build + disabled while running (Stop/Force-stop surface). Hooks mocked via `vi.mock`; + `usePersistentState` runs for real (localStorage cleared in `beforeEach`). +- [x] **Exit gate (7b)** — see gate table below (build/lint/test/node all green); + `@mui/x-data-grid` no longer imported by Media (or FileBrowser); **no sorting, + no resizing** (DataTable never wires `getSortedRowModel`/`enableColumnResizing`; + Media passes neither). + +### Files changed (this slice) + +Modified: + +- `frontend/src/pages/Media.tsx` (full rewrite; `@mui/x-data-grid` + + `@mui/material` → `DataTable` + shadcn/Tailwind) +- `openspec/changes/web-ui-rework/tasks.md` (Slice 7b checkboxes 0 → 6 `[x]`) + +Added (new): + +- `frontend/src/pages/__tests__/Media.test.tsx` (6 component tests) + +Untouched (no-unintended-edits respected): `frontend/src/components/ui/data-table.tsx` +(7a, **frozen — reused, not edited**), `FileBrowser.impl.tsx` (7a, already +TanStack), `Applications.tsx` + its test (test already `vi.mock("../Media")`, so +the child swap is transparent), every other page/component, `package.json` +(MUI/@emotion removal is slice 8), `docs/REQUIREMENTS.md` (slice 8). +`git status --porcelain` shows exactly `M frontend/src/pages/Media.tsx` + +`?? frontend/src/pages/__tests__/Media.test.tsx` (plus these two OpenSpec +artifacts). + +### Gate results (run from `frontend/`) — ALL GREEN + +| Gate | Command | Result | +|------|---------|--------| +| MUI-free | `grep -cE '@mui/(material\|icons-material\|x-data-grid)' src/pages/Media.tsx` | ✅ **0** | +| Build | `npm run build` (`tsc -b` + `vite build`) | ✅ exit 0 (chunk-size warning pre-existing, not a failure) | +| Lint | `npm run lint` (`eslint .`) | ✅ exit 0 — 0 errors; 2 warnings both **pre-existing** in `UsersPage.impl.tsx` (slice 6b, out of scope) | +| Test | `npm test` (`vitest run`) | ✅ **23 files / 64 tests** pass (baseline 22/58 → +1 file, +6 tests) | +| Legacy node | `node --test tests/*.test.mjs` | ✅ 4/4 pass | +| Visibility-only | (Media passes no sort/resize; DataTable 7a enforces) | ✅ no sorting, no resizing | + +> **Known flake (NOT a 7b regression):** under parallel load the full suite +> occasionally flakes `UsersPage.test.tsx` "opens compose and inserts bold markup" +> (slice-6b compose-test timing sensitivity, already documented in the 7a +> progress notes). It passes **7/7 in isolation across 2 runs**, and the **full +> suite is 23/23 on a re-run**. My diff touches Media.tsx + Media.test.tsx only; +> `UsersPage.impl.tsx` is unchanged this slice. + +> **`node --test tests` (no glob):** fails with `Cannot find module '.../tests'` +> — the **pre-existing** Node 22 invocation quirk noted since slice 1. The +> package's canonical node-suite command `node --test tests/*.test.mjs` is green +> (4/4). My diff does not touch `package.json` or `tests/`. + +### Design decisions / deviations + +1. **Default page size = 100 (parity).** The pre-rework Media used a fixed + `limit = 100`; `pageSize` defaults to `100` so initial paging behavior is + identical. The DataTable pager now also offers `[50, 100, 200]` (a feature + addition permitted by design §3.4's "pagination parity"), with `offset` + reset to 0 on any page-size change to keep the server offset sane. +2. **Column cells use default accessor rendering.** The task text suggested + `formatDuration`/`humanSize`/`formatBitrate`/HDR-badge formatting, but the + backend `display_media_row` **already** returns formatted strings for + `size`/`bitrate`/`date_added`/`hdr`/`video`/`resolution`, and the pre-rework + DataGrid rendered raw field values. Applying those formatters to already- + formatted strings would break parity (e.g. `humanSize("12.4 GB")`), so cells + render the raw field value (exact parity). `formatDuration` is still used for + build elapsed/eta (unchanged). +3. **`usePrefersSmallScreen` replaces MUI `useMediaQuery`.** Local + `window.matchMedia("(max-width: 900px)")` hook re-applies the pre-rework + mobile column-visibility override (same 9 columns hidden). It is defensive + (treats missing `matchMedia` as desktop) so jsdom renders the full 15-column + desktop layout in tests without editing the shared `src/test/setup.ts` (out + of scope). +4. **The standalone Prev/Next pager + `hideFooter`** from the DataGrid are gone — + the `DataTable` pagination footer (Previous/Next + "Page X of Y" + rows-per- + page + server total) replaces them (page-nav parity). A one-line "Showing X + of Y items | Page X of Y" caption is kept above the table for the per-page + count (the footer shows the server total only). +5. **No fixed 640px scroll box.** The pre-rework DataGrid was bounded to a 640px + scroll viewport; the TanStack/shadcn table now flows on the page (page-level + scroll). Behavior parity (paging/selection/row-click) is preserved; the + bounded-viewport visual is a minor deviation. The DataTable wrapper was not + modified (7a frozen). +6. **Row selection is NEW behavior** (the pre-rework Media had + `checkboxSelection={false}`). Design §3.4 / tasks.md explicitly require + `enableRowSelection` on Media, so a leading checkbox column is now present. + Selection state is local `useState` (resets on reload — no pre-rework parity + to preserve); `getRowId` is path-derived so any selection survives server + paging. + +### Slice boundary / PR + +Single sub-PR, well within the 400-line budget: ~1 file rewrite (~310 inserted +/ ~360 deleted net of the MUI teardown — the migration is more compact) + 1 new +test file (~210 lines). No 7b sub-split needed. The parent owns the commit/PR; +nothing committed here. + +### Top risk for slice 8 (cleanup + docs) + +**Slice 8 removes `@mui/material`, `@mui/icons-material`, `@mui/x-data-grid`, +`@emotion/react`, `@emotion/styled` from `package.json`** — but `UsersPage.impl.tsx` +still imports `@mui/icons-material` + `@mui/material` (slice **6b** is still +unchecked: compose dialog + 9 icons). Until 6b lands, `@mui/*` is NOT safe to +uninstall. Slice 8's grep gate (`grep -rlE '@mui/(material|icons-material|...)' +frontend/src` returns nothing) will **fail** until 6b completes. So **6b must +land before (or alongside) slice 8**. Secondary slice-8 risks: re-confirm +`recharts`/`d3`/`theme.ts` are still gone (carry-over), and the +`docs/REQUIREMENTS.md` write-up of the single design system + reconciled IA. + +### Remaining tasks (exact unchecked `- [ ]` lines) + +Slice 7b is complete (59/71). The remaining 12 unchecked lines are: + +- **Slice 6b** (5 — compose dialog, 9 lucide icons, rich-text behavior, compose + tests, exit gate 6a+6b): `UsersPage.impl.tsx` still uses `@mui/icons-material` + (9) + `@mui/material` (Dialog family + TextField/Divider/IconButton). +- **Slice 8** (7 — remove MUI/@emotion deps, grep-verify zero `@mui/*` + + `@emotion/*`, re-confirm `recharts`/`d3`/`theme.ts`, final gates, update + `docs/REQUIREMENTS.md`, exit gate). + +No slice-7b work remains. diff --git a/openspec/changes/web-ui-rework/tasks.md b/openspec/changes/web-ui-rework/tasks.md index d6aa02d..1581d4d 100644 --- a/openspec/changes/web-ui-rework/tasks.md +++ b/openspec/changes/web-ui-rework/tasks.md @@ -225,12 +225,12 @@ Each slice section restates this gate as its final task. ### Slice 7b — Media (server-driven pagination) -- [ ] Migrate `frontend/src/pages/Media.tsx` off `@mui/x-data-grid` onto `DataTable`: build `mediaColumns: ColumnDef[]` for the 15 locked columns (`title, series, season, episode, type, year, runtime_min, size, bitrate, hdr, video, resolution, date_added, library, path`). -- [ ] Render `DataTable` with `enableRowSelection`, `enablePagination` + `manualPagination` + `rowCount` (driven by `queryResult.total`), `onRowClick → navigate('/files?path=…')` (opens file browser at the item's path — preserved), and `enableColumnVisibilityToggle` (toggleable set must match the 15-column list above exactly). -- [ ] Lift pagination + column-visibility state into the existing `usePersistentState` media state and feed `useMediaQuery({ limit, offset, … })`; use a stable path-derived `getRowId` so selection survives server-driven paging. -- [ ] Also migrate the remaining `@mui/material` Media shell (Card/SectionCard/Grid/Select/Input/LinearProgress) to shadcn primitives + CSS grid + `Progress`; preserve index-build controls + progress (stop/force-stop). -- [ ] Add component tests asserting the toggleable column set equals the locked 15 and that row-click triggers the navigation callback. -- [ ] **Exit gate (7b):** Media on TanStack Table with pagination (server-driven, page-size + total-count + page-nav parity), row selection, row click → file browser, column-visibility parity; `@mui/x-data-grid` no longer imported anywhere; **no sorting, no resizing** present; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green; manual smoke of Media paging + row-click and FileBrowser row-click preview. +- [x] Migrate `frontend/src/pages/Media.tsx` off `@mui/x-data-grid` onto `DataTable`: build `mediaColumns: ColumnDef[]` for the 15 locked columns (`title, series, season, episode, type, year, runtime_min, size, bitrate, hdr, video, resolution, date_added, library, path`). +- [x] Render `DataTable` with `enableRowSelection`, `enablePagination` + `manualPagination` + `rowCount` (driven by `queryResult.total`), `onRowClick → navigate('/files?path=…')` (opens file browser at the item's path — preserved), and `enableColumnVisibilityToggle` (toggleable set must match the 15-column list above exactly). +- [x] Lift pagination + column-visibility state into the existing `usePersistentState` media state and feed `useMediaQuery({ limit, offset, … })`; use a stable path-derived `getRowId` so selection survives server-driven paging. +- [x] Also migrate the remaining `@mui/material` Media shell (Card/SectionCard/Grid/Select/Input/LinearProgress) to shadcn primitives + CSS grid + `Progress`; preserve index-build controls + progress (stop/force-stop). +- [x] Add component tests asserting the toggleable column set equals the locked 15 and that row-click triggers the navigation callback. +- [x] **Exit gate (7b):** Media on TanStack Table with pagination (server-driven, page-size + total-count + page-nav parity), row selection, row click → file browser, column-visibility parity; `@mui/x-data-grid` no longer imported anywhere; **no sorting, no resizing** present; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green; manual smoke of Media paging + row-click and FileBrowser row-click preview. ---