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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user