diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 0545080..0b4992f 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 = { + 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) */} +
+ {sections.map((section) => { + const meta = SECTION_META[section.id]; + const Icon = meta.icon; + return ( + + ); + })} +
+ {/* Sectioned widgets — single column (spec R7.1) */} +
+ {sections.map((section) => ( +
+

+ {SECTION_META[section.id].label} +

+ {section.widgets.map((widget) => ( + + ))} +
+ ))} +
+ + ); +} + function emptyShortcut(): DashboardShortcutInput { return { id: null, @@ -336,6 +451,8 @@ export function Dashboard() { const [deleteShortcutId, setDeleteShortcutId] = useState(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() { )} - {visibleWidgets.map((widget) => ( - - ))} + {isMobile && mobileSections.length > 0 ? ( + + ) : ( + visibleWidgets.map((widget) => ( + + )) + )} ({ - WidgetInstanceCard: () =>
, + WidgetInstanceCard: ({ widget }: { widget: { title: string } }) => ( +
{widget.title}
+ ), })); vi.mock("../../components/WidgetConfigDialog", () => ({ WidgetConfigDialog: () =>
, @@ -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(); @@ -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 { + 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 { + 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(); + + // 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(); + + // 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(); + + // 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(); + }); +});