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
+207 -149
View File
@@ -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 = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
// 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<UserStateItem>[] = [
{ 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) => (
<Badge variant={activityBadgeVariant(r.activity_label)}>
{r.activity_label}
</Badge>
),
},
{ 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() {
</div>
<div className="max-h-[660px] overflow-auto rounded-lg border">
<Table aria-label="Users table">
<TableHeader>
<TableRow>
<TableHead className={cn(thBase, "w-14 p-2")}>
<Checkbox
checked={allVisibleSelected}
aria-label="Select all visible users"
onCheckedChange={(checked) =>
toggleVisibleSelection(checked === true)
}
/>
</TableHead>
<TableHead className={thBase}>User</TableHead>
<TableHead className={thBase}>Email</TableHead>
<TableHead className={cn(thBase, "w-[132px] text-center")}>
Activity
</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-[140px] text-center md:table-cell",
)}
>
Type
</TableHead>
<TableHead className={cn(thBase, "w-[132px] text-center")}>
Jellyseerr
</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-[120px] text-center md:table-cell",
)}
>
Role
</TableHead>
<TableHead className={thBase}>Permissions</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-24 text-center md:table-cell",
)}
>
Reqs
</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-[120px] text-center md:table-cell",
)}
>
Contact
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows.map((row) => {
const linked =
row.jellyseerr_user_id !== null &&
row.jellyseerr_user_id !== undefined;
const checked = selectedIdSet.has(row.jellyfin_id);
return (
<TableRow
key={row.jellyfin_id}
data-state={
checked || selectedUser?.jellyfin_id === row.jellyfin_id
? "selected"
: undefined
}
className="cursor-pointer"
onClick={() => setSearchParams({ user: row.jellyfin_id })}
{isMobile ? (
<div className="p-3">
<MobileCardRow
rows={filteredRows}
fields={userCardFields}
getRowId={(r) => r.jellyfin_id}
onRowClick={(r) => setSearchParams({ user: r.jellyfin_id })}
actions={(r) => {
const checked = selectedIdSet.has(r.jellyfin_id);
return (
<Checkbox
checked={checked}
aria-label={`Select ${userLabel(r)}`}
className="mobile-touch-target"
onClick={(e) => e.stopPropagation()}
onCheckedChange={() =>
toggleUserSelected(r.jellyfin_id)
}
/>
);
}}
/>
</div>
) : (
<Table aria-label="Users table">
<TableHeader>
<TableRow>
<TableHead className={cn(thBase, "w-14 p-2")}>
<Checkbox
checked={allVisibleSelected}
aria-label="Select all visible users"
onCheckedChange={(checked) =>
toggleVisibleSelection(checked === true)
}
/>
</TableHead>
<TableHead className={thBase}>User</TableHead>
<TableHead className={thBase}>Email</TableHead>
<TableHead className={cn(thBase, "w-[132px] text-center")}>
Activity
</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-[140px] text-center md:table-cell",
)}
>
<TableCell className="w-14 p-2">
<Checkbox
checked={checked}
aria-label={`Select ${userLabel(row)}`}
onClick={(event) => event.stopPropagation()}
onCheckedChange={() =>
toggleUserSelected(row.jellyfin_id)
}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-3 min-w-0">
<Avatar className="size-9">
<AvatarImage
src={row.avatar || undefined}
alt={userLabel(row)}
/>
<AvatarFallback>
{userLabel(row).charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="truncate font-semibold leading-tight">
{userLabel(row)}
</div>
<div className="truncate text-xs text-muted-foreground">
{row.username && row.username !== row.display_name
? row.username
: row.jellyfin_id}
Type
</TableHead>
<TableHead className={cn(thBase, "w-[132px] text-center")}>
Jellyseerr
</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-[120px] text-center md:table-cell",
)}
>
Role
</TableHead>
<TableHead className={thBase}>Permissions</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-24 text-center md:table-cell",
)}
>
Reqs
</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-[120px] text-center md:table-cell",
)}
>
Contact
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows.map((row) => {
const linked =
row.jellyseerr_user_id !== null &&
row.jellyseerr_user_id !== undefined;
const checked = selectedIdSet.has(row.jellyfin_id);
return (
<TableRow
key={row.jellyfin_id}
data-state={
checked ||
selectedUser?.jellyfin_id === row.jellyfin_id
? "selected"
: undefined
}
className="cursor-pointer"
onClick={() =>
setSearchParams({ user: row.jellyfin_id })
}
>
<TableCell className="w-14 p-2">
<Checkbox
checked={checked}
aria-label={`Select ${userLabel(row)}`}
onClick={(event) => event.stopPropagation()}
onCheckedChange={() =>
toggleUserSelected(row.jellyfin_id)
}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-3 min-w-0">
<Avatar className="size-9">
<AvatarImage
src={row.avatar || undefined}
alt={userLabel(row)}
/>
<AvatarFallback>
{userLabel(row).charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="truncate font-semibold leading-tight">
{userLabel(row)}
</div>
<div className="truncate text-xs text-muted-foreground">
{row.username &&
row.username !== row.display_name
? row.username
: row.jellyfin_id}
</div>
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="truncate font-medium">
{row.email || "—"}
</div>
</TableCell>
<TableCell className="text-center">
<Badge
variant={activityBadgeVariant(row.activity_label)}
>
{row.activity_label}
</Badge>
</TableCell>
<TableCell className="hidden text-center md:table-cell">
<Badge variant="outline">{row.user_type_label}</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant={linked ? "success" : "secondary"}>
{linked
? `Linked #${row.jellyseerr_user_id}`
: "Base only"}
</Badge>
</TableCell>
<TableCell className="hidden text-center md:table-cell">
<Badge variant="outline">{row.role}</Badge>
</TableCell>
<TableCell className="whitespace-normal">
{row.permissions_label}
</TableCell>
<TableCell className="hidden text-center font-semibold md:table-cell">
{row.request_count ?? "—"}
</TableCell>
<TableCell className="hidden text-center md:table-cell">
<Badge
variant={row.contactable ? "success" : "secondary"}
>
{row.contactable ? "Yes" : "No"}
</Badge>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableCell>
<TableCell>
<div className="truncate font-medium">
{row.email || "—"}
</div>
</TableCell>
<TableCell className="text-center">
<Badge
variant={activityBadgeVariant(row.activity_label)}
>
{row.activity_label}
</Badge>
</TableCell>
<TableCell className="hidden text-center md:table-cell">
<Badge variant="outline">{row.user_type_label}</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant={linked ? "success" : "secondary"}>
{linked
? `Linked #${row.jellyseerr_user_id}`
: "Base only"}
</Badge>
</TableCell>
<TableCell className="hidden text-center md:table-cell">
<Badge variant="outline">{row.role}</Badge>
</TableCell>
<TableCell className="whitespace-normal">
{row.permissions_label}
</TableCell>
<TableCell className="hidden text-center font-semibold md:table-cell">
{row.request_count ?? "—"}
</TableCell>
<TableCell className="hidden text-center md:table-cell">
<Badge
variant={row.contactable ? "success" : "secondary"}
>
{row.contactable ? "Yes" : "No"}
</Badge>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</div>
</div>
@@ -766,7 +824,7 @@ export function UsersPage() {
<DialogContent
className={cn(
"flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl",
isMobile &&
isComposeMobile &&
"inset-0 max-h-none max-w-none translate-x-0 translate-y-0 rounded-none",
)}
>
@@ -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("<strong>");
});
});
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(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
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(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
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();
});
});