Mobile Media table: stacked cards + mobile pagination (Slice 3)
Below md, the Media DataTable renders as MobileCardRow cards: title as primary, plus size/HDR/library/year (3-5 fields, null-safe). Card tap navigates to /files?path=... (same handleRowClick as desktop). The TanStack column-visibility toggle is absent below md (the card picks the fields). Pagination is preserved via a standalone MediaMobilePagination component that mirrors DataTablePagination semantics (rows count, page-size select, page indicator, prev/next with correct disabled states) off the raw PaginationState. The duplication is flagged tech debt -- extracting a shared TablePagination is a follow-up, out of scope for this slice. Desktop (md+) is byte-for-byte identical: the isMobile===false branch renders the same DataTable with the same props. enableRowSelection state is vestigial (no batch consumer on either path); navigation is the correct primary mobile interaction. Tests: 5 new covering mobile cards + hidden column toggle + pagination + card-tap navigation, and desktop DataTable + column toggle. matchMedia mocked per-breakpoint. 94 tests pass; lint/build green. Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 3).
This commit is contained in:
+165
-27
@@ -9,6 +9,10 @@ import type {
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import {
|
||||
MobileCardRow,
|
||||
type MobileCardField,
|
||||
} from "@/components/ui/mobile-card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -31,6 +35,7 @@ import {
|
||||
useForceStopBuildIndex,
|
||||
} from "../hooks/useMedia";
|
||||
import { usePersistentState } from "../hooks/usePersistentState";
|
||||
import { useIsMobile } from "../hooks/useIsMobile";
|
||||
import type { MediaItem } from "../types";
|
||||
import { useServiceInstances } from "../hooks/useServices";
|
||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||
@@ -75,6 +80,116 @@ function getMediaRowId(row: MediaItem): string {
|
||||
return row.path;
|
||||
}
|
||||
|
||||
// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
|
||||
// Title is the primary identifier; size/HDR/library/year give the at-a-glance
|
||||
// tech + context info a user scanning the library on a phone needs. Runtime,
|
||||
// bitrate, resolution, codec etc. live on the desktop table only.
|
||||
const mediaCardFields: MobileCardField<MediaItem>[] = [
|
||||
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
|
||||
{ key: "size", label: "Size", render: (r) => r.size || "-" },
|
||||
{
|
||||
key: "hdr",
|
||||
label: "HDR",
|
||||
render: (r) => r.hdr || "-",
|
||||
},
|
||||
{ key: "library", label: "Library", render: (r) => r.library || "-" },
|
||||
{
|
||||
key: "year",
|
||||
label: "Year",
|
||||
render: (r) => (r.year != null ? String(r.year) : "-"),
|
||||
},
|
||||
];
|
||||
|
||||
// Standalone pagination for the mobile card layout. The DataTable renders its
|
||||
// own pagination internally; this mirrors that UI (rows count, page-size
|
||||
// select, page indicator, prev/next) but works off the raw pagination state
|
||||
// instead of a TanStack table instance. See spec R3.3.
|
||||
function MediaMobilePagination({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
totalRows,
|
||||
pageCount,
|
||||
onPaginationChange,
|
||||
}: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
pageSizeOptions: number[];
|
||||
totalRows: number;
|
||||
pageCount: number;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 p-4 text-sm">
|
||||
<div className="text-muted-foreground">
|
||||
{`${totalRows} row${totalRows === 1 ? "" : "s"}`}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">Rows per page</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) =>
|
||||
onPaginationChange(() => ({
|
||||
pageIndex: 0,
|
||||
pageSize: Number(value),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-[70px]"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map((option) => (
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
Page {pageIndex + 1} of {pageCount}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onPaginationChange((prev) => ({
|
||||
...prev,
|
||||
pageIndex: Math.max(0, prev.pageIndex - 1),
|
||||
}))
|
||||
}
|
||||
disabled={pageIndex <= 0}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onPaginationChange((prev) => ({
|
||||
...prev,
|
||||
pageIndex: prev.pageIndex + 1,
|
||||
}))
|
||||
}
|
||||
disabled={pageIndex >= pageCount - 1}
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
||||
const SMALL_BREAKPOINT = "(max-width: 900px)";
|
||||
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
|
||||
@@ -178,6 +293,7 @@ export function Media() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const isSmall = usePrefersSmallScreen();
|
||||
const isMobile = useIsMobile();
|
||||
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
||||
const selectedServiceId =
|
||||
searchParams.get("jellyfin_service_id") ||
|
||||
@@ -532,33 +648,55 @@ export function Media() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status?.exists && (
|
||||
<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."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{status?.exists &&
|
||||
(isMobile ? (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="p-4">
|
||||
<MobileCardRow
|
||||
rows={queryResult?.items ?? []}
|
||||
fields={mediaCardFields}
|
||||
getRowId={getMediaRowId}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
{queryResult && (
|
||||
<MediaMobilePagination
|
||||
pageIndex={pageIndex}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={[50, 100, 200]}
|
||||
totalRows={total}
|
||||
pageCount={totalPages}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<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."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,7 +129,11 @@ vi.mock("../../hooks/useDashboard", () => ({
|
||||
|
||||
// usePersistentState reads/writes localStorage; clear between tests so the
|
||||
// offset/pageSize/columnVisibility state never leaks across cases.
|
||||
// matchMedia must be stubbed so useIsMobile (md:768px) and usePrefersSmallScreen
|
||||
// (900px) resolve without TypeError in jsdom. Default to desktop (matches:false)
|
||||
// so the DataTable path renders by default; mobile tests override.
|
||||
beforeEach(() => {
|
||||
setMatchMedia(false);
|
||||
window.localStorage.clear();
|
||||
navigate.mockClear();
|
||||
status = statusFixture();
|
||||
@@ -152,6 +156,30 @@ beforeEach(() => {
|
||||
};
|
||||
});
|
||||
|
||||
/** Stub window.matchMedia so useIsMobile / usePrefersSmallScreen resolve in jsdom. */
|
||||
function setMatchMedia(matches: boolean) {
|
||||
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes("768") ? matches : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: (
|
||||
_evt: string,
|
||||
listener: (e: MediaQueryListEvent) => void,
|
||||
) => listeners.push(listener),
|
||||
removeEventListener: (
|
||||
_evt: string,
|
||||
listener: (e: MediaQueryListEvent) => void,
|
||||
) => {
|
||||
const idx = listeners.indexOf(listener);
|
||||
if (idx >= 0) listeners.splice(idx, 1);
|
||||
},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
|
||||
it("exposes exactly the 15 locked toggleable columns", async () => {
|
||||
render(<Media />);
|
||||
@@ -261,3 +289,68 @@ describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", (
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Media (mobile card layout — slice 3)", () => {
|
||||
it("renders cards with the title as primary below md", () => {
|
||||
setMatchMedia(true);
|
||||
render(<Media />);
|
||||
|
||||
// Card titles render (primary field).
|
||||
expect(screen.getByText("Inception")).toBeInTheDocument();
|
||||
expect(screen.getByText("Matrix")).toBeInTheDocument();
|
||||
|
||||
// Card field labels render (at least once per row).
|
||||
expect(screen.getAllByText("Size").length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText("HDR").length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText("Library").length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText("Year").length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Desktop table headers do NOT render on mobile.
|
||||
expect(screen.queryByRole("columnheader", { name: "Title" })).toBeNull();
|
||||
expect(screen.queryByRole("columnheader", { name: "Bitrate" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the column-visibility toggle below md", () => {
|
||||
setMatchMedia(true);
|
||||
render(<Media />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Columns/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders pagination controls below the cards on mobile", () => {
|
||||
setMatchMedia(true);
|
||||
render(<Media />);
|
||||
|
||||
expect(screen.getByText("2 rows")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous page" }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Next page" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates to the file browser when a card is tapped on mobile", async () => {
|
||||
setMatchMedia(true);
|
||||
render(<Media />);
|
||||
|
||||
await userEvent.click(screen.getByText("Inception"));
|
||||
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the DataTable (not cards) at desktop width", () => {
|
||||
render(<Media />);
|
||||
|
||||
// Desktop column headers render.
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "Title" }),
|
||||
).toBeInTheDocument();
|
||||
// Column-visibility toggle is present.
|
||||
expect(screen.getByRole("button", { name: /Columns/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user