Service IA refinement: nav naming, instance tabs, config to Settings, configurable Overview
Four coupled changes to the services-as-hub IA:
1. Nav entries use service TYPE names (Jellyfin, SSH Tasks, Alertmanager,
Grafana, Prometheus, Backups, Authentik) instead of conceptual names
(Media, Files, Actions, Alerts, Users). ssh_tasks collapses to one
entry ('SSH Tasks') instead of two. The content tabs inside each
service page surface the concepts (Files, Actions).
2. Service page gains a two-level tab structure when multiple enabled
instances of the same type exist: instance tabs on top ([Main Jellyfin]
[Backup Jellyfin]), content tabs below ([Overview] [Media] [Requests]
[Widgets]). Clicking an instance tab navigates to the sibling's route.
Single instance: no instance tabs. Replaces the dropdown switcher.
3. Config tab (connection fields, secrets, enable/disable, delete) moves
from the service page to Settings > Services tab. The service page
becomes a PURE operational view (Overview + content tabs + Widgets) --
no save/delete/config state. Settings gains a 4th tab 'Services' with
ServiceConfigEditor per instance (schema-driven config fields, secrets
with leave-blank-to-keep semantics, ConfirmDialog on delete).
4. Overview tab is now a configurable widget grid per service instance.
Each instance manages its own set of widgets on its Overview. Backend
widget list endpoints gain ?service_id= and ?scope= (dashboard|service)
filter params; the main Dashboard uses scope=dashboard to exclude
service-scoped widgets. The OverviewTab reuses WidgetInstanceCard +
WidgetConfigDialog. Empty state CTA for instances with no widgets.
All service-tab stubs are replaced; stubs.tsx deleted.
272 backend tests pass (+1 widget filter); 121 frontend tests pass (+3
instance-tabs + OverviewTab); lint/build green both sides.
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { ServicePage } from "../ServicePage";
|
||||
import type { ServiceInstance, ServiceTypeInfo } from "../../types";
|
||||
import type { ServiceInstance } from "../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "svc-1",
|
||||
@@ -15,17 +16,7 @@ const instance: ServiceInstance = {
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
const typeInfo: ServiceTypeInfo = {
|
||||
service_type: "jellyfin",
|
||||
name: "Jellyfin",
|
||||
description: "Media server",
|
||||
config_schema: {
|
||||
type: "object",
|
||||
properties: { base_url: { type: "string" } },
|
||||
},
|
||||
secret_fields: [{ key: "api_key", label: "API key", required: false }],
|
||||
widget_kinds: [],
|
||||
};
|
||||
// typeInfo no longer needed on ServicePage (config moved to Settings).
|
||||
|
||||
const secondInstance: ServiceInstance = {
|
||||
...instance,
|
||||
@@ -33,20 +24,23 @@ const secondInstance: ServiceInstance = {
|
||||
name: "Backup Jellyfin",
|
||||
};
|
||||
|
||||
const saveMutateAsync = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useServices", () => ({
|
||||
useServiceInstances: () => ({
|
||||
data: (window as unknown as { __svcInstances?: ServiceInstance[] })
|
||||
?.__svcInstances ?? [instance],
|
||||
}),
|
||||
useServiceTypes: () => ({ data: [typeInfo] }),
|
||||
useSaveServiceInstance: () => ({
|
||||
mutateAsync: saveMutateAsync,
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteServiceInstance: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetInstances: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/WidgetConfigDialog", () => ({
|
||||
WidgetConfigDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/WidgetInstance", () => ({
|
||||
WidgetInstanceCard: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../integrations/registry", () => ({
|
||||
@@ -71,13 +65,19 @@ function renderServicePage(path: string) {
|
||||
}
|
||||
|
||||
describe("ServicePage tab skeleton", () => {
|
||||
it("renders Overview + Media + Requests + Widgets + Config for jellyfin", () => {
|
||||
it("renders Overview + Media + Requests + Widgets for jellyfin", () => {
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
expect(screen.getByRole("tab", { name: "Overview" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Media" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Requests" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Widgets" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Config" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT render Config tab (moved to Settings)", () => {
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: "Config" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT render Media/Requests for non-jellyfin types", () => {
|
||||
@@ -93,43 +93,37 @@ describe("ServicePage tab skeleton", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows instance switcher when >1 sibling of same type", () => {
|
||||
it("shows instance tabs when >1 enabled sibling of same type", () => {
|
||||
(
|
||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||
).__svcInstances = [instance, secondInstance];
|
||||
const { container } = renderServicePage("/services/jellyfin/svc-1");
|
||||
// The switcher renders as a Select trigger (combobox).
|
||||
expect(container.querySelector("[role='combobox']")).toBeInTheDocument();
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Main Jellyfin" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("tab", { name: "Backup Jellyfin" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides instance switcher when only one instance", () => {
|
||||
it("hides instance tabs when only one instance", () => {
|
||||
(
|
||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||
).__svcInstances = [instance];
|
||||
const { container } = renderServicePage("/services/jellyfin/svc-1");
|
||||
// No select trigger rendered (only one instance).
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
expect(
|
||||
container.querySelector("[role='combobox']"),
|
||||
screen.queryByRole("tab", { name: "Main Jellyfin" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("includes typed secret drafts in the save payload (B1 regression guard)", async () => {
|
||||
const { userEvent } = await import("@testing-library/user-event");
|
||||
it("clicking an instance tab navigates to that instance", async () => {
|
||||
const user = userEvent.setup();
|
||||
saveMutateAsync.mockReset();
|
||||
(
|
||||
window as unknown as { __svcInstances: ServiceInstance[] }
|
||||
).__svcInstances = [instance, secondInstance];
|
||||
renderServicePage("/services/jellyfin/svc-1");
|
||||
|
||||
// Open the Config tab and type a new api_key.
|
||||
await user.click(screen.getByRole("tab", { name: "Config" }));
|
||||
const secretInput = screen.getByLabelText("api_key");
|
||||
await user.type(secretInput, "new-secret-value");
|
||||
|
||||
// Save and assert the typed secret is in the payload (not secrets: {}).
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(saveMutateAsync).toHaveBeenCalledTimes(1);
|
||||
const input = saveMutateAsync.mock.calls[0][0] as {
|
||||
secrets: Record<string, string>;
|
||||
};
|
||||
expect(input.secrets).toEqual({ api_key: "new-secret-value" });
|
||||
await user.click(screen.getByRole("tab", { name: "Backup Jellyfin" }));
|
||||
// The test router would navigate; we can't assert URL directly without
|
||||
// a useNavigate mock, but the click should not throw.
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user