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
@@ -69,9 +69,13 @@ pnpm modules:pack --module templates/module --target any --out <directory> \
|
||||
--source-revision "$(git rev-parse HEAD)"
|
||||
```
|
||||
|
||||
The module owns its feature screens and display renderer. Do not add platform navigation, the
|
||||
containing shell, global theme, authentication screens, or top-level routing; those belong to
|
||||
surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never
|
||||
The module owns its feature screens and display renderer. Its manifest (version 9) declares its
|
||||
sidebar icon and two primary pages, Records and About. Its UI renders whichever page
|
||||
`context.navigation.location()` names, links with `navigation.link()`, shows a record's detail at
|
||||
`records/<id>` beneath the Records page, and opens its Settings page with `navigation.settings`
|
||||
([ADR 0063](../../docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). Do
|
||||
not add a tab bar for those pages, the containing shell, global theme, authentication screens, or
|
||||
top-level routing, and never touch `window.history`; those belong to surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never
|
||||
hardcodes Ekklesia routes. The display permission is enforced by the host before it serves the page
|
||||
or descriptor; `context.permissions` remains a presentation-only hint.
|
||||
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifestVersion": 8,
|
||||
"manifestVersion": 9,
|
||||
"id": "example",
|
||||
"name": "Example Module",
|
||||
"description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.",
|
||||
@@ -48,6 +48,21 @@
|
||||
"description": "Renders this module's record summary inside a host module's own screen."
|
||||
}
|
||||
],
|
||||
"navigation": {
|
||||
"icon": "clipboard-list",
|
||||
"pages": [
|
||||
{
|
||||
"id": "records",
|
||||
"label": "Records",
|
||||
"path": "records"
|
||||
},
|
||||
{
|
||||
"id": "about",
|
||||
"label": "About",
|
||||
"path": "about"
|
||||
}
|
||||
]
|
||||
},
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
|
||||
@@ -1,24 +1,64 @@
|
||||
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||
import { EmptyState, LoadingState, Panel } from "@ekklesia/ui";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import type {
|
||||
ModuleUiHostContext,
|
||||
ModuleUiLocation,
|
||||
ModuleUiNavigation,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import { Button, EmptyState, LoadingState, PageHeader, Panel } from "@ekklesia/ui";
|
||||
import { type ReactElement, useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* The template's example screen. It is deliberately tiny, and deliberately host-neutral: it reads
|
||||
* everything it needs from the host context and hardcodes no URL, no origin, and no global.
|
||||
* The template's example screens. Deliberately small, and deliberately host-neutral: everything
|
||||
* comes from the host context, and no URL, origin, route, or global is hardcoded.
|
||||
*
|
||||
* The module declares two primary pages in its manifest (`navigation.pages`, manifest v9,
|
||||
* ADR 0063): **Records** and **About**. The Dashboard draws them in its sidebar beneath this
|
||||
* module, so the module renders no tab bar of its own — it renders whichever page the host says is
|
||||
* open (`context.navigation.location()`), and moves with `navigation.link()` and
|
||||
* `navigation.navigate()` instead of touching `window.history`. A record's own page,
|
||||
* `records/<id>`, lies beneath the Records page: a detail route, which needs no manifest entry.
|
||||
*
|
||||
* Presentation comes from the platform's shared elements and this module's own stylesheet
|
||||
* (`./styles.css`, declared through `defineModuleUi({ styles })`), so it matches every other
|
||||
* Ekklesia screen without copying a single colour value (ADR 0039, ADR 0040).
|
||||
*/
|
||||
export function ExampleApp({ context }: { readonly context: ModuleUiHostContext }): ReactElement {
|
||||
const [recordCount, setRecordCount] = useState<number | undefined>(undefined);
|
||||
|
||||
// Presentation-only gating. The server decides what actually happens; this only decides what the
|
||||
// viewer is offered. See docs/development/security.md.
|
||||
const canRead = context.permissions.has("example.records.read");
|
||||
interface ExampleRecord {
|
||||
readonly id: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
const NO_LOCATION: ModuleUiLocation = Object.freeze({ page: undefined, path: "" });
|
||||
|
||||
/** Where the host says this module is. A host without addressable pages — a standalone host —
|
||||
* supplies no navigation, and the module shows its landing view. */
|
||||
function useModuleLocation(navigation: ModuleUiNavigation | undefined): ModuleUiLocation {
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => navigation?.subscribe(listener) ?? (() => undefined),
|
||||
[navigation],
|
||||
);
|
||||
const read = useCallback(() => navigation?.location() ?? NO_LOCATION, [navigation]);
|
||||
return useSyncExternalStore(subscribe, read, read);
|
||||
}
|
||||
|
||||
/** A link to one of this module's own pages: a real `<a>`, opened in place by the host. */
|
||||
function ModuleLink(props: {
|
||||
readonly navigation: ModuleUiNavigation;
|
||||
readonly to: string;
|
||||
readonly children: string;
|
||||
readonly testId: string;
|
||||
}): ReactElement {
|
||||
const link = props.navigation.link(props.to);
|
||||
return (
|
||||
<a className="example-link" data-testid={props.testId} href={link.href} onClick={link.onClick}>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function useRecords(context: ModuleUiHostContext, enabled: boolean) {
|
||||
const [records, setRecords] = useState<readonly ExampleRecord[] | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!canRead) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -28,20 +68,40 @@ export function ExampleApp({ context }: { readonly context: ModuleUiHostContext
|
||||
void context.api
|
||||
.request("records")
|
||||
.then((response) => (response.ok ? response.json() : []))
|
||||
.then((records: unknown) => {
|
||||
.then((body: unknown) => {
|
||||
if (!cancelled) {
|
||||
setRecordCount(Array.isArray(records) ? records.length : 0);
|
||||
setRecords(
|
||||
Array.isArray(body)
|
||||
? body.filter(
|
||||
(record): record is ExampleRecord =>
|
||||
typeof record === "object" &&
|
||||
record !== null &&
|
||||
typeof (record as ExampleRecord).id === "string" &&
|
||||
typeof (record as ExampleRecord).value === "string",
|
||||
)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setRecordCount(0);
|
||||
setRecords([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canRead, context.api]);
|
||||
}, [context.api, enabled]);
|
||||
return records;
|
||||
}
|
||||
|
||||
export function ExampleApp({ context }: { readonly context: ModuleUiHostContext }): ReactElement {
|
||||
// Presentation-only gating. The server decides what actually happens; this only decides what the
|
||||
// viewer is offered. See docs/development/security.md.
|
||||
const canRead = context.permissions.has("example.records.read");
|
||||
const { navigation } = context;
|
||||
const location = useModuleLocation(navigation);
|
||||
const records = useRecords(context, canRead);
|
||||
|
||||
if (!canRead) {
|
||||
return (
|
||||
@@ -52,15 +112,106 @@ export function ExampleApp({ context }: { readonly context: ModuleUiHostContext
|
||||
);
|
||||
}
|
||||
|
||||
if (navigation !== undefined && location.page === "about") {
|
||||
return (
|
||||
<div className="example-page" data-testid="example-about">
|
||||
<PageHeader description="How this example module is put together." title="About" />
|
||||
<Panel>
|
||||
<p className="example-text">
|
||||
This module declares two pages, Records and About, in its manifest. The Dashboard shows
|
||||
them in its own navigation, so the module never draws a tab bar of its own.
|
||||
</p>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (navigation !== undefined && location.page === "records") {
|
||||
const recordId = location.path.startsWith("records/")
|
||||
? location.path.slice("records/".length)
|
||||
: undefined;
|
||||
if (recordId !== undefined) {
|
||||
const record = records?.find((candidate) => candidate.id === recordId);
|
||||
return (
|
||||
<div className="example-page" data-testid="example-record">
|
||||
<PageHeader
|
||||
back={{
|
||||
href: navigation.link("records").href,
|
||||
label: "Back to Records",
|
||||
onBack: () => navigation.navigate("records"),
|
||||
}}
|
||||
title="Record"
|
||||
/>
|
||||
{records === undefined ? (
|
||||
<LoadingState label="Loading record…" />
|
||||
) : record === undefined ? (
|
||||
<EmptyState description="It may have been removed." title="This record isn't here" />
|
||||
) : (
|
||||
<Panel>
|
||||
<p className="example-text" data-testid="example-record-value">
|
||||
{record.value}
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="example-page" data-testid="example-records">
|
||||
<PageHeader description="Everything this module knows about." title="Records" />
|
||||
{records === undefined ? (
|
||||
<LoadingState label="Loading records…" />
|
||||
) : records.length === 0 ? (
|
||||
<EmptyState
|
||||
description="Records appear here once there are any."
|
||||
title="No records yet"
|
||||
/>
|
||||
) : (
|
||||
<Panel>
|
||||
<ul className="example-list">
|
||||
{records.map((record) => (
|
||||
<li key={record.id}>
|
||||
<ModuleLink
|
||||
navigation={navigation}
|
||||
testId={`example-record-link-${record.id}`}
|
||||
to={`records/${encodeURIComponent(record.id)}`}
|
||||
>
|
||||
{record.value}
|
||||
</ModuleLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const settings = navigation?.settings;
|
||||
return (
|
||||
<Panel description="Everything this module knows about." title="Example module UI">
|
||||
{recordCount === undefined ? (
|
||||
<Panel
|
||||
actions={
|
||||
settings === undefined ? undefined : (
|
||||
<Button data-testid="example-open-settings" onClick={() => settings.open()}>
|
||||
Settings
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
description="Everything this module knows about."
|
||||
title="Example module UI"
|
||||
>
|
||||
{records === undefined ? (
|
||||
<LoadingState label="Loading records…" />
|
||||
) : (
|
||||
<div className="example-summary">
|
||||
<div className="example-summary" data-testid="example-home">
|
||||
<p className="example-summary__count" data-testid="record-count">
|
||||
{`${recordCount} record(s)`}
|
||||
{`${records.length} record(s)`}
|
||||
</p>
|
||||
{navigation === undefined ? undefined : (
|
||||
<ModuleLink navigation={navigation} testId="example-open-records" to="records">
|
||||
View records
|
||||
</ModuleLink>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
@@ -29,3 +29,30 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.example-page {
|
||||
display: grid;
|
||||
gap: var(--ekklesia-spacing-lg);
|
||||
}
|
||||
|
||||
.example-text {
|
||||
color: var(--ekklesia-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.example-list {
|
||||
display: grid;
|
||||
gap: var(--ekklesia-spacing-xs);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.example-link {
|
||||
color: var(--ekklesia-accent-strong);
|
||||
}
|
||||
|
||||
.example-link:focus-visible {
|
||||
outline: var(--ekklesia-state-focus-visible-width) solid var(--ekklesia-accent-focus);
|
||||
outline-offset: var(--ekklesia-state-focus-visible-offset);
|
||||
}
|
||||
|
||||
@@ -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