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
<div role=button tabIndex=0> with Enter/Space keyboard handling instead
of <button>, so nesting a Radix Checkbox (which renders a <button>) in
the actions slot produces valid HTML. The desktop-parity argument for
<button>-in-<button> did not hold (desktop rows are <tr>, not buttons).

Cross-cutting: useIsMobile hardened with typeof window.matchMedia guard
(safe in real browsers; only changes jsdom crash -> false). The file-local
900px compose hook was renamed useComposeViewport to avoid collision with
the shared 768px useIsMobile.

Tests: BackupJobsTable test file added (was untested), UsersPage mobile
selection round-trip + stopPropagation, mobile card render across all
four components. 105 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 5).
This commit is contained in:
Developer
2026-06-26 13:24:19 +00:00
parent 2076ab76fa
commit 2eb649eceb
10 changed files with 606 additions and 186 deletions
@@ -8,6 +8,11 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
MobileCardRow,
type MobileCardField,
} from "@/components/ui/mobile-card";
import { useIsMobile } from "../hooks/useIsMobile";
import type { BackupAlert } from "../types/backups";
interface Props {
@@ -29,7 +34,51 @@ function severityVariant(severity: string): SeverityVariant {
return severity === "critical" ? "destructive" : "warning";
}
// Mobile card fields (spec R3.2): message is the primary identifier;
// severity/type/created give the at-a-glance info. See OpenSpec change
// `mobile-responsive-parity`, tasks slice 5.2.
const alertCardFields: MobileCardField<BackupAlert>[] = [
{ key: "message", label: "Message", render: (a) => a.message, primary: true },
{
key: "severity",
label: "Severity",
render: (a) => (
<Badge variant={severityVariant(a.severity)}>{a.severity}</Badge>
),
},
{ key: "type", label: "Type", render: (a) => a.alert_type },
{
key: "created",
label: "Created",
render: (a) => formatTimestamp(a.created_at),
},
];
export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) {
const isMobile = useIsMobile();
if (isMobile) {
return (
<MobileCardRow
rows={alerts}
fields={alertCardFields}
getRowId={(a) => a.id}
actions={(a) =>
!a.acknowledged ? (
<Button
size="sm"
variant="outline"
className="mobile-touch-target"
onClick={() => onAcknowledge(a.id)}
>
Ack
</Button>
) : null
}
/>
);
}
return (
<div className="overflow-hidden rounded-lg border border-border">
<Table aria-label="Backup alerts">
@@ -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<JobCardRow>[] = [
{ 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) => <Badge variant={statusVariant(r.status)}>{r.status}</Badge>,
},
];
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 (
<MobileCardRow
rows={cardRows}
fields={jobCardFields}
getRowId={(r) => r.job.id}
/>
);
}
return (
<div className="overflow-hidden rounded-lg border border-border">
<Table aria-label="Backup jobs">
+67 -27
View File
@@ -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<BackupRun>[] = [
{ key: "job", label: "Job", render: (r) => r.job_id, primary: true },
{
key: "status",
label: "Status",
render: (r) => <Badge variant={statusVariant(r.status)}>{r.status}</Badge>,
},
{
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<string>("all");
const isMobile = useIsMobile();
const filteredRuns =
statusFilter === "all"
@@ -77,34 +109,42 @@ export default function BackupRunsTable({ runs }: Props) {
</SelectContent>
</Select>
<div className="overflow-hidden rounded-lg border border-border">
<Table aria-label="Backup runs">
<TableHeader>
<TableRow className="bg-card hover:bg-card">
<TableHead>Job</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Size</TableHead>
<TableHead>Started</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRuns.map((run) => (
<TableRow key={run.id}>
<TableCell>{run.job_id}</TableCell>
<TableCell>
<Badge variant={statusVariant(run.status)}>
{run.status}
</Badge>
</TableCell>
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
{isMobile ? (
<MobileCardRow
rows={filteredRuns}
fields={runCardFields}
getRowId={(r) => r.id}
/>
) : (
<div className="overflow-hidden rounded-lg border border-border">
<Table aria-label="Backup runs">
<TableHeader>
<TableRow className="bg-card hover:bg-card">
<TableHead>Job</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Size</TableHead>
<TableHead>Started</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TableHeader>
<TableBody>
{filteredRuns.map((run) => (
<TableRow key={run.id}>
<TableCell>{run.job_id}</TableCell>
<TableCell>
<Badge variant={statusVariant(run.status)}>
{run.status}
</Badge>
</TableCell>
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}
@@ -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(
<BackupAlertsTable
alerts={[alert({ id: "m1", message: "Disk full" })]}
onAcknowledge={vi.fn()}
/>,
);
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(
<BackupAlertsTable
alerts={[alert({ id: "a1", acknowledged: false })]}
onAcknowledge={onAck}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Ack" }));
expect(onAck).toHaveBeenCalledWith("a1");
});
});
@@ -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> = {}): 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> = {}): 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(
<BackupJobsTable
jobs={[job({ name: "nightly", schedule_interval_seconds: 86400 })]}
latestRuns={new Map()}
/>,
);
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(
<BackupJobsTable
jobs={[job({ id: "j1", name: "nightly", source: "/data" })]}
latestRuns={
new Map([["j1", run({ status: "success" })]]) as Map<string, BackupRun>
}
/>,
);
expect(screen.getByText("nightly")).toBeInTheDocument();
expect(screen.getAllByText("Source")).toHaveLength(1);
expect(screen.getAllByText("Schedule")).toHaveLength(1);
expect(screen.getAllByText("Last status")).toHaveLength(1);
});
});
@@ -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(<BackupRunsTable runs={[run({ id: "r1", job_id: "nightly" })]} />);
expect(screen.getByText("nightly")).toBeInTheDocument();
expect(screen.getAllByText("Status")).toHaveLength(1);
expect(screen.getAllByText("Duration")).toHaveLength(1);
});
});
+11 -4
View File
@@ -91,14 +91,21 @@ export function MobileCardRow<T>({
if (onRowClick) {
return (
<button
<div
key={rowKey}
type="button"
role="button"
tabIndex={0}
onClick={() => onRowClick(row)}
className="mobile-touch-target min-h-11 w-full rounded-lg border border-border bg-card p-3 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onRowClick(row);
}
}}
className="mobile-touch-target min-h-11 w-full cursor-pointer rounded-lg border border-border bg-card p-3 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{body}
</button>
</div>
);
}