Rebase services-as-hub-ia onto mobile-responsive-parity

Combine both branches into a single coherent branch:
- Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm,
  .mobile-touch-target, mobile cards, SheetForm forms, 44px targets,
  dirty-state confirm, TablePagination, refetchIntervalInBackground).
- Full services-as-hub IA (data-driven nav, service-page tab skeleton,
  new service types, Authentik directory + messaging, named dashboards,
  legacy routes 404, Observability split, Jellyseerr absorbed).

Enhancement: service tabs now use mobile-parity primitives:
- MediaTab: MobileCardRow below md (title/size/HDR/library/year) +
  TablePagination; DataTable at md+ (desktop branch preserved).
- FilesTab: MobileCardRow below md (name/type/size/modified) +
  handleRowClick; DataTable at md+.
- ServicePage: SheetForm branch below md (open-on-mount, sticky header
  + save bar, cancel navigates back to /services, dirty-state guard).
- Dashboard: single-column + section anchors below md (from mobile-parity)
  + empty-state CTA (from services-hub).
- App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity)
  + data-driven useNavItems (from services-hub).
- Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from
  mobile-parity; JobsTab inherits mobile behavior through its sub-components.

Conflict resolutions:
- Backend: entirely from services-hub (mobile didn't touch it).
- Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/
  ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted
  (services-hub deleted them; content moved into service tabs).
- New service-tabs/*: from services-hub, enhanced with mobile patterns.
- App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile.
- Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors).
- ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm.
- Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity.

117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests +
services-hub's new tab/dashboard tests); 271 backend tests pass; lint/
build green both sides.
This commit is contained in:
Developer
2026-06-26 20:55:00 +00:00
parent b583d5a365
commit 01527ae4f0
75 changed files with 5343 additions and 4926 deletions
+4 -171
View File
@@ -2,18 +2,12 @@ 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,
ServiceInstance,
WidgetInstance,
} from "../../types";
import type { DashboardShortcut } 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: ({ widget }: { widget: { title: string } }) => (
<div data-testid="widget-stub">{widget.title}</div>
),
WidgetInstanceCard: () => <div data-testid="widget-stub" />,
}));
vi.mock("../../components/WidgetConfigDialog", () => ({
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
@@ -27,15 +21,11 @@ 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: widgetInstances }),
useWidgetInstances: () => ({ data: [] }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: serviceInstances }),
useServiceInstances: () => ({ data: [] }),
}));
const saveShortcutMutate = vi.fn().mockResolvedValue({});
@@ -75,26 +65,8 @@ 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 />);
@@ -142,142 +114,3 @@ 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();
});
});