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:
@@ -1,5 +1,11 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
DatabaseBackup,
|
||||||
|
LayoutDashboard,
|
||||||
|
Monitor,
|
||||||
|
} from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -26,13 +32,122 @@ import {
|
|||||||
useSaveDashboardShortcut,
|
useSaveDashboardShortcut,
|
||||||
} from "../hooks/useDashboard";
|
} from "../hooks/useDashboard";
|
||||||
import { useWidgetInstances } from "../hooks/useWidgets";
|
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 { SectionCard } from "../components/SectionCard";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import { WidgetInstanceCard } from "../components/WidgetInstance";
|
import { WidgetInstanceCard } from "../components/WidgetInstance";
|
||||||
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
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 {
|
function emptyShortcut(): DashboardShortcutInput {
|
||||||
return {
|
return {
|
||||||
id: null,
|
id: null,
|
||||||
@@ -336,6 +451,8 @@ export function Dashboard() {
|
|||||||
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
||||||
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
||||||
const { data: widgetInstances = [] } = useWidgetInstances();
|
const { data: widgetInstances = [] } = useWidgetInstances();
|
||||||
|
const { data: services = [] } = useServiceInstances();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const visibleWidgets = useMemo(
|
const visibleWidgets = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -345,6 +462,11 @@ export function Dashboard() {
|
|||||||
[widgetInstances],
|
[widgetInstances],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mobileSections = useMemo(
|
||||||
|
() => groupWidgetsBySection(visibleWidgets, services),
|
||||||
|
[visibleWidgets, services],
|
||||||
|
);
|
||||||
|
|
||||||
const openCreateShortcut = () => {
|
const openCreateShortcut = () => {
|
||||||
setShortcutDraft(emptyShortcut());
|
setShortcutDraft(emptyShortcut());
|
||||||
setShortcutDialogOpen(true);
|
setShortcutDialogOpen(true);
|
||||||
@@ -417,9 +539,13 @@ export function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{visibleWidgets.map((widget) => (
|
{isMobile && mobileSections.length > 0 ? (
|
||||||
<WidgetInstanceCard key={widget.id} widget={widget} />
|
<MobileWidgetSections sections={mobileSections} />
|
||||||
))}
|
) : (
|
||||||
|
visibleWidgets.map((widget) => (
|
||||||
|
<WidgetInstanceCard key={widget.id} widget={widget} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
<ShortcutDialog
|
<ShortcutDialog
|
||||||
open={shortcutDialogOpen}
|
open={shortcutDialogOpen}
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { Dashboard } from "../Dashboard";
|
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
|
// Stub the composed widgets so the test exercises Dashboard's own behavior
|
||||||
// (shortcut CRUD) without rendering widgets or their data queries.
|
// (shortcut CRUD) without rendering widgets or their data queries.
|
||||||
vi.mock("../../components/WidgetInstance", () => ({
|
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", () => ({
|
vi.mock("../../components/WidgetConfigDialog", () => ({
|
||||||
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
|
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
|
||||||
@@ -21,8 +27,15 @@ vi.mock("react-router-dom", () => ({
|
|||||||
vi.mock("../../hooks/useSettings", () => ({
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
useMonitoringSettings: () => ({ data: [] }),
|
useMonitoringSettings: () => ({ data: [] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// --- Dynamic mock state (reset in beforeEach) ---
|
||||||
|
let widgetInstances: WidgetInstance[] = [];
|
||||||
|
let serviceInstances: ServiceInstance[] = [];
|
||||||
vi.mock("../../hooks/useWidgets", () => ({
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
useWidgetInstances: () => ({ data: [] }),
|
useWidgetInstances: () => ({ data: widgetInstances }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
useServiceInstances: () => ({ data: serviceInstances }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
||||||
@@ -62,8 +75,26 @@ beforeEach(() => {
|
|||||||
saveShortcutMutate.mockClear();
|
saveShortcutMutate.mockClear();
|
||||||
deleteShortcutMutate.mockClear();
|
deleteShortcutMutate.mockClear();
|
||||||
shortcuts = [];
|
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", () => {
|
describe("Dashboard", () => {
|
||||||
it("shows the empty-state alert when there are no shortcuts", () => {
|
it("shows the empty-state alert when there are no shortcuts", () => {
|
||||||
render(<Dashboard />);
|
render(<Dashboard />);
|
||||||
@@ -111,3 +142,142 @@ describe("Dashboard", () => {
|
|||||||
expect(saved.shortcut_type).toBe("website");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user