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