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