Add the module navigation contract: manifest v9, module routes, return-to-origin
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7447381c8f
commit
4671622b9d
@@ -395,6 +395,185 @@ check(
|
||||
"widening the window brings the sidebar back on the same page",
|
||||
);
|
||||
|
||||
// --- Module navigation (ADR 0063): pages in the sidebar, real routes, module → Settings → back ---
|
||||
if (openedModuleId) {
|
||||
const moduleId = openedModuleId;
|
||||
const declared =
|
||||
await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/dashboard/navigation`)}, {
|
||||
credentials: "include",
|
||||
}).then((response) => response.json()).then((navigation) => {
|
||||
const entries = [...navigation.ungrouped, ...navigation.groups.flatMap((group) => group.modules)];
|
||||
return entries.find((entry) => entry.module.id === ${JSON.stringify(moduleId)})?.navigation ?? null;
|
||||
})`);
|
||||
const inModule = (inner) =>
|
||||
`document.querySelector("[data-ekklesia-module-ui]")?.shadowRoot?.querySelector(${JSON.stringify(inner)})`;
|
||||
const moduleMounted = `${selector("module-container")}?.childElementCount > 0 && !${selector("module-error")}`;
|
||||
const isCurrent = (testId) => `${selector(testId)}?.getAttribute("aria-current") === "page"`;
|
||||
if (declared === null) {
|
||||
check(false, "the navigation view describes the opened module");
|
||||
} else if (declared.pages.length === 0) {
|
||||
// A manifest v8 module (or a v9 one with no pages): exactly as before.
|
||||
await navigate(`${dashboardOrigin}/${moduleId}`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${moduleMounted} && ${isCurrent(`select-${moduleId}`)} && !${selector(`module-subnavigation-${moduleId}`)} && ${selector(`select-${moduleId}`)}?.querySelector("svg")?.dataset.icon === ${JSON.stringify(declared.icon === null ? "module" : `module:${declared.icon}`)}`,
|
||||
"the single-page module with its icon and no sub-navigation",
|
||||
),
|
||||
`${moduleId} declares no pages: one sidebar item with its icon, mounted at its root`,
|
||||
);
|
||||
await navigate(`${dashboardOrigin}/${moduleId}/definitely-not-a-page`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("host-not-found")} && !(${selector("module-container")}?.childElementCount > 0)`,
|
||||
"not-found beneath a single-page module",
|
||||
),
|
||||
`an address beneath ${moduleId}'s root is not found, and mounts nothing`,
|
||||
);
|
||||
} else {
|
||||
const [first, second] = declared.pages;
|
||||
const pagePath = (page) => `/${moduleId}/${page.path}`;
|
||||
await navigate(`${dashboardOrigin}/${moduleId}`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${moduleMounted} && ${isCurrent(`select-${moduleId}`)} && ${selector(`module-subnavigation-${moduleId}`)}?.querySelectorAll("a").length === ${declared.pages.length} && ${selector(`select-${moduleId}`)}?.querySelector("svg")?.dataset.icon === ${JSON.stringify(declared.icon === null ? "module" : `module:${declared.icon}`)}`,
|
||||
"the module's pages beneath it in the sidebar",
|
||||
),
|
||||
`${moduleId}'s declared icon and ${declared.pages.length} pages appear beneath it while it is open`,
|
||||
);
|
||||
await evaluate(
|
||||
`window.__ekklesiaModuleRoot = document.querySelector("[data-ekklesia-module-ui]")`,
|
||||
);
|
||||
await evaluate(`${selector(`select-${moduleId}-${first.id}`)}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === ${JSON.stringify(pagePath(first))} && ${isCurrent(`select-${moduleId}-${first.id}`)} && document.querySelector("[data-ekklesia-module-ui]") === window.__ekklesiaModuleRoot`,
|
||||
"the first page, without remounting the module",
|
||||
),
|
||||
`choosing ${first.label} opens ${pagePath(first)} in place, marks it, and keeps the module mounted`,
|
||||
);
|
||||
if (second) {
|
||||
await navigate(`${dashboardOrigin}${pagePath(second)}`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === ${JSON.stringify(pagePath(second))} && ${moduleMounted} && ${isCurrent(`select-${moduleId}-${second.id}`)}`,
|
||||
"a declared page from its deep link",
|
||||
),
|
||||
`a deep link to ${pagePath(second)} opens that page (what a refresh or bookmark does)`,
|
||||
);
|
||||
await evaluate("history.back()");
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === ${JSON.stringify(pagePath(first))} && ${isCurrent(`select-${moduleId}-${first.id}`)} && ${moduleMounted}`,
|
||||
"Back to the previous page",
|
||||
),
|
||||
"Back returns to the previously open page",
|
||||
);
|
||||
await evaluate("history.forward()");
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === ${JSON.stringify(pagePath(second))} && ${isCurrent(`select-${moduleId}-${second.id}`)}`,
|
||||
"Forward to the next page",
|
||||
),
|
||||
"Forward returns to it again",
|
||||
);
|
||||
}
|
||||
await navigate(`${dashboardOrigin}/${moduleId}/definitely-not-a-page`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("host-not-found")} && !(${selector("module-container")}?.childElementCount > 0)`,
|
||||
"not-found for an undeclared page",
|
||||
),
|
||||
`an undeclared page under ${moduleId} is not found, and mounts nothing`,
|
||||
);
|
||||
if (moduleId === "example") {
|
||||
// The template's own pages: a detail route beneath Records, and its Settings link.
|
||||
await navigate(`${dashboardOrigin}/example/records`);
|
||||
await waitFor(
|
||||
inModule('[data-testid^="example-record-link-"]'),
|
||||
"a record link inside the module",
|
||||
);
|
||||
await evaluate(`${inModule('[data-testid^="example-record-link-"]')}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname.startsWith("/example/records/") && ${isCurrent("select-example-records")} && ${inModule('[data-testid="example-record-value"]')}`,
|
||||
"the record's detail route",
|
||||
),
|
||||
"a detail route beneath a declared page opens inside the module, its page marked",
|
||||
);
|
||||
await navigate(`${dashboardOrigin}/example`);
|
||||
await waitFor(
|
||||
inModule('[data-testid="example-open-settings"]'),
|
||||
"the module's Settings button",
|
||||
);
|
||||
await evaluate(`${inModule('[data-testid="example-open-settings"]')}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === "/settings/example" && location.search === "?return=%2Fexample" && ${selector("module-settings")}`,
|
||||
"the module's Settings page, carrying where it came from",
|
||||
),
|
||||
"the module opens its centralized Settings page through its navigation capability",
|
||||
);
|
||||
}
|
||||
// Settings → Back to exactly the module page that sent the person there, across a refresh.
|
||||
await navigate(
|
||||
`${dashboardOrigin}/settings/${moduleId}?return=${encodeURIComponent(pagePath(first))}`,
|
||||
);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("module-settings")}?.querySelector(".ek-page-header__back")?.getAttribute("href") === ${JSON.stringify(pagePath(first))}`,
|
||||
"Back to the originating module page",
|
||||
),
|
||||
"after a refresh, Settings' Back still names the exact module page it was opened from",
|
||||
);
|
||||
await evaluate(`${selector("module-settings")}.querySelector(".ek-page-header__back").click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === ${JSON.stringify(pagePath(first))} && ${moduleMounted} && ${isCurrent(`select-${moduleId}-${first.id}`)}`,
|
||||
"the originating module page",
|
||||
),
|
||||
`Back from Settings returns to exactly ${pagePath(first)}`,
|
||||
);
|
||||
await navigate(
|
||||
`${dashboardOrigin}/settings/${moduleId}?return=${encodeURIComponent("https://evil.example.test/")}`,
|
||||
);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("module-settings")}?.querySelector(".ek-page-header__back")?.getAttribute("href") === "/settings"`,
|
||||
"an unsafe return target ignored",
|
||||
),
|
||||
"an off-site return target is ignored: Back goes to Settings",
|
||||
);
|
||||
// The phone drawer: the identical hierarchy, closing once a page is chosen.
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 2,
|
||||
height: 844,
|
||||
mobile: true,
|
||||
width: 390,
|
||||
});
|
||||
await navigate(`${dashboardOrigin}/${moduleId}`);
|
||||
await waitFor(selector("mobile-navigation-trigger"), "the phone navigation trigger");
|
||||
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
|
||||
await waitFor(
|
||||
`${selector("navigation-drawer")}?.querySelector('[data-testid="module-subnavigation-${moduleId}"]')`,
|
||||
"the module's pages in the drawer",
|
||||
);
|
||||
await evaluate(`${selector(`select-${moduleId}-${first.id}`)}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === ${JSON.stringify(pagePath(first))} && !${selector("navigation-drawer")} && ${moduleMounted}`,
|
||||
"a page chosen from the drawer",
|
||||
),
|
||||
"the phone drawer shows the module's pages, and closes once one is chosen",
|
||||
);
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 1,
|
||||
height: 1080,
|
||||
mobile: false,
|
||||
width: 1920,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Settings (ADR 0061): a deep link, a refresh, Discover, a module's settings, the drawer -------
|
||||
await navigate(`${dashboardOrigin}/settings`);
|
||||
check(
|
||||
|
||||
Reference in New Issue
Block a user