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