From 2eb649eceb98e7d00f6a1482e9779491808d9989 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 26 Jun 2026 13:24:19 +0000 Subject: [PATCH] Mobile Users + Backups tables: stacked cards + selection (Slice 5) Below md, the Users directory and the three Backups tables render as MobileCardRow cards: - UsersPage: display name primary; username/activity/email fields. Each card carries a selection checkbox (44px via mobile-touch-target) in the actions slot with stopPropagation so toggling selection does not open the drawer; card-body tap still opens the drawer. - BackupAlertsTable: alert message primary; severity/type/created fields; Acknowledge action preserved in actions slot. - BackupJobsTable: job name primary; source/schedule/last-status fields (joins latestRuns into a JobCardRow). - BackupRunsTable: run job_id primary; status/duration/size/started fields; status-filter Select renders above both layouts (preserved on mobile). Desktop (md+) is byte-for-byte identical for all four components -- the UsersPage diff is dominated by re-indenting the existing Table into the isMobile ternary else branch. Fix from Slice 5 review: MobileCardRow now renders the clickable card as
with Enter/Space keyboard handling instead of + ) : null + } + /> + ); + } + return (
diff --git a/frontend/src/components/BackupJobsTable.tsx b/frontend/src/components/BackupJobsTable.tsx index 400f016..884a58d 100644 --- a/frontend/src/components/BackupJobsTable.tsx +++ b/frontend/src/components/BackupJobsTable.tsx @@ -7,6 +7,11 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { + MobileCardRow, + type MobileCardField, +} from "@/components/ui/mobile-card"; +import { useIsMobile } from "../hooks/useIsMobile"; import type { BackupJob, BackupRun } from "../types/backups"; interface Props { @@ -41,7 +46,54 @@ function statusVariant(status: string): StatusVariant { return "secondary"; } +// Mobile card fields (spec R3.2): job name is primary; source/schedule/status +// give at-a-glance context. See OpenSpec change `mobile-responsive-parity`. +interface JobCardRow { + job: BackupJob; + status: string; + run_started: number | null; +} + +const jobCardFields: MobileCardField[] = [ + { key: "name", label: "Name", render: (r) => r.job.name, primary: true }, + { + key: "source", + label: "Source", + render: (r) => r.job.source ?? "—", + }, + { + key: "schedule", + label: "Schedule", + render: (r) => formatInterval(r.job.schedule_interval_seconds), + }, + { + key: "status", + label: "Last status", + render: (r) => {r.status}, + }, +]; + export default function BackupJobsTable({ jobs, latestRuns }: Props) { + const isMobile = useIsMobile(); + + if (isMobile) { + const cardRows: JobCardRow[] = jobs.map((job) => { + const run = latestRuns.get(job.id); + return { + job, + status: run?.status ?? "unknown", + run_started: run?.started_at ?? null, + }; + }); + return ( + r.job.id} + /> + ); + } + return (
diff --git a/frontend/src/components/BackupRunsTable.tsx b/frontend/src/components/BackupRunsTable.tsx index 2e2a82d..cad7b2d 100644 --- a/frontend/src/components/BackupRunsTable.tsx +++ b/frontend/src/components/BackupRunsTable.tsx @@ -15,6 +15,11 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { + MobileCardRow, + type MobileCardField, +} from "@/components/ui/mobile-card"; +import { useIsMobile } from "../hooks/useIsMobile"; import type { BackupRun } from "../types/backups"; interface Props { @@ -55,8 +60,35 @@ function statusVariant(status: string): StatusVariant { return "warning"; } +// Mobile card fields (spec R3.2): job_id is primary; status/duration/size/ +// started give the at-a-glance info. See OpenSpec change `mobile-responsive-parity`. +const runCardFields: MobileCardField[] = [ + { key: "job", label: "Job", render: (r) => r.job_id, primary: true }, + { + key: "status", + label: "Status", + render: (r) => {r.status}, + }, + { + key: "duration", + label: "Duration", + render: (r) => formatDuration(r.duration_ms), + }, + { + key: "size", + label: "Size", + render: (r) => formatBytes(r.bytes_transferred), + }, + { + key: "started", + label: "Started", + render: (r) => formatTimestamp(r.started_at), + }, +]; + export default function BackupRunsTable({ runs }: Props) { const [statusFilter, setStatusFilter] = useState("all"); + const isMobile = useIsMobile(); const filteredRuns = statusFilter === "all" @@ -77,34 +109,42 @@ export default function BackupRunsTable({ runs }: Props) { -
-
- - - Job - Status - Duration - Size - Started - - - - {filteredRuns.map((run) => ( - - {run.job_id} - - - {run.status} - - - {formatDuration(run.duration_ms)} - {formatBytes(run.bytes_transferred)} - {formatTimestamp(run.started_at)} + {isMobile ? ( + r.id} + /> + ) : ( +
+
+ + + Job + Status + Duration + Size + Started - ))} - -
-
+ + + {filteredRuns.map((run) => ( + + {run.job_id} + + + {run.status} + + + {formatDuration(run.duration_ms)} + {formatBytes(run.bytes_transferred)} + {formatTimestamp(run.started_at)} + + ))} + + +
+ )} ); } diff --git a/frontend/src/components/__tests__/BackupAlertsTable.test.tsx b/frontend/src/components/__tests__/BackupAlertsTable.test.tsx index 38daefc..69334ef 100644 --- a/frontend/src/components/__tests__/BackupAlertsTable.test.tsx +++ b/frontend/src/components/__tests__/BackupAlertsTable.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import BackupAlertsTable from "../BackupAlertsTable"; @@ -61,3 +61,46 @@ describe("BackupAlertsTable", () => { expect(screen.queryByRole("button", { name: "Acknowledge" })).toBeNull(); }); }); + +// jsdom lacks matchMedia; default to desktop so existing tests are unaffected. +function setMatchMedia(matches: boolean) { + window.matchMedia = ((query: string) => ({ + matches: query.includes("768") ? matches : false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +} + +beforeEach(() => setMatchMedia(false)); + +describe("BackupAlertsTable (mobile card layout — slice 5)", () => { + it("renders cards with message as primary below md", () => { + setMatchMedia(true); + render( + , + ); + expect(screen.getByText("Disk full")).toBeInTheDocument(); + expect(screen.getAllByText("Severity")).toHaveLength(1); + }); + + it("renders acknowledge action on card below md", async () => { + setMatchMedia(true); + const onAck = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Ack" })); + expect(onAck).toHaveBeenCalledWith("a1"); + }); +}); diff --git a/frontend/src/components/__tests__/BackupJobsTable.test.tsx b/frontend/src/components/__tests__/BackupJobsTable.test.tsx new file mode 100644 index 0000000..68a689c --- /dev/null +++ b/frontend/src/components/__tests__/BackupJobsTable.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import BackupJobsTable from "../BackupJobsTable"; +import type { BackupJob, BackupRun } from "../../types/backups"; + +function job(overrides: Partial = {}): BackupJob { + return { + id: "j1", + name: "nightly", + source: "/data", + target: "s3://bucket", + schedule_interval_seconds: 86400, + created_at: 1_700_000_000, + ...overrides, + }; +} + +function run(overrides: Partial = {}): BackupRun { + return { + id: "r1", + job_id: "j1", + started_at: 1_700_000_000, + ended_at: null, + status: "success", + bytes_transferred: 2048, + duration_ms: 1500, + error_message: null, + details_json: null, + created_at: 1_700_000_000, + ...overrides, + }; +} + +// jsdom lacks matchMedia; default to desktop so the table renders. +function setMatchMedia(matches: boolean) { + window.matchMedia = ((query: string) => ({ + matches: query.includes("768") ? matches : false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +} + +beforeEach(() => setMatchMedia(false)); + +describe("BackupJobsTable (desktop)", () => { + it("renders job name and schedule interval", () => { + render( + , + ); + expect(screen.getByText("nightly")).toBeInTheDocument(); + expect(screen.getByText("1d")).toBeInTheDocument(); + }); +}); + +describe("BackupJobsTable (mobile card layout — slice 5)", () => { + it("renders cards with job name as primary below md", () => { + setMatchMedia(true); + render( + + } + />, + ); + expect(screen.getByText("nightly")).toBeInTheDocument(); + expect(screen.getAllByText("Source")).toHaveLength(1); + expect(screen.getAllByText("Schedule")).toHaveLength(1); + expect(screen.getAllByText("Last status")).toHaveLength(1); + }); +}); diff --git a/frontend/src/components/__tests__/BackupRunsTable.test.tsx b/frontend/src/components/__tests__/BackupRunsTable.test.tsx index cadd440..40efcb1 100644 --- a/frontend/src/components/__tests__/BackupRunsTable.test.tsx +++ b/frontend/src/components/__tests__/BackupRunsTable.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import BackupRunsTable from "../BackupRunsTable"; import type { BackupRun } from "../../types/backups"; @@ -57,3 +57,29 @@ describe("BackupRunsTable", () => { expect(screen.getByText("2.0 KB")).toBeInTheDocument(); }); }); + +// jsdom lacks matchMedia; default to desktop so existing tests are unaffected. +function setMatchMedia(matches: boolean) { + window.matchMedia = ((query: string) => ({ + matches: query.includes("768") ? matches : false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +} + +beforeEach(() => setMatchMedia(false)); + +describe("BackupRunsTable (mobile card layout — slice 5)", () => { + it("renders cards with job_id as primary below md", () => { + setMatchMedia(true); + render(); + expect(screen.getByText("nightly")).toBeInTheDocument(); + expect(screen.getAllByText("Status")).toHaveLength(1); + expect(screen.getAllByText("Duration")).toHaveLength(1); + }); +}); diff --git a/frontend/src/components/ui/mobile-card.tsx b/frontend/src/components/ui/mobile-card.tsx index 758ec0e..aceb0ff 100644 --- a/frontend/src/components/ui/mobile-card.tsx +++ b/frontend/src/components/ui/mobile-card.tsx @@ -91,14 +91,21 @@ export function MobileCardRow({ if (onRowClick) { return ( - + ); } diff --git a/frontend/src/hooks/useIsMobile.ts b/frontend/src/hooks/useIsMobile.ts index 854a5a5..67b78d1 100644 --- a/frontend/src/hooks/useIsMobile.ts +++ b/frontend/src/hooks/useIsMobile.ts @@ -17,11 +17,17 @@ const MOBILE_QUERY = "(max-width: 768px)"; export function useIsMobile(): boolean { const [isMobile, setIsMobile] = useState( () => - typeof window !== "undefined" && window.matchMedia(MOBILE_QUERY).matches, + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia(MOBILE_QUERY).matches, ); useEffect(() => { - if (typeof window === "undefined") return; + if ( + typeof window === "undefined" || + typeof window.matchMedia !== "function" + ) + return; const mql = window.matchMedia(MOBILE_QUERY); const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); mql.addEventListener("change", handler); diff --git a/frontend/src/pages/UsersPage.impl.tsx b/frontend/src/pages/UsersPage.impl.tsx index cc962cd..31281c6 100644 --- a/frontend/src/pages/UsersPage.impl.tsx +++ b/frontend/src/pages/UsersPage.impl.tsx @@ -51,8 +51,13 @@ import { import { cn } from "@/lib/utils"; import { MetricCard } from "../components/MetricCard"; import { SessionActivityPanel } from "../components/SessionActivityPanel"; +import { + MobileCardRow, + type MobileCardField, +} from "@/components/ui/mobile-card"; import { useUsers } from "../hooks/useUsers"; import { useActivity } from "../hooks/useDashboard"; +import { useIsMobile } from "../hooks/useIsMobile"; import { useSendUserMessage } from "../hooks/useSendUserMessage"; import { useUserMessageQueueStatus } from "../hooks/useUserMessageQueueStatus"; import type { UserDirectoryItem } from "../types"; @@ -63,9 +68,9 @@ import { type UserStateItem, } from "../userState"; -// Replaces MUI `useMediaQuery` (a 6b-owned component) with a dependency-free -// matchMedia hook for the compose dialog's mobile fullScreen behavior. -function useIsMobile(query = "(max-width: 900px)") { +// Local breakpoint for the compose dialog (slice 6b uses 900px for fullScreen). +// The shared `useIsMobile` from hooks/ (768px) drives the directory table branch. +function useComposeViewport(query = "(max-width: 900px)") { const [mobile, setMobile] = useState(() => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia(query).matches @@ -102,11 +107,35 @@ function activityBadgeVariant( const DEFAULT_HTML_BODY = "

Hello,

Best,
Manage

"; +// Mobile card fields (spec R3.2): display name is primary; username, activity +// badge, and email give the at-a-glance info for scanning users on a phone. +// See OpenSpec change `mobile-responsive-parity`, tasks slice 5.1. +const userCardFields: MobileCardField[] = [ + { key: "name", label: "Name", render: (r) => userLabel(r), primary: true }, + { + key: "username", + label: "Username", + render: (r) => + r.username && r.username !== r.display_name ? r.username : r.jellyfin_id, + }, + { + key: "activity", + label: "Activity", + render: (r) => ( + + {r.activity_label} + + ), + }, + { key: "email", label: "Email", render: (r) => r.email || "—" }, +]; + export function UsersPage() { const { data, isError, error } = useUsers(); const { data: activity } = useActivity(); const queueStatusQuery = useUserMessageQueueStatus(); const sendUserMessage = useSendUserMessage(); + const isComposeMobile = useComposeViewport(); const isMobile = useIsMobile(); const [search, setSearch] = useState(""); const [searchParams, setSearchParams] = useSearchParams(); @@ -479,154 +508,183 @@ export function UsersPage() {
- - - - - - toggleVisibleSelection(checked === true) - } - /> - - User - Email - - Activity - - - Type - - - Jellyseerr - - - Role - - Permissions - - Reqs - - - Contact - - - - - {filteredRows.map((row) => { - const linked = - row.jellyseerr_user_id !== null && - row.jellyseerr_user_id !== undefined; - const checked = selectedIdSet.has(row.jellyfin_id); - return ( - setSearchParams({ user: row.jellyfin_id })} + {isMobile ? ( +
+ r.jellyfin_id} + onRowClick={(r) => setSearchParams({ user: r.jellyfin_id })} + actions={(r) => { + const checked = selectedIdSet.has(r.jellyfin_id); + return ( + e.stopPropagation()} + onCheckedChange={() => + toggleUserSelected(r.jellyfin_id) + } + /> + ); + }} + /> +
+ ) : ( +
+ + + + + toggleVisibleSelection(checked === true) + } + /> + + User + Email + + Activity + + - - event.stopPropagation()} - onCheckedChange={() => - toggleUserSelected(row.jellyfin_id) - } - /> - - -
- - - - {userLabel(row).charAt(0).toUpperCase()} - - -
-
- {userLabel(row)} -
-
- {row.username && row.username !== row.display_name - ? row.username - : row.jellyfin_id} + Type + + + Jellyseerr + + + Role + + Permissions + + Reqs + + + Contact + + + + + {filteredRows.map((row) => { + const linked = + row.jellyseerr_user_id !== null && + row.jellyseerr_user_id !== undefined; + const checked = selectedIdSet.has(row.jellyfin_id); + return ( + + setSearchParams({ user: row.jellyfin_id }) + } + > + + event.stopPropagation()} + onCheckedChange={() => + toggleUserSelected(row.jellyfin_id) + } + /> + + +
+ + + + {userLabel(row).charAt(0).toUpperCase()} + + +
+
+ {userLabel(row)} +
+
+ {row.username && + row.username !== row.display_name + ? row.username + : row.jellyfin_id} +
-
- - -
- {row.email || "—"} -
-
- - - {row.activity_label} - - - - {row.user_type_label} - - - - {linked - ? `Linked #${row.jellyseerr_user_id}` - : "Base only"} - - - - {row.role} - - - {row.permissions_label} - - - {row.request_count ?? "—"} - - - - {row.contactable ? "Yes" : "No"} - - - - ); - })} - -
+ + +
+ {row.email || "—"} +
+
+ + + {row.activity_label} + + + + {row.user_type_label} + + + + {linked + ? `Linked #${row.jellyseerr_user_id}` + : "Base only"} + + + + {row.role} + + + {row.permissions_label} + + + {row.request_count ?? "—"} + + + + {row.contactable ? "Yes" : "No"} + + + + ); + })} + + + )}
@@ -766,7 +824,7 @@ export function UsersPage() { diff --git a/frontend/src/pages/__tests__/UsersPage.test.tsx b/frontend/src/pages/__tests__/UsersPage.test.tsx index 3bebc5e..a7544b5 100644 --- a/frontend/src/pages/__tests__/UsersPage.test.tsx +++ b/frontend/src/pages/__tests__/UsersPage.test.tsx @@ -9,8 +9,10 @@ import type { UserDirectoryResponse, } from "../../types"; -// jsdom has no window.matchMedia; MUI `useMediaQuery` (still used by the -// compose dialog, slice 6b) must not blow up during render. Stub to "desktop". +// jsdom has no window.matchMedia; the shared `useIsMobile` hook and the +// compose dialog viewport hook must not blow up during render. Stub to +// "desktop" (matches: false) by default; the slice-5 describe block flips it +// to mobile for card-layout assertions. beforeEach(() => { if (!window.matchMedia) { window.matchMedia = ((query: string) => ({ @@ -283,3 +285,61 @@ describe("UsersPage (slice 6b — compose dialog formatting actions)", () => { expect(body.value).toContain(""); }); }); + + describe("UsersPage (mobile card layout — slice 5)", () => { + beforeEach(() => { + window.matchMedia = ((query: string) => ({ + matches: query.includes("768"), + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + }); + + it("renders user cards with display name as primary below md", () => { + users = [ + userFixture({ jellyfin_id: "u1", display_name: "Alice" }), + userFixture({ + jellyfin_id: "u2", + username: "bob", + display_name: "Bob", + }), + ]; + + render( + + + , + ); + + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("Bob")).toBeInTheDocument(); + // Activity field label should appear per card. + expect(screen.getAllByText("Activity")).toHaveLength(2); + }); + + it("toggles selection from the card checkbox without opening the drawer", async () => { + users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })]; + render( + + + , + ); + + const checkbox = screen.getByRole("checkbox", { + name: /Select Alice/i, + }); + expect(checkbox).toHaveAttribute("data-state", "unchecked"); + + await userEvent.click(checkbox); + expect(checkbox).toHaveAttribute("data-state", "checked"); + + // Drawer stays closed: the session-panel stub only renders when the + // drawer opens via a card-body tap, not via the checkbox. + expect(screen.queryByTestId("session-panel-stub")).not.toBeInTheDocument(); + }); +});