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
+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();
});
});