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
@@ -2,7 +2,7 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { parseModuleManifest } from "@ekklesia/module-sdk";
|
||||
import { moduleManifestNavigation, parseModuleManifest } from "@ekklesia/module-sdk";
|
||||
import { buildModuleUi } from "@ekklesia/module-ui-build";
|
||||
import {
|
||||
type ModuleUiApiClient,
|
||||
@@ -58,6 +58,7 @@ function recordingHost(options: {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: undefined,
|
||||
moduleId: "example",
|
||||
navigation: undefined,
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
permissions: { has: () => options.canRead },
|
||||
@@ -65,6 +66,56 @@ function recordingHost(options: {
|
||||
};
|
||||
}
|
||||
|
||||
/** A host navigation capability the test drives: the pages this module declares, a location the
|
||||
* host can move, and a record of every place the module asked to go. */
|
||||
function fakeNavigation(initial: string) {
|
||||
const pages = ["records", "about"];
|
||||
let path = initial;
|
||||
const listeners = new Set<() => void>();
|
||||
const pageOf = (route: string) =>
|
||||
pages.find((page) => route === page || route.startsWith(`${page}/`));
|
||||
let snapshot = { page: pageOf(path), path };
|
||||
const visited: string[] = [];
|
||||
const goTo = (next: string): void => {
|
||||
path = next;
|
||||
snapshot = { page: pageOf(path), path };
|
||||
for (const listener of [...listeners]) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
const navigation = {
|
||||
goTo,
|
||||
link: (to: string) => ({
|
||||
href: `/example/${to}`,
|
||||
onClick: (event: { preventDefault(): void }) => {
|
||||
event.preventDefault();
|
||||
visited.push(to);
|
||||
goTo(to);
|
||||
},
|
||||
}),
|
||||
location: () => snapshot,
|
||||
navigate: (to: string) => {
|
||||
visited.push(to);
|
||||
goTo(to);
|
||||
},
|
||||
settings: {
|
||||
link: () => ({ href: "/settings/example", onClick: () => undefined }),
|
||||
open: () => {
|
||||
navigation.settingsOpened += 1;
|
||||
},
|
||||
},
|
||||
settingsOpened: 0,
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
visited,
|
||||
};
|
||||
return navigation;
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
// Let React commit and the awaited fetch microtasks settle.
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
@@ -187,6 +238,71 @@ describe("module UI build pipeline", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("declares two primary pages and an icon in its manifest v9 navigation (ADR 0063)", async () => {
|
||||
const manifest = parseModuleManifest(
|
||||
JSON.parse(await readFile(path.join(moduleDirectory, "ekklesia.module.json"), "utf8")),
|
||||
);
|
||||
expect(manifest.manifestVersion).toBe(9);
|
||||
expect(moduleManifestNavigation(manifest)).toEqual({
|
||||
icon: "clipboard-list",
|
||||
pages: [
|
||||
{ id: "records", label: "Records", path: "records" },
|
||||
{ id: "about", label: "About", path: "about" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the page its host says is open, and moves only through the host's navigation", async () => {
|
||||
const namespace = (await import(artifactHref)) as unknown;
|
||||
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const navigation = fakeNavigation("");
|
||||
const host = {
|
||||
...recordingHost({
|
||||
canRead: true,
|
||||
records: [{ id: "a1", value: "Hello, one" }],
|
||||
requested: [],
|
||||
resolve: () => undefined,
|
||||
}),
|
||||
navigation,
|
||||
};
|
||||
|
||||
const unmount = await definition.mount(container, host);
|
||||
await flush();
|
||||
// The landing view links to a page by its host-made address, and offers Settings.
|
||||
const toRecords = container.querySelector<HTMLAnchorElement>(
|
||||
"[data-testid='example-open-records']",
|
||||
);
|
||||
expect(toRecords?.getAttribute("href")).toBe("/example/records");
|
||||
container.querySelector<HTMLButtonElement>("[data-testid='example-open-settings']")?.click();
|
||||
expect(navigation.settingsOpened).toBe(1);
|
||||
|
||||
toRecords?.click();
|
||||
await flush();
|
||||
expect(navigation.visited).toEqual(["records"]);
|
||||
expect(container.querySelector("[data-testid='example-records']")).not.toBeNull();
|
||||
const toRecord = container.querySelector<HTMLAnchorElement>(
|
||||
"[data-testid='example-record-link-a1']",
|
||||
);
|
||||
expect(toRecord?.getAttribute("href")).toBe("/example/records/a1");
|
||||
|
||||
toRecord?.click();
|
||||
await flush();
|
||||
expect(container.querySelector("[data-testid='example-record-value']")?.textContent).toBe(
|
||||
"Hello, one",
|
||||
);
|
||||
|
||||
navigation.goTo("about");
|
||||
await flush();
|
||||
expect(container.querySelector("[data-testid='example-about']")).not.toBeNull();
|
||||
// The module never touched the browser's own history.
|
||||
expect(window.location.pathname).toBe("/");
|
||||
|
||||
await (unmount as () => Promise<void>)();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("mounts the display artifact through the same ABI, then unmounts it", async () => {
|
||||
const namespace = (await import(displayArtifactHref)) as unknown;
|
||||
const definition = parseModuleUiExport(namespace, manifestIdentity);
|
||||
|
||||
Reference in New Issue
Block a user