Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard. - Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe label, kebab-case path relative to the module root; no traversal, schemes, queries, duplicates, or nesting). Validated before activation. - Version 8 stays accepted beside version 9, each read strictly as itself (discriminated union); no released manifest is rewritten. The template moves to v9; the released Signage 0.1.5 manifest is a test fixture. - Real module routes: /<id> is the landing view, /<id>/<page> a declared page, anything beneath a page a module-owned detail route; anything else is not-found and mounts nothing. api/assets/display are never pages. - Sidebar: declared icons, pages nested under an open module, one aria-current, collapsed and drawer behaviour; v8 modules unchanged. - module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe, navigate, link, settings), refusing anything outside the module's routes; undefined on the display host. Mount ABI unchanged. - Return-to-origin: one `?return=` carrier on Settings URLs, validated by parseReturnTarget and against running modules' declared routes; Settings Back returns to exactly the originating module page, across refreshes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
439 lines
16 KiB
TypeScript
439 lines
16 KiB
TypeScript
import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { HostApp } from "../src/browser/HostApp.js";
|
|
import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js";
|
|
import { createMemoryHostViewport } from "../src/browser/host-viewport.js";
|
|
import { createMemorySidebarPreference } from "../src/browser/sidebar-preference.js";
|
|
import type { DashboardSettings } from "../src/host-protocol.js";
|
|
|
|
declare global {
|
|
// eslint-disable-next-line no-var
|
|
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
|
}
|
|
|
|
/**
|
|
* The Settings shell (ADR 0061): its landing page, the platform sections and Discover, each
|
|
* module's settings, and which of them the server said this person may see.
|
|
*/
|
|
|
|
const apiBaseUrl = "https://ekklesia.example.test";
|
|
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
let consoleError: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
container = document.createElement("div");
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
consoleError.mockRestore();
|
|
});
|
|
|
|
function entry(id: string, name: string) {
|
|
return {
|
|
module: {
|
|
api: { baseUrl: `/api/v1/modules/${id}` },
|
|
contributions: [],
|
|
display: null,
|
|
id,
|
|
permissions: [],
|
|
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
|
|
ui: { url: `/api/v1/module-ui/${id}/0.1.0` },
|
|
version: "0.1.0",
|
|
},
|
|
name,
|
|
navigation: { icon: null, pages: [] },
|
|
};
|
|
}
|
|
|
|
const NAVIGATION = {
|
|
groups: [],
|
|
ungrouped: [entry("signage", "Signage"), entry("calendar", "Calendar")],
|
|
};
|
|
|
|
/** An administrator who may see everything: every platform section, two modules' settings, and a
|
|
* disabled module (`archive`) whose settings exist but are not navigable. Calendar declares no
|
|
* settings, so it is a known module with no settings page. */
|
|
const ADMINISTRATOR: DashboardSettings = {
|
|
modules: [
|
|
{ available: false, id: "archive", name: "Archive" },
|
|
{ available: true, id: "signage", name: "Signage" },
|
|
],
|
|
sections: ["general", "branding", "displays", "discover", "system"],
|
|
};
|
|
|
|
interface Rendered {
|
|
readonly history: MemoryHostHistory;
|
|
readonly calls: string[];
|
|
readonly titles: string[];
|
|
}
|
|
|
|
async function render(
|
|
path: string,
|
|
options: {
|
|
readonly settings?: DashboardSettings | "fails";
|
|
readonly compact?: boolean;
|
|
readonly history?: MemoryHostHistory;
|
|
} = {},
|
|
): Promise<Rendered> {
|
|
const history = options.history ?? createMemoryHostHistory(path);
|
|
const calls: string[] = [];
|
|
const titles: string[] = [];
|
|
let settingsAttempts = 0;
|
|
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
calls.push(new URL(url).pathname);
|
|
if (url.endsWith("/api/v1/principal")) {
|
|
return Response.json({ kind: "authenticated", userId: "user-1" });
|
|
}
|
|
if (url.endsWith("/api/v1/dashboard/navigation")) {
|
|
return Response.json(NAVIGATION);
|
|
}
|
|
if (url.endsWith("/api/v1/dashboard/settings")) {
|
|
settingsAttempts += 1;
|
|
const settings = options.settings ?? ADMINISTRATOR;
|
|
return settings === "fails" && settingsAttempts === 1
|
|
? Response.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, { status: 503 })
|
|
: Response.json(settings === "fails" ? ADMINISTRATOR : settings);
|
|
}
|
|
return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
|
|
}) as unknown as typeof fetch;
|
|
await act(async () => {
|
|
root.render(
|
|
<HostApp
|
|
config={{ apiBaseUrl, fetch: fetchImplementation }}
|
|
history={history}
|
|
importModule={async () => ({
|
|
default: {
|
|
abi: MODULE_UI_ABI_VERSION,
|
|
id: "signage",
|
|
mount(element: Element, context: ModuleUiHostContext) {
|
|
element.textContent = `${context.moduleId} mounted`;
|
|
},
|
|
version: "0.1.0",
|
|
},
|
|
})}
|
|
setDocumentTitle={(title) => titles.push(title)}
|
|
sidebarPreference={createMemorySidebarPreference()}
|
|
viewport={createMemoryHostViewport(options.compact ?? false)}
|
|
/>,
|
|
);
|
|
});
|
|
return { calls, history, titles };
|
|
}
|
|
|
|
function query(testId: string): HTMLElement | null {
|
|
return container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
|
|
}
|
|
|
|
function required(testId: string): HTMLElement {
|
|
const element = query(testId);
|
|
if (!element) {
|
|
throw new Error(`missing ${testId}`);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
async function click(element: Element): Promise<void> {
|
|
await act(async () => {
|
|
element.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, cancelable: true }));
|
|
});
|
|
}
|
|
|
|
const current = () =>
|
|
[...container.querySelectorAll('nav [aria-current="page"]')].map(
|
|
(element) => (element as HTMLElement).dataset.testid,
|
|
);
|
|
|
|
describe("the Settings landing page", () => {
|
|
it("is a real page, not a redirect, listing what this person may open", async () => {
|
|
const { history, titles } = await render("/settings");
|
|
|
|
expect(history.entries).toEqual(["/settings"]);
|
|
expect(query("settings-home")?.querySelector("h1")?.textContent).toBe("Settings");
|
|
expect(
|
|
[...container.querySelectorAll('[data-testid^="settings-home-"]')].map((link) =>
|
|
link.getAttribute("href"),
|
|
),
|
|
).toEqual([
|
|
"/settings/general",
|
|
"/settings/branding",
|
|
"/settings/displays",
|
|
"/settings/discover/browse",
|
|
"/settings/system",
|
|
"/settings/signage",
|
|
]);
|
|
// A disabled module's settings are not offered.
|
|
expect(query("settings-home-module-archive")).toBeNull();
|
|
// Each group is a section of the page, below its one h1.
|
|
expect(
|
|
[...container.querySelectorAll("main h2")].map((heading) => heading.textContent),
|
|
).toEqual(["Platform", "Modules"]);
|
|
expect(titles.at(-1)).toBe("Settings");
|
|
expect(current()).toEqual(["select-settings"]);
|
|
});
|
|
|
|
it("says plainly when there is nothing for this person to change", async () => {
|
|
await render("/settings", { settings: { modules: [], sections: [] } });
|
|
|
|
expect(query("settings-home")?.textContent).toContain(
|
|
"There are no settings for you to change",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("the platform sections", () => {
|
|
it("gives each a page of its own, with Back to Settings and an honest placeholder", async () => {
|
|
for (const section of ["general", "branding", "displays", "system"] as const) {
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
root = createRoot(container);
|
|
const { titles } = await render(`/settings/${section}`);
|
|
|
|
const page = required(`settings-section-${section}`);
|
|
expect(page.querySelector("h1")?.textContent, section).toBeTruthy();
|
|
expect(page.textContent, section).toContain("Not available yet");
|
|
// No fake controls.
|
|
expect(page.querySelector("input, select, textarea, form"), section).toBeNull();
|
|
const back = page.querySelector(".ek-page-header__back");
|
|
expect(back?.getAttribute("href"), section).toBe("/settings");
|
|
expect(back?.textContent, section).toContain("Back to Settings");
|
|
expect(current(), section).toEqual([`select-settings-${section}`]);
|
|
expect(titles.at(-1), section).toMatch(/ settings$/u);
|
|
}
|
|
});
|
|
|
|
it("enters Discover at Browse, its one canonical address, without adding a history entry", async () => {
|
|
const { history, titles } = await render("/settings/discover");
|
|
|
|
expect(history.entries).toEqual(["/settings/discover/browse"]);
|
|
expect(query("settings-discover-browse")?.querySelector("h1")?.textContent).toBe(
|
|
"Discover modules",
|
|
);
|
|
expect(query("settings-discover-browse")?.textContent).toContain(
|
|
"Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses.",
|
|
);
|
|
expect(titles.at(-1)).toBe("Discover modules");
|
|
expect(current()).toEqual(["select-discover-browse"]);
|
|
expect(query("select-settings-discover")?.getAttribute("href")).toBe(
|
|
"/settings/discover/browse",
|
|
);
|
|
});
|
|
|
|
it("has Browse, Installed, and Updates beneath Discover, each speaking of modules", async () => {
|
|
const { history } = await render("/settings/discover/browse");
|
|
|
|
const views = [...required("discover-subnavigation").querySelectorAll("a")];
|
|
expect(views.map((view) => view.textContent)).toEqual(["Browse", "Installed", "Updates"]);
|
|
expect(views.map((view) => view.getAttribute("href"))).toEqual([
|
|
"/settings/discover/browse",
|
|
"/settings/discover/installed",
|
|
"/settings/discover/updates",
|
|
]);
|
|
|
|
await click(required("select-discover-installed"));
|
|
expect(query("settings-discover-installed")?.querySelector("h1")?.textContent).toBe(
|
|
"Installed modules",
|
|
);
|
|
await click(required("select-discover-updates"));
|
|
expect(query("settings-discover-updates")?.querySelector("h1")?.textContent).toBe(
|
|
"Module updates",
|
|
);
|
|
expect(history.entries).toEqual([
|
|
"/settings/discover/browse",
|
|
"/settings/discover/installed",
|
|
"/settings/discover/updates",
|
|
]);
|
|
});
|
|
|
|
it("says a section is unavailable to someone who may not see it, rather than showing it", async () => {
|
|
await render("/settings/discover/installed", {
|
|
settings: { modules: [], sections: ["displays"] },
|
|
});
|
|
|
|
expect(query("settings-no-access")).not.toBeNull();
|
|
expect(query("settings-discover-installed")).toBeNull();
|
|
expect(query("select-settings-discover")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("module settings", () => {
|
|
it("opens a running module's settings at /settings/<module-id>, as a placeholder", async () => {
|
|
const { titles } = await render("/settings/signage");
|
|
|
|
const page = required("module-settings");
|
|
expect(page.querySelector("h1")?.textContent).toBe("Signage");
|
|
expect(page.textContent).toContain("Not available yet");
|
|
expect(page.querySelector("input, select, textarea, form")).toBeNull();
|
|
expect(titles.at(-1)).toBe("Signage settings");
|
|
expect(current()).toEqual(["select-module-settings-signage"]);
|
|
});
|
|
|
|
it("says a disabled module's settings are turned off, rather than redirecting", async () => {
|
|
const { history } = await render("/settings/archive");
|
|
|
|
expect(query("settings-module-unavailable")?.textContent).toContain(
|
|
"This module is turned off",
|
|
);
|
|
expect(history.entries).toEqual(["/settings/archive"]);
|
|
expect(query("select-module-settings-archive")).toBeNull();
|
|
});
|
|
|
|
it("says a known module's settings aren't available when there are none for this person", async () => {
|
|
await render("/settings/calendar");
|
|
|
|
expect(query("settings-no-access")).not.toBeNull();
|
|
});
|
|
|
|
it("is not found for a name that is no module", async () => {
|
|
for (const path of ["/settings/definitely-not-a-module", "/settings/modules"]) {
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
root = createRoot(container);
|
|
await render(path);
|
|
expect(query("host-not-found"), path).not.toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Settings in the navigation", () => {
|
|
it("is one item after the modules, expanding into Platform and Modules only inside Settings", async () => {
|
|
const { history } = await render("/signage");
|
|
|
|
const settingsLink = required("select-settings");
|
|
expect(settingsLink.getAttribute("href")).toBe("/settings");
|
|
expect(query("settings-subnavigation")).toBeNull();
|
|
const links = [...container.querySelectorAll("nav a")].map((link) => link.textContent);
|
|
expect(links).toEqual(["Overview", "Signage", "Calendar", "Settings"]);
|
|
|
|
await click(settingsLink);
|
|
|
|
expect(history.entries).toEqual(["/signage", "/settings"]);
|
|
const sub = required("settings-subnavigation");
|
|
expect([...sub.querySelectorAll("h3")].map((heading) => heading.textContent)).toEqual([
|
|
"Platform",
|
|
"Modules",
|
|
]);
|
|
expect([...sub.querySelectorAll("a")].map((link) => link.textContent)).toEqual([
|
|
"General",
|
|
"Branding",
|
|
"Displays",
|
|
"Discover",
|
|
"System",
|
|
"Signage",
|
|
]);
|
|
// The rest of the navigation stays, so leaving Settings is one click.
|
|
await click(required("select-signage"));
|
|
expect(query("settings-subnavigation")).toBeNull();
|
|
expect(history.entries).toEqual(["/signage", "/settings", "/signage"]);
|
|
});
|
|
|
|
it("shows only the sections and module settings this person may see", async () => {
|
|
await render("/settings", {
|
|
settings: {
|
|
modules: [{ available: true, id: "signage", name: "Signage" }],
|
|
sections: ["displays"],
|
|
},
|
|
});
|
|
|
|
const sub = required("settings-subnavigation");
|
|
expect([...sub.querySelectorAll("a")].map((link) => link.textContent)).toEqual([
|
|
"Displays",
|
|
"Signage",
|
|
]);
|
|
});
|
|
|
|
it("is not offered at all to someone with nothing to change", async () => {
|
|
await render("/", { settings: { modules: [], sections: [] } });
|
|
|
|
expect(query("select-settings")).toBeNull();
|
|
});
|
|
|
|
it("is withheld when Settings cannot be loaded, whose pages say so and retry", async () => {
|
|
const { calls } = await render("/settings", { settings: "fails" });
|
|
|
|
expect(query("select-settings")).toBeNull();
|
|
expect(query("settings-load-failed")).not.toBeNull();
|
|
// The rest of the Dashboard still works.
|
|
expect(query("select-signage")).not.toBeNull();
|
|
|
|
const retry = [...required("settings-load-failed").querySelectorAll("button")].find(
|
|
(button) => button.textContent === "Retry",
|
|
) as HTMLButtonElement;
|
|
await act(async () => {
|
|
retry.click();
|
|
});
|
|
expect(query("settings-home")).not.toBeNull();
|
|
expect(calls.filter((path) => path === "/api/v1/dashboard/settings")).toHaveLength(2);
|
|
});
|
|
|
|
it("keeps the exact Settings page across a refresh, and Back and Forward walk it", async () => {
|
|
const history = createMemoryHostHistory("/settings");
|
|
await render("/settings", { history });
|
|
await click(required("select-settings-displays"));
|
|
await click(required("select-module-settings-signage"));
|
|
|
|
await act(async () => {
|
|
history.go(-1);
|
|
});
|
|
expect(query("settings-section-displays")).not.toBeNull();
|
|
await act(async () => {
|
|
history.go(1);
|
|
});
|
|
expect(query("module-settings")).not.toBeNull();
|
|
|
|
// A refresh is the same address, rendered from scratch.
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
root = createRoot(container);
|
|
await render("/settings/signage", { history: createMemoryHostHistory(history.pathname()) });
|
|
expect(query("module-settings")).not.toBeNull();
|
|
expect(current()).toEqual(["select-module-settings-signage"]);
|
|
});
|
|
|
|
it("moves focus to the page when moving between two Settings pages", async () => {
|
|
await render("/settings/general");
|
|
const main = required("host-main-region");
|
|
|
|
await click(required("select-settings-branding"));
|
|
|
|
expect(document.activeElement).toBe(main);
|
|
expect(main.getAttribute("aria-label")).toBe("Branding settings");
|
|
});
|
|
|
|
it("uses the same hierarchy in the phone drawer, which closes once a page is chosen", async () => {
|
|
const { history } = await render("/settings", { compact: true });
|
|
await click(required("mobile-navigation-trigger"));
|
|
|
|
const drawer = required("navigation-drawer");
|
|
expect(
|
|
[...drawer.querySelectorAll('[data-testid="settings-subnavigation"] a')].map(
|
|
(link) => link.textContent,
|
|
),
|
|
).toEqual(["General", "Branding", "Displays", "Discover", "System", "Signage"]);
|
|
for (const link of drawer.querySelectorAll('[data-testid="settings-subnavigation"] a')) {
|
|
expect((link as HTMLElement).style.minHeight).toBe("var(--ekklesia-control-height-lg)");
|
|
}
|
|
|
|
await click(required("select-settings-system"));
|
|
|
|
expect(query("navigation-drawer")).toBeNull();
|
|
expect(history.entries).toEqual(["/settings", "/settings/system"]);
|
|
expect(query("settings-section-system")).not.toBeNull();
|
|
});
|
|
});
|