Mobile dashboard layout: single column + section anchors (Slice 2)

Below md, widgets render in a single column grouped by section
(Observability / Media / Backups / Custom) with a horizontally-scrollable
anchor pill bar that smooth-scrolls to each section. Empty sections are
omitted from both the bar and the list. scroll-mt-16 keeps the sticky
TopBar from covering section headings.

Section mapping: observability (alertmanager/prometheus/grafana services),
media (jellyfin), backups (builtin backups widget), custom (static,
ssh_tasks, nextcloud, unknown, orphans). Within each section the user's
configured sort order is preserved.

Desktop (md+) is byte-for-byte unchanged -- the isMobile===false branch
emits the original visibleWidgets.map(...) sequence with no wrapper.
useServiceInstances() is cache-shared with WidgetInstanceCard (same
TanStack key), so no extra network requests.

Tests: 3 new (6 total) covering mobile single-column + anchors, desktop
non-regression, and scrollIntoView jump. matchMedia mocked per-breakpoint.
89 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R7, tasks slice 2).
This commit is contained in:
Developer
2026-06-26 12:25:42 +00:00
parent 688a18af22
commit c447dfe68d
2 changed files with 303 additions and 7 deletions
+130 -4
View File
@@ -1,5 +1,11 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Activity,
DatabaseBackup,
LayoutDashboard,
Monitor,
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -26,13 +32,122 @@ import {
useSaveDashboardShortcut,
} from "../hooks/useDashboard";
import { useWidgetInstances } from "../hooks/useWidgets";
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
import { useServiceInstances } from "../hooks/useServices";
import { useIsMobile } from "../hooks/useIsMobile";
import type {
DashboardShortcut,
DashboardShortcutInput,
ServiceInstance,
WidgetInstance,
} from "../types";
import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { DialogFooter } from "../components/DialogFooter";
import { WidgetInstanceCard } from "../components/WidgetInstance";
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
// --- Mobile section grouping (spec R7.2) ---
const SECTION_ORDER = ["observability", "media", "backups", "custom"] as const;
type SectionId = (typeof SECTION_ORDER)[number];
const SECTION_META: Record<
SectionId,
{ label: string; icon: typeof Activity }
> = {
observability: { label: "Observability", icon: Activity },
media: { label: "Media", icon: Monitor },
backups: { label: "Backups", icon: DatabaseBackup },
custom: { label: "Custom", icon: LayoutDashboard },
};
const OBSERVABILITY_TYPES = new Set(["alertmanager", "prometheus", "grafana"]);
function widgetSection(
widget: WidgetInstance,
services: ServiceInstance[],
): SectionId {
if (!widget.service_id) {
return widget.widget_kind === "backups" ? "backups" : "custom";
}
const service = services.find((s) => s.id === widget.service_id);
const serviceType = service?.service_type ?? "";
if (OBSERVABILITY_TYPES.has(serviceType)) return "observability";
if (serviceType === "jellyfin") return "media";
return "custom";
}
function groupWidgetsBySection(
widgets: WidgetInstance[],
services: ServiceInstance[],
): { id: SectionId; widgets: WidgetInstance[] }[] {
const groups: Record<SectionId, WidgetInstance[]> = {
observability: [],
media: [],
backups: [],
custom: [],
};
for (const w of widgets) {
groups[widgetSection(w, services)].push(w);
}
return SECTION_ORDER.map((id) => ({ id, widgets: groups[id] })).filter(
(s) => s.widgets.length > 0,
);
}
function MobileWidgetSections({
sections,
}: {
sections: { id: SectionId; widgets: WidgetInstance[] }[];
}) {
return (
<>
{/* Anchor bar — horizontally scrollable pills (spec R7.2, md:hidden) */}
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
{sections.map((section) => {
const meta = SECTION_META[section.id];
const Icon = meta.icon;
return (
<button
key={section.id}
type="button"
className="mobile-touch-target inline-flex shrink-0 items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() =>
document
.getElementById(`dashboard-section-${section.id}`)
?.scrollIntoView({
behavior: "smooth",
block: "start",
})
}
>
<Icon className="size-3.5" />
{meta.label}
</button>
);
})}
</div>
{/* Sectioned widgets — single column (spec R7.1) */}
<div className="grid grid-cols-1 gap-4">
{sections.map((section) => (
<section
key={section.id}
id={`dashboard-section-${section.id}`}
className="scroll-mt-16 flex flex-col gap-2"
>
<h3 className="text-sm font-semibold text-muted-foreground">
{SECTION_META[section.id].label}
</h3>
{section.widgets.map((widget) => (
<WidgetInstanceCard key={widget.id} widget={widget} />
))}
</section>
))}
</div>
</>
);
}
function emptyShortcut(): DashboardShortcutInput {
return {
id: null,
@@ -336,6 +451,8 @@ export function Dashboard() {
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
const { data: widgetInstances = [] } = useWidgetInstances();
const { data: services = [] } = useServiceInstances();
const isMobile = useIsMobile();
const visibleWidgets = useMemo(
() =>
@@ -345,6 +462,11 @@ export function Dashboard() {
[widgetInstances],
);
const mobileSections = useMemo(
() => groupWidgetsBySection(visibleWidgets, services),
[visibleWidgets, services],
);
const openCreateShortcut = () => {
setShortcutDraft(emptyShortcut());
setShortcutDialogOpen(true);
@@ -417,9 +539,13 @@ export function Dashboard() {
)}
</SectionCard>
{visibleWidgets.map((widget) => (
<WidgetInstanceCard key={widget.id} widget={widget} />
))}
{isMobile && mobileSections.length > 0 ? (
<MobileWidgetSections sections={mobileSections} />
) : (
visibleWidgets.map((widget) => (
<WidgetInstanceCard key={widget.id} widget={widget} />
))
)}
<ShortcutDialog
open={shortcutDialogOpen}
+173 -3
View File
@@ -2,12 +2,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Dashboard } from "../Dashboard";
import type { DashboardShortcut } from "../../types";
import type {
DashboardShortcut,
ServiceInstance,
WidgetInstance,
} from "../../types";
// Stub the composed widgets so the test exercises Dashboard's own behavior
// (shortcut CRUD) without rendering widgets or their data queries.
vi.mock("../../components/WidgetInstance", () => ({
WidgetInstanceCard: () => <div data-testid="widget-stub" />,
WidgetInstanceCard: ({ widget }: { widget: { title: string } }) => (
<div data-testid="widget-stub">{widget.title}</div>
),
}));
vi.mock("../../components/WidgetConfigDialog", () => ({
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
@@ -21,8 +27,15 @@ vi.mock("react-router-dom", () => ({
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [] }),
}));
// --- Dynamic mock state (reset in beforeEach) ---
let widgetInstances: WidgetInstance[] = [];
let serviceInstances: ServiceInstance[] = [];
vi.mock("../../hooks/useWidgets", () => ({
useWidgetInstances: () => ({ data: [] }),
useWidgetInstances: () => ({ data: widgetInstances }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: serviceInstances }),
}));
const saveShortcutMutate = vi.fn().mockResolvedValue({});
@@ -62,8 +75,26 @@ beforeEach(() => {
saveShortcutMutate.mockClear();
deleteShortcutMutate.mockClear();
shortcuts = [];
widgetInstances = [];
serviceInstances = [];
setMatchMedia(false); // desktop by default
});
// --- matchMedia mock for useIsMobile (jsdom has no native matchMedia) ---
function setMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches: query === "(max-width: 768px)" ? matches : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
describe("Dashboard", () => {
it("shows the empty-state alert when there are no shortcuts", () => {
render(<Dashboard />);
@@ -111,3 +142,142 @@ describe("Dashboard", () => {
expect(saved.shortcut_type).toBe("website");
});
});
// --- Mobile layout tests (spec R7.1, R7.2) ---
function makeWidget(overrides: Partial<WidgetInstance> = {}): WidgetInstance {
return {
id: "w1",
service_id: null,
widget_kind: "static",
title: "Widget 1",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
...overrides,
};
}
function makeService(
overrides: Partial<ServiceInstance> = {},
): ServiceInstance {
return {
id: "svc1",
service_type: "jellyfin",
name: "Jellyfin",
config: {},
secrets_set: {},
enabled: true,
created_at: 0,
updated_at: 0,
...overrides,
};
}
describe("Dashboard mobile layout", () => {
it("renders widgets in a single column with an anchor bar below md", () => {
setMatchMedia(true); // mobile
serviceInstances = [
makeService({ id: "graf", service_type: "grafana" }),
makeService({ id: "jelly", service_type: "jellyfin" }),
];
widgetInstances = [
makeWidget({
id: "w-obs",
service_id: "graf",
widget_kind: "link",
title: "Grafana Link",
}),
makeWidget({
id: "w-media",
service_id: "jelly",
widget_kind: "activity",
title: "Jellyfin Activity",
}),
makeWidget({
id: "w-backup",
service_id: null,
widget_kind: "backups",
title: "Backup Summary",
}),
];
render(<Dashboard />);
// Anchor bar pills are visible for populated sections (each label appears
// in both the pill and the section heading, so use getAllByText).
expect(screen.getAllByText("Observability").length).toBeGreaterThanOrEqual(
1,
);
expect(screen.getAllByText("Media").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Backups").length).toBeGreaterThanOrEqual(1);
// Sections with no widgets are NOT rendered.
expect(screen.queryByText("Custom")).not.toBeInTheDocument();
// Each widget renders.
expect(screen.getByText("Grafana Link")).toBeInTheDocument();
expect(screen.getByText("Jellyfin Activity")).toBeInTheDocument();
expect(screen.getByText("Backup Summary")).toBeInTheDocument();
});
it("does NOT render the anchor bar at desktop width", () => {
setMatchMedia(false); // desktop
serviceInstances = [makeService({ id: "graf", service_type: "grafana" })];
widgetInstances = [
makeWidget({
id: "w-obs",
service_id: "graf",
widget_kind: "link",
title: "Grafana Link",
}),
];
render(<Dashboard />);
// Widget renders (flat list, no section wrappers).
expect(screen.getByText("Grafana Link")).toBeInTheDocument();
// No section headings or anchor pills on desktop.
expect(screen.queryByText("Observability")).not.toBeInTheDocument();
expect(screen.queryByText("Media")).not.toBeInTheDocument();
});
it("anchor bar pills jump to their section via scrollIntoView", async () => {
setMatchMedia(true); // mobile
serviceInstances = [
makeService({ id: "graf", service_type: "grafana" }),
makeService({ id: "jelly", service_type: "jellyfin" }),
];
widgetInstances = [
makeWidget({
id: "w-obs",
service_id: "graf",
widget_kind: "link",
title: "Grafana Link",
}),
makeWidget({
id: "w-media",
service_id: "jelly",
widget_kind: "activity",
title: "Jellyfin Activity",
}),
];
const scrollSpy = vi.spyOn(Element.prototype, "scrollIntoView");
render(<Dashboard />);
// The Media section element exists.
expect(document.getElementById("dashboard-section-media")).not.toBeNull();
// Click the "Media" anchor pill (button role disambiguates from heading).
const mediaPill = screen.getByRole("button", { name: "Media" });
await userEvent.click(mediaPill);
expect(scrollSpy).toHaveBeenCalled();
scrollSpy.mockRestore();
});
});