feat(frontend): slice 7b — Media on TanStack Table (server pagination)

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).
This commit is contained in:
Developer
2026-06-17 18:33:59 +00:00
parent e8b0f1144b
commit 3dc1b31fc3
5 changed files with 852 additions and 317 deletions
+370 -311
View File
@@ -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<MediaItem>[] = [
{ 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<string, boolean>;
};
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 (
<div className="flex flex-col gap-1.5">
<Label htmlFor={id}>{label}</Label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger id={id} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// 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 (
<div className="h-1 w-full animate-pulse rounded-full bg-muted-foreground/30" />
);
}
return <Progress value={Math.max(0, Math.min(100, value * 100))} />;
}
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<MediaTabState>(
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
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<MediaTabState>) =>
setMediaState((current) => ({ ...current, ...patch }));
const limit = 100;
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
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<MediaItem>[] = [
{ 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<PaginationState> = (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<VisibilityState> = (
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 (
<Stack spacing={2}>
<Stack
direction="row"
spacing={1.5}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="h5">Jellyfin</Typography>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel>Machine</InputLabel>
<div className="flex flex-col gap-4">
<div className="flex flex-row flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold">Jellyfin</h2>
<div className="flex flex-col gap-1.5">
<Label htmlFor="media-machine">Machine</Label>
<Select
label="Machine"
value={selectedMachineId}
onChange={(e) =>
onValueChange={(value) =>
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("machine_id", String(e.target.value));
next.set("machine_id", value);
return next;
},
{ replace: true },
)
}
>
{jellyfinMachines.map((machine) => (
<MenuItem key={machine.id} value={machine.id}>
{machine.name}
</MenuItem>
))}
<SelectTrigger id="media-machine" className="w-full md:w-[220px]">
<SelectValue placeholder="Select a machine" />
</SelectTrigger>
<SelectContent>
{jellyfinMachines.map((machine) => (
<SelectItem key={machine.id} value={machine.id}>
{machine.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</div>
{status?.exists ? (
<Typography variant="body2" color="text.secondary">
<p className="text-sm text-muted-foreground">
Index: {status.item_count.toLocaleString()} items
{status.updated_at_label
? ` | updated ${status.updated_at_label}`
: ""}
</Typography>
</p>
) : (
<Alert severity="warning" sx={{ py: 0 }}>
No index built yet.
<Alert variant="destructive" className="py-0">
<AlertDescription>No index built yet.</AlertDescription>
</Alert>
)}
{counts && (
<Typography variant="body2" color="text.secondary">
<p className="text-sm text-muted-foreground">
Library stats: {counts.movies.toLocaleString()} movies ·{" "}
{counts.series.toLocaleString()} series ·{" "}
{counts.episodes.toLocaleString()} episodes ·{" "}
{(libraries?.length ?? 0).toLocaleString()} libraries
</Typography>
</p>
)}
<Button
variant="outlined"
variant="outline"
onClick={() => buildIndex.mutate()}
disabled={
buildIndex.isPending || buildRunning || buildCancelRequested
@@ -242,8 +384,7 @@ export function Media() {
{buildRunning && (
<>
<Button
variant="outlined"
color="error"
variant="destructive"
onClick={() => stopBuildIndex.mutate()}
disabled={stopBuildIndex.isPending || buildCancelRequested}
>
@@ -252,8 +393,8 @@ export function Media() {
: "Stop build"}
</Button>
<Button
variant="outlined"
color="warning"
variant="outline"
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10"
onClick={() => forceStopBuildIndex.mutate()}
disabled={forceStopBuildIndex.isPending}
>
@@ -263,248 +404,166 @@ export function Media() {
</Button>
</>
)}
{(buildRunning || status?.build_error) && (
<Box sx={{ width: "100%", minWidth: 260, flexBasis: "100%" }}>
<Stack spacing={1}>
<Typography
variant="body2"
color={status?.build_error ? "error" : "text.secondary"}
>
{buildLabel ||
(buildRunning
? "Building media index..."
: status?.build_error || "")}
</Typography>
</div>
<Stack spacing={0.35}>
<Typography variant="caption" color="text.secondary">
Overall:{" "}
{buildProgress != null
? `${Math.round(buildProgress * 100)}%`
: "pending"}
{buildRunning
? ` • elapsed ${elapsedLabel} • eta ${etaLabel}`
: ""}
</Typography>
<LinearProgress
variant={
buildProgress != null ? "determinate" : "indeterminate"
}
value={
buildProgress != null
? Math.max(0, Math.min(100, buildProgress * 100))
: undefined
}
/>
<Typography variant="caption" color="text.secondary">
{status?.build_items_processed?.toLocaleString() ?? 0}/
{status?.build_items_total?.toLocaleString() ?? 0} items
</Typography>
</Stack>
{(buildRunning || status?.build_error) && (
<div className="flex w-full min-w-[260px] flex-col gap-2">
<p
className={
status?.build_error
? "text-sm text-destructive"
: "text-sm text-muted-foreground"
}
>
{buildLabel ||
(buildRunning
? "Building media index..."
: status?.build_error || "")}
</p>
<Stack spacing={0.35}>
<Typography variant="caption" color="text.secondary">
Current: {libraryLabel}
{buildRunning
? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}`
: ""}
</Typography>
<LinearProgress
variant={
buildLibraryProgress != null
? "determinate"
: "indeterminate"
}
value={
buildLibraryProgress != null
? Math.max(0, Math.min(100, buildLibraryProgress * 100))
: undefined
}
/>
<Typography variant="caption" color="text.secondary">
{status?.build_library_items_processed?.toLocaleString() ?? 0}
/{status?.build_library_items_total?.toLocaleString() ?? 0}{" "}
items
</Typography>
</Stack>
</Stack>
</Box>
)}
</Stack>
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground">
Overall:{" "}
{buildProgress != null
? `${Math.round(buildProgress * 100)}%`
: "pending"}
{buildRunning
? ` • elapsed ${elapsedLabel} • eta ${etaLabel}`
: ""}
</p>
<BuildProgress value={buildProgress} />
<p className="text-xs text-muted-foreground">
{status?.build_items_processed?.toLocaleString() ?? 0}/
{status?.build_items_total?.toLocaleString() ?? 0} items
</p>
</div>
<Card variant="outlined">
<CardContent>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 4 }}>
<TextField
fullWidth
label="Search"
size="small"
value={search}
onChange={(e) => {
updateMediaState({ search: e.target.value, offset: 0 });
}}
/>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Types</InputLabel>
<Select
label="Types"
value={types}
onChange={(e) => {
updateMediaState({ types: e.target.value, offset: 0 });
}}
>
<MenuItem value="Movie,Episode">Movies + Episodes</MenuItem>
<MenuItem value="Movie">Movies only</MenuItem>
<MenuItem value="Episode">Episodes only</MenuItem>
<MenuItem value="Movie,Episode,Video">All video</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>HDR</InputLabel>
<Select
label="HDR"
value={hdrFilter}
onChange={(e) => {
updateMediaState({ hdrFilter: e.target.value, offset: 0 });
}}
>
<MenuItem value="All">All</MenuItem>
<MenuItem value="HDR only">HDR only</MenuItem>
<MenuItem value="SDR/unknown only">SDR/unknown only</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Sort</InputLabel>
<Select
label="Sort"
value={sortKey}
onChange={(e) =>
updateMediaState({ sortKey: e.target.value })
}
>
{[
["title", "Title"],
["series", "Series"],
["size", "Size"],
["bitrate", "Bitrate"],
["runtime", "Runtime"],
["year", "Year"],
["date_added", "Date added"],
["resolution", "Resolution"],
].map(([k, l]) => (
<MenuItem key={k} value={k}>
{l}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Order</InputLabel>
<Select
label="Order"
value={sortOrder}
onChange={(e) =>
updateMediaState({ sortOrder: e.target.value })
}
>
<MenuItem value="Ascending">Ascending</MenuItem>
<MenuItem value="Descending">Descending</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground">
Current: {libraryLabel}
{buildRunning
? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}`
: ""}
</p>
<BuildProgress value={buildLibraryProgress} />
<p className="text-xs text-muted-foreground">
{status?.build_library_items_processed?.toLocaleString() ?? 0}/
{status?.build_library_items_total?.toLocaleString() ?? 0} items
</p>
</div>
</div>
)}
<Card>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-12">
<div className="col-span-1 flex flex-col gap-1.5 md:col-span-4">
<Label htmlFor="media-search">Search</Label>
<Input
id="media-search"
value={search}
onChange={(e) =>
updateMediaState({ search: e.target.value, offset: 0 })
}
/>
</div>
<div className="col-span-6 md:col-span-2">
<FilterSelect
id="media-types"
label="Types"
value={types}
onChange={(value) =>
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" },
]}
/>
</div>
<div className="col-span-6 md:col-span-2">
<FilterSelect
id="media-hdr"
label="HDR"
value={hdrFilter}
onChange={(value) =>
updateMediaState({ hdrFilter: value, offset: 0 })
}
options={[
{ value: "All", label: "All" },
{ value: "HDR only", label: "HDR only" },
{ value: "SDR/unknown only", label: "SDR/unknown only" },
]}
/>
</div>
<div className="col-span-6 md:col-span-2">
<FilterSelect
id="media-sort"
label="Sort"
value={sortKey}
onChange={(value) => 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" },
]}
/>
</div>
<div className="col-span-6 md:col-span-2">
<FilterSelect
id="media-order"
label="Order"
value={sortOrder}
onChange={(value) => updateMediaState({ sortOrder: value })}
options={[
{ value: "Ascending", label: "Ascending" },
{ value: "Descending", label: "Descending" },
]}
/>
</div>
</CardContent>
</Card>
{queryResult && (
<Typography variant="caption" color="text.secondary">
Showing {queryResult.items.length} of{" "}
{queryResult.total.toLocaleString()} items | Page {page} of{" "}
{totalPages}
</Typography>
<p className="text-xs text-muted-foreground">
Showing {queryResult.items.length} of {total.toLocaleString()} items |
Page {pageIndex + 1} of {totalPages}
</p>
)}
{status?.exists && (
<Box
sx={{
height: 640,
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 2,
}}
>
<DataGrid
rows={rows}
columns={columns}
loading={isLoading}
checkboxSelection={false}
disableRowSelectionOnClick
onRowClick={(params) => {
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
<div className="rounded-lg border bg-card">
<DataTable
columns={mediaColumns}
data={queryResult?.items ?? []}
getRowId={getMediaRowId}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
onRowClick={handleRowClick}
enableColumnVisibilityToggle
columnVisibility={effectiveColumnVisibility}
onColumnVisibilityChange={handleColumnVisibilityChange}
enablePagination
manualPagination
pagination={pagination}
onPaginationChange={handlePaginationChange}
pageSizeOptions={[50, 100, 200]}
rowCount={total}
emptyMessage={
isLoading
? "Loading media..."
: "No media items match these filters."
}
hideFooter
sx={{
"& .MuiDataGrid-columnHeaders": {
fontWeight: 700,
backgroundColor: "action.hover",
},
}}
/>
</Box>
</div>
)}
{queryResult && totalPages > 1 && (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Button
variant="outlined"
size="small"
onClick={() =>
updateMediaState({ offset: Math.max(0, offset - limit) })
}
disabled={page <= 1}
>
Prev
</Button>
<Typography variant="body2">
Page {page} / {totalPages}
</Typography>
<Button
variant="outlined"
size="small"
onClick={() => updateMediaState({ offset: offset + limit })}
disabled={page >= totalPages}
>
Next
</Button>
</Stack>
)}
</Stack>
</div>
);
}
+259
View File
@@ -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> = {},
): 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> = {},
): 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> = {}): 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(<Media />);
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(<Media />);
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(<Media />);
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(<Media />);
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(<Media />);
// 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(<Media />);
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();
});
});
+19
View File
@@ -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.
@@ -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 17a — **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<MediaItem>[]` 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=<encoded 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.
+6 -6
View File
@@ -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<MediaItem>[]` 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<MediaItem>[]` 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.
---