caf6c226ff
The IA shell lands. The static navItems array is replaced by useNavItems(),
which combines useServiceInstances (enabled instances) + useDashboards to
build the nav in spec order: Main Dashboard, named dashboards, conditional
service-type entries (one per configured type; ssh_tasks contributes Files
+ Actions, nextcloud contributes none), Services, Settings.
Legacy top-level routes (/media, /files, /actions, /users, /observability,
/backups, /monitoring, /applications) are removed; a NotFoundPage catch-all
returns 404 (R4.7).
ServicePage is refactored to a tab skeleton: Overview | type-specific
content tabs | Widgets | Config. serviceContentTabs(type) returns the
per-type set (jellyfin=Media+Requests, ssh_tasks=Files+Actions, backups=Jobs,
authentik=Users+Messaging, alertmanager=Alerts, grafana=Links,
prometheus=Metrics, nextcloud=none). Content tabs are stubs ('coming soon');
real content migrates in slices 5-9. Widgets + Config tabs preserve the
existing widget-list and config/secrets editing verbatim.
ServiceTypePage resolves /services/:type (no id) by redirecting to the
first enabled instance; empty state when none.
Instance switcher (Select) appears when >1 ENABLED sibling of the same
type exists (R3.1).
Empty states: Dashboard shows an 'Add a service' CTA when no instances
exist; ServicesPage already had a strong empty state.
Fixes from Slice 4 review:
- B1 (blocker): secret editing regressed because buildInput() hardcoded
secrets:{} after the ConfigBody lift orphaned draftSecrets. Lifted
draftSecrets to the parent ServicePage; buildInput now sends only the
non-blank typed drafts ('leave blank to keep' semantics restored).
- S1: switcher trigger keys off enabled siblings, not total.
New: navEntries.ts + test, dashboards api/hook, service-tabs/ stubs +
index, ServiceTypePage, ServicePage tab skeleton + ConfigBody lift,
Dashboard empty-state CTA, ServicePage tab/switcher/secret-save tests.
Note: this branch is based on main (mobile-responsive-parity is unmerged);
the mobile SheetForm on ServicePage will be re-added when content tabs
get real content (slices 5-9). 84 tests pass (+1 secret-save guard);
lint/build green.
Refs openspec/changes/services-as-hub-ia/ (spec R1-R4/R9, tasks slice 4).
117 lines
3.6 KiB
TypeScript
117 lines
3.6 KiB
TypeScript
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";
|
|
|
|
// 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" />,
|
|
}));
|
|
vi.mock("../../components/WidgetConfigDialog", () => ({
|
|
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
|
|
}));
|
|
|
|
const navigate = vi.fn();
|
|
vi.mock("react-router-dom", () => ({
|
|
useNavigate: () => navigate,
|
|
}));
|
|
|
|
vi.mock("../../hooks/useSettings", () => ({
|
|
useMonitoringSettings: () => ({ data: [] }),
|
|
}));
|
|
vi.mock("../../hooks/useWidgets", () => ({
|
|
useWidgetInstances: () => ({ data: [] }),
|
|
}));
|
|
vi.mock("../../hooks/useServices", () => ({
|
|
useServiceInstances: () => ({ data: [] }),
|
|
}));
|
|
|
|
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
|
const deleteShortcutMutate = vi.fn();
|
|
|
|
let shortcuts: DashboardShortcut[] = [];
|
|
|
|
vi.mock("../../hooks/useDashboard", () => ({
|
|
useActivity: () => ({ data: undefined }),
|
|
useDashboardShortcuts: () => ({ data: shortcuts }),
|
|
useSaveDashboardShortcut: () => ({ mutateAsync: saveShortcutMutate }),
|
|
useDeleteDashboardShortcut: () => ({ mutate: deleteShortcutMutate }),
|
|
}));
|
|
|
|
function websiteShortcut(
|
|
overrides: Partial<DashboardShortcut> = {},
|
|
): DashboardShortcut {
|
|
return {
|
|
id: "s1",
|
|
label: "Wiki",
|
|
shortcut_type: "website",
|
|
enabled: true,
|
|
icon: "📚",
|
|
url: "example.com",
|
|
task_id: "",
|
|
machine_id: "",
|
|
user_id: "",
|
|
notes: "Team wiki",
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
...overrides,
|
|
} as DashboardShortcut;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
navigate.mockReset();
|
|
saveShortcutMutate.mockClear();
|
|
deleteShortcutMutate.mockClear();
|
|
shortcuts = [];
|
|
});
|
|
|
|
describe("Dashboard", () => {
|
|
it("shows the empty-state alert when there are no shortcuts", () => {
|
|
render(<Dashboard />);
|
|
expect(screen.getByText(/No shortcuts yet/)).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("button", { name: "Add shortcut" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders a shortcut card and deletes it via the confirm dialog", async () => {
|
|
shortcuts = [websiteShortcut()];
|
|
render(<Dashboard />);
|
|
|
|
expect(screen.getByText("Wiki")).toBeInTheDocument();
|
|
|
|
// Open the delete confirm.
|
|
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
|
expect(screen.getByText("Delete shortcut?")).toBeInTheDocument();
|
|
|
|
// Confirm deletion -> delete mutation fires with the shortcut id.
|
|
const dialogs = screen.getAllByRole("button", { name: "Delete" });
|
|
// The card "Delete" plus the confirm "Delete"; confirm is the last one.
|
|
await userEvent.click(dialogs[dialogs.length - 1]);
|
|
expect(deleteShortcutMutate).toHaveBeenCalledTimes(1);
|
|
expect(deleteShortcutMutate).toHaveBeenCalledWith("s1");
|
|
});
|
|
|
|
it("creates a shortcut via the dialog and saves it", async () => {
|
|
render(<Dashboard />);
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: "Add shortcut" }));
|
|
|
|
// Edit dialog opens in "New shortcut" mode.
|
|
expect(screen.getByText("New shortcut")).toBeInTheDocument();
|
|
|
|
// Fill the label and save.
|
|
await userEvent.type(screen.getByLabelText("Label"), "Grafana");
|
|
await userEvent.click(
|
|
screen.getByRole("button", { name: "Save shortcut" }),
|
|
);
|
|
|
|
expect(saveShortcutMutate).toHaveBeenCalledTimes(1);
|
|
const saved = saveShortcutMutate.mock.calls[0][0];
|
|
expect(saved.label).toBe("Grafana");
|
|
expect(saved.shortcut_type).toBe("website");
|
|
});
|
|
});
|