feat(shell): Settings hierarchy, Discover, and /settings/<module-id>
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m29s
CI / image-build (pull_request) Successful in 1m4s
CI / deploy-smoke (same-origin) (pull_request) Successful in 54s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m56s

The Settings foundation (ADR 0061):

- URL model. /settings is a real landing page. The platform sections
  are /settings/general, /branding, /displays, and /system. Module
  lifecycle is Discover: /settings/discover/browse, /installed, and
  /updates. /settings/discover is respelled in place to Browse's one
  canonical address. A module's settings are /settings/<module-id>,
  never nested under Discover. Deeper Settings addresses are
  reserved. "Modules" stays the user-facing word: "Discover modules",
  "Installed modules", "Module updates".
- Collisions. Core's one reserved list gains the Settings section
  names (branding, discover, displays, general, system) as a second
  documented group, so no module id can shadow a section; `modules` is
  an ordinary id. apps/api tests hold it equal to the shell's sections.
- Navigation. One Settings item in the same sidebar expands in place
  into Platform and Modules (and Discover's three views) while in
  Settings, and the phone drawer has the same hierarchy. The rest of
  the navigation stays.
- Permissions. GET /api/v1/dashboard/settings evaluates, through
  Core's evaluator, which destinations the viewer may see:
  - Displays: core.devices.manage
  - Discover: core.modules.lifecycle.manage
  - a module's settings: core.modules.configuration.manage, for each
    module that declares configuration (ADR 0029)
  - General, Branding, System: the platform owner, until each has its
    own permission
  These are presentation hints; every data route keeps its own
  enforcement. With nothing visible, there is no Settings link.
- Pages are honest placeholders with Back to Settings. A disabled
  module's settings say it is turned off; a section or module the
  person cannot see says so without detail; an unknown name is not
  found; a failed load withholds the link and offers Retry.
- The deploy smoke opens Settings by deep link, refreshes a section,
  enters Discover, opens the module's settings, uses the phone
  drawer, and returns to the Dashboard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-14 22:22:50 -05:00
co-authored by Claude Opus 5.5
parent 34692ac05f
commit 89ef4b5ff6
30 changed files with 2343 additions and 50 deletions
+79 -1
View File
@@ -7,6 +7,8 @@ import {
DASHBOARD_PLATFORM_PAGES,
dashboardPath,
parseDashboardPath,
DISCOVER_VIEWS,
SETTINGS_SECTIONS,
} from "../src/dashboard-routes.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
@@ -34,7 +36,8 @@ describe("parseDashboardPath", () => {
});
it("never reads a platform page as a module page", () => {
for (const pathname of ["/settings", "/settings/", "/account", "/account/appearance"]) {
expect(parseDashboardPath("/settings")).toEqual({ kind: "settings" });
for (const pathname of ["/account", "/account/appearance"]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
@@ -49,7 +52,10 @@ describe("parseDashboardPath", () => {
"/signage/timelines",
"/signage/timelines/abc",
"/%E0%A4%A",
// Deeper Settings pages are reserved for later (ADR 0061).
"/settings/displays/add",
"/settings/signage/timelines",
"/settings//signage",
"signage",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
@@ -57,6 +63,78 @@ describe("parseDashboardPath", () => {
});
});
describe("Settings routes (ADR 0061)", () => {
it("names exactly the platform's own Settings sections, frozen", () => {
expect(Object.isFrozen(SETTINGS_SECTIONS)).toBe(true);
expect(SETTINGS_SECTIONS).toEqual(["general", "branding", "displays", "discover", "system"]);
expect(Object.isFrozen(DISCOVER_VIEWS)).toBe(true);
expect(DISCOVER_VIEWS).toEqual(["browse", "installed", "updates"]);
});
it("has a landing page of its own, tolerating one trailing slash", () => {
expect(parseDashboardPath("/settings")).toEqual({ kind: "settings" });
expect(parseDashboardPath("/settings/")).toEqual({ kind: "settings" });
});
it("reads each platform section, and nothing else, as a platform section", () => {
for (const section of ["general", "branding", "displays", "system"] as const) {
expect(parseDashboardPath(`/settings/${section}`), section).toEqual({
kind: "settings-section",
section,
});
}
});
it("reads Discover's three views, entering Discover at Browse, its one canonical address", () => {
for (const view of DISCOVER_VIEWS) {
expect(parseDashboardPath(`/settings/discover/${view}`), view).toEqual({
kind: "discover",
view,
});
}
// `/settings/discover` means Browse, and the shell respells it `/settings/discover/browse` in
// place, so there is one meaningful Browse address rather than two.
expect(parseDashboardPath("/settings/discover")).toEqual({ kind: "discover", view: "browse" });
expect(dashboardPath({ kind: "discover", view: "browse" })).toBe("/settings/discover/browse");
for (const pathname of [
"/settings/discover/catalog",
"/settings/discover/browse/signage",
"/settings/discover//",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
it("reads every other name as a module's settings — never under /settings/modules/", () => {
expect(parseDashboardPath("/settings/signage")).toEqual({
kind: "module-settings",
moduleId: "signage",
});
expect(parseDashboardPath("/settings/sign%61ge/")).toEqual({
kind: "module-settings",
moduleId: "signage",
});
expect(parseDashboardPath("/settings/modules/signage")).toEqual({ kind: "not-found" });
// `modules` is no longer a platform section, so it is simply a module id like any other.
expect(parseDashboardPath("/settings/modules")).toEqual({
kind: "module-settings",
moduleId: "modules",
});
});
it("spells each Settings destination canonically and round-trips it", () => {
for (const [route, path] of [
[{ kind: "settings" }, "/settings"],
[{ kind: "settings-section", section: "displays" }, "/settings/displays"],
[{ kind: "discover", view: "installed" }, "/settings/discover/installed"],
[{ kind: "module-settings", moduleId: "signage" }, "/settings/signage"],
] as const) {
expect(dashboardPath(route)).toBe(path);
expect(parseDashboardPath(path)).toEqual(route);
}
});
});
describe("dashboardPath", () => {
it("spells each route canonically, with no implementation prefix", () => {
expect(dashboardPath({ kind: "overview" })).toBe("/");
+2
View File
@@ -482,6 +482,8 @@ describe("Ekklesia shell — browser authentication", () => {
"/api/auth/sign-in/email",
"/api/v1/principal",
"/api/v1/dashboard/navigation",
// Which Settings destinations the person may see, asked alongside the navigation (ADR 0061).
"/api/v1/dashboard/settings",
// The host-owned device panel loads with the rest of the shell for an administrator.
"/api/v1/devices",
]);
+56 -1
View File
@@ -2,9 +2,11 @@ import { describe, expect, it, vi } from "vitest";
import {
bootstrapHost,
type HostModuleUiDescriptor,
HostProtocolError,
loadDashboardNavigation,
loadHostPrincipal,
parseDashboardNavigation,
parseDashboardSettings,
parseHostPrincipal,
resolveApiOrigin,
} from "../src/host-protocol.js";
@@ -293,9 +295,14 @@ describe("host protocol — Dashboard navigation", () => {
});
describe("host protocol — bootstrap", () => {
it("resolves the principal and then the Dashboard navigation", async () => {
it("resolves the principal and then the Dashboard navigation and Settings together", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
"/api/v1/dashboard/settings": () =>
jsonResponse({
modules: [{ available: true, id: "example", name: "Example" }],
sections: ["displays"],
}),
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
});
@@ -304,9 +311,57 @@ describe("host protocol — bootstrap", () => {
expect(calls.map((call) => call.url)).toEqual([
`${apiBaseUrl}/api/v1/principal`,
`${apiBaseUrl}/api/v1/dashboard/navigation`,
`${apiBaseUrl}/api/v1/dashboard/settings`,
]);
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" });
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
expect(bootstrap.settings).toEqual({
modules: [{ available: true, id: "example", name: "Example" }],
sections: ["displays"],
});
});
it("keeps the Dashboard working when Settings cannot be loaded", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const { implementation } = stubFetch({
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
"/api/v1/dashboard/settings": () => jsonResponse({ code: "UNAVAILABLE" }, 503),
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
});
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
expect(bootstrap.settings).toBe("unavailable");
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
consoleError.mockRestore();
});
it("parses the Settings view strictly", () => {
expect(parseDashboardSettings({ modules: [], sections: ["general", "system"] })).toEqual({
modules: [],
sections: ["general", "system"],
});
for (const malformed of [
null,
{ sections: [] },
{ modules: [], sections: ["billing"] },
{ modules: [], sections: ["general", "general"] },
{ extra: 1, modules: [], sections: [] },
{ modules: [{ available: "yes", id: "a", name: "A" }], sections: [] },
{ modules: [{ available: true, id: "", name: "A" }], sections: [] },
{ modules: [{ available: true, id: "a", name: " " }], sections: [] },
{
modules: [
{ available: true, id: "a", name: "A" },
{ available: false, id: "a", name: "B" },
],
sections: [],
},
]) {
expect(() => parseDashboardSettings(malformed), JSON.stringify(malformed)).toThrow(
HostProtocolError,
);
}
});
it("never requests navigation for an anonymous viewer", async () => {
+5 -2
View File
@@ -196,7 +196,7 @@ describe("the Dashboard opens on the page its URL names", () => {
});
it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => {
for (const path of ["/settings/displays", "/calendar/events", "/account"]) {
for (const path of ["/settings/displays/add", "/calendar/events", "/account"]) {
await act(async () => {
root.unmount();
});
@@ -216,7 +216,10 @@ describe("the Dashboard opens on the page its URL names", () => {
// one could not turn `/settings` into a module page.
const { imported } = await render("/settings");
expect(query("host-not-found")).not.toBeNull();
// `/settings` is the Settings area (ADR 0061) — here its view could not be loaded, since this
// stub answers nothing for it — and never a module page.
expect(query("settings-load-failed")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(imported).toEqual([]);
expect(query("select-signage")?.getAttribute("href")).toBe("/signage");
expect(container.innerHTML).not.toContain("/modules/");
+7 -1
View File
@@ -9,6 +9,12 @@ describe("parseReturnTarget", () => {
expect(parseReturnTarget("/")).toEqual({ kind: "overview" });
expect(parseReturnTarget("/signage")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseReturnTarget("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
// Settings pages are Dashboard pages too (ADR 0061).
expect(parseReturnTarget("/settings")).toEqual({ kind: "settings" });
expect(parseReturnTarget("/settings/signage")).toEqual({
kind: "module-settings",
moduleId: "signage",
});
});
it("refuses anything that is not a string, or is empty or enormous", () => {
@@ -54,7 +60,7 @@ describe("parseReturnTarget", () => {
"/signage#top",
"/api/v1/principal",
"/display/signage",
"/settings",
"/settings/displays/add",
"/signage/timelines",
]) {
expect(parseReturnTarget(value), value).toBeUndefined();
@@ -0,0 +1,437 @@
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,
};
}
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();
});
});