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.