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:
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user