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
@@ -23,6 +23,10 @@
|
||||
"./display-assets": {
|
||||
"types": "./dist/display-assets.d.ts",
|
||||
"import": "./dist/display-assets.js"
|
||||
},
|
||||
"./module-icons": {
|
||||
"types": "./dist/module-icons.d.ts",
|
||||
"import": "./dist/module-icons.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
CSSProperties,
|
||||
FocusEventHandler,
|
||||
KeyboardEventHandler,
|
||||
MouseEvent,
|
||||
MouseEventHandler,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
@@ -57,7 +56,14 @@ const currentStyle: CSSProperties = {
|
||||
};
|
||||
|
||||
/** A click the browser should handle itself: a new tab or window, a download, or another button. */
|
||||
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
|
||||
export function isBrowserHandledClick(event: {
|
||||
readonly defaultPrevented: boolean;
|
||||
readonly button: number;
|
||||
readonly metaKey: boolean;
|
||||
readonly ctrlKey: boolean;
|
||||
readonly shiftKey: boolean;
|
||||
readonly altKey: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
|
||||
@@ -20,7 +20,14 @@ import {
|
||||
signInWithEmail,
|
||||
signOutHost,
|
||||
} from "../auth-protocol.js";
|
||||
import { type DashboardRoute, dashboardPath, parseDashboardPath } from "../dashboard-routes.js";
|
||||
import {
|
||||
type DashboardRoute,
|
||||
dashboardPath,
|
||||
parseDashboardPath,
|
||||
returnTargetFromSearch,
|
||||
} from "../dashboard-routes.js";
|
||||
import { resolveModuleRoute } from "../module-navigation.js";
|
||||
import { parseReturnTarget } from "../return-target.js";
|
||||
import {
|
||||
bootstrapHost,
|
||||
type DashboardNavigationEntry,
|
||||
@@ -42,6 +49,7 @@ import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js
|
||||
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
|
||||
import { LoginForm } from "./LoginForm.js";
|
||||
import { ModuleUiFrame } from "./ModuleUiFrame.js";
|
||||
import { createModuleUiNavigation } from "./module-ui-navigation.js";
|
||||
import { ResetPasswordForm } from "./ResetPasswordForm.js";
|
||||
import { ShellLayout } from "./ShellLayout.js";
|
||||
import { resolveSettingsPage, type SettingsPage, settingsPageTitle } from "./settings-catalog.js";
|
||||
@@ -51,6 +59,7 @@ import {
|
||||
SettingsHomePage,
|
||||
SettingsLoadFailedPage,
|
||||
SettingsSectionPage,
|
||||
type SettingsReturn,
|
||||
SettingsUnavailablePage,
|
||||
} from "./SettingsPages.js";
|
||||
import { createMemorySidebarPreference, type SidebarPreference } from "./sidebar-preference.js";
|
||||
@@ -194,19 +203,20 @@ function SettingsPageView(props: {
|
||||
readonly page: SettingsPage;
|
||||
readonly history: HostHistory;
|
||||
readonly onRetry: () => void;
|
||||
readonly returnTo: SettingsReturn;
|
||||
}): ReactElement {
|
||||
const { page, history } = props;
|
||||
const { page, history, returnTo } = props;
|
||||
switch (page.kind) {
|
||||
case "home":
|
||||
return <SettingsHomePage history={history} settings={page.settings} />;
|
||||
case "section":
|
||||
return <SettingsSectionPage history={history} section={page.section} />;
|
||||
return <SettingsSectionPage history={history} returnTo={returnTo} section={page.section} />;
|
||||
case "discover":
|
||||
return <DiscoverPage history={history} view={page.view} />;
|
||||
return <DiscoverPage history={history} returnTo={returnTo} view={page.view} />;
|
||||
case "module":
|
||||
return <ModuleSettingsPage history={history} module={page.module} />;
|
||||
return <ModuleSettingsPage history={history} module={page.module} returnTo={returnTo} />;
|
||||
case "unavailable":
|
||||
return <SettingsUnavailablePage history={history} reason={page.reason} />;
|
||||
return <SettingsUnavailablePage history={history} reason={page.reason} returnTo={returnTo} />;
|
||||
case "load-failed":
|
||||
return <SettingsLoadFailedPage onRetry={props.onRetry} />;
|
||||
case "not-found":
|
||||
@@ -220,6 +230,74 @@ function routeKey(route: DashboardRoute): string {
|
||||
return route.kind === "not-found" ? "not-found" : dashboardPath(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `?return=` value the Settings pages may use (ADR 0063), or `undefined`. `parseReturnTarget`
|
||||
* accepts only a canonical same-origin Dashboard address; a module's address is additionally checked
|
||||
* against the modules actually running here and the routes each declares, so Back never points at a
|
||||
* module that is not running or at a page it does not have.
|
||||
*/
|
||||
function usableReturnTarget(
|
||||
raw: string | undefined,
|
||||
entries: readonly DashboardNavigationEntry[],
|
||||
): string | undefined {
|
||||
const destination = parseReturnTarget(raw);
|
||||
if (destination === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (destination.kind !== "module") {
|
||||
return raw;
|
||||
}
|
||||
const entry = entries.find((candidate) => candidate.descriptor.id === destination.moduleId);
|
||||
return entry !== undefined && resolveModuleRoute(entry.pages, destination.path) !== undefined
|
||||
? raw
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* One mounted module UI and the navigation capability it is handed (ADR 0063). The capability is
|
||||
* created once per mount and reads the address bar live, so it is the same object for the module's
|
||||
* whole life however often it moves between its pages, and the frame never remounts because of it.
|
||||
*/
|
||||
function MountedModule(props: {
|
||||
readonly bootstrap: HostBootstrap;
|
||||
readonly config: EkklesiaHostConfig;
|
||||
readonly entry: DashboardNavigationEntry;
|
||||
readonly history: HostHistory;
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
readonly onAuthorizationResponse: (status: 401 | 403) => void;
|
||||
}): ReactElement {
|
||||
const { bootstrap, entry, history } = props;
|
||||
const settingsRef = useRef(bootstrap.settings);
|
||||
settingsRef.current = bootstrap.settings;
|
||||
const moduleId = entry.descriptor.id;
|
||||
const navigation = useMemo(
|
||||
() =>
|
||||
createModuleUiNavigation({
|
||||
hasSettings: () => {
|
||||
const settings = settingsRef.current;
|
||||
return (
|
||||
settings !== "unavailable" &&
|
||||
settings.modules.some((module) => module.id === moduleId && module.available)
|
||||
);
|
||||
},
|
||||
history,
|
||||
moduleId,
|
||||
pages: entry.pages,
|
||||
}),
|
||||
[entry.pages, history, moduleId],
|
||||
);
|
||||
return (
|
||||
<ModuleUiFrame
|
||||
config={props.config}
|
||||
descriptor={entry.descriptor}
|
||||
{...(props.importModule ? { importModule: props.importModule } : {})}
|
||||
navigation={navigation}
|
||||
onAuthorizationResponse={props.onAuthorizationResponse}
|
||||
principal={bootstrap.principal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HostMessage(props: {
|
||||
readonly children: string;
|
||||
readonly testId: string;
|
||||
@@ -299,8 +377,11 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
const sidebarPreference = props.sidebarPreference ?? fallbackPreference;
|
||||
const appearance = props.appearance ?? fallbackAppearance;
|
||||
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
|
||||
const readPathname = useCallback(() => history.pathname(), [history]);
|
||||
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
|
||||
// The whole address, so a change of query alone (a new place to return to) re-renders too.
|
||||
const readAddress = useCallback(() => `${history.pathname()}${history.search()}`, [history]);
|
||||
const address = useSyncExternalStore(subscribe, readAddress, readAddress);
|
||||
const pathname = address.split("?")[0] ?? "/";
|
||||
const search = address.slice(pathname.length);
|
||||
const route = useMemo(() => parseDashboardPath(pathname), [pathname]);
|
||||
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
|
||||
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
|
||||
@@ -364,9 +445,11 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
// history entry, so what is bookmarked or shared next is the one canonical address.
|
||||
useEffect(() => {
|
||||
if (route.kind !== "not-found" && dashboardPath(route) !== pathname) {
|
||||
history.replace(dashboardPath(route));
|
||||
// The query is kept: it is where to return to (ADR 0063), and respelling the path must not
|
||||
// lose it.
|
||||
history.replace(`${dashboardPath(route)}${search}`);
|
||||
}
|
||||
}, [history, pathname, route]);
|
||||
}, [history, pathname, route, search]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@@ -522,6 +605,12 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
state.status === "ready" && route.kind === "module"
|
||||
? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId)
|
||||
: undefined;
|
||||
// Which of the open module's own routes this is (ADR 0063): its root, a declared page, or a route
|
||||
// beneath one. Anything else is the host's not-found, and the module is not mounted for it.
|
||||
const openModuleRoute =
|
||||
openEntry !== undefined && route.kind === "module"
|
||||
? resolveModuleRoute(openEntry.pages, route.path)
|
||||
: undefined;
|
||||
const settingsPage =
|
||||
state.status === "ready" &&
|
||||
(route.kind === "settings" ||
|
||||
@@ -542,7 +631,13 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
: route.kind === "overview"
|
||||
? "Overview"
|
||||
: route.kind === "module"
|
||||
? (openEntry?.name ?? "This page isn’t available")
|
||||
? openEntry === undefined
|
||||
? "This page isn’t available"
|
||||
: openModuleRoute === undefined
|
||||
? "Page not found"
|
||||
: openModuleRoute.page === undefined
|
||||
? openEntry.name
|
||||
: `${openModuleRoute.page.label} · ${openEntry.name}`
|
||||
: route.kind === "account"
|
||||
? "Account"
|
||||
: route.kind === "account-section"
|
||||
@@ -675,7 +770,16 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
viewport={viewport}
|
||||
>
|
||||
{settingsPage !== undefined ? (
|
||||
<SettingsPageView history={history} onRetry={retryBootstrap} page={settingsPage} />
|
||||
<SettingsPageView
|
||||
history={history}
|
||||
onRetry={retryBootstrap}
|
||||
page={settingsPage}
|
||||
returnTo={{
|
||||
moduleName: (moduleId) =>
|
||||
allEntries(bootstrap).find((entry) => entry.descriptor.id === moduleId)?.name,
|
||||
target: usableReturnTarget(returnTargetFromSearch(search), allEntries(bootstrap)),
|
||||
}}
|
||||
/>
|
||||
) : route.kind === "account" || route.kind === "account-section" ? (
|
||||
bootstrap.account === "unavailable" ? (
|
||||
<AccountLoadFailedPage onRetry={retryBootstrap} />
|
||||
@@ -698,17 +802,22 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
) : route.kind === "overview" ? (
|
||||
<OverviewPage />
|
||||
) : route.kind === "module" ? (
|
||||
openEntry ? (
|
||||
<ModuleUiFrame
|
||||
openEntry === undefined ? (
|
||||
// Installed but not running, or not known at all: never mounted, whatever the path.
|
||||
<ModuleUnavailablePage history={history} />
|
||||
) : openModuleRoute === undefined ? (
|
||||
<NotFoundPage history={history} />
|
||||
) : (
|
||||
<MountedModule
|
||||
bootstrap={bootstrap}
|
||||
config={config}
|
||||
descriptor={openEntry.descriptor}
|
||||
entry={openEntry}
|
||||
history={history}
|
||||
{...(importModule ? { importModule } : {})}
|
||||
// Keyed by module identity, not path: moving between its own pages never remounts it.
|
||||
key={moduleKey(openEntry)}
|
||||
onAuthorizationResponse={onAuthorizationResponse}
|
||||
principal={bootstrap.principal}
|
||||
/>
|
||||
) : (
|
||||
<ModuleUnavailablePage history={history} />
|
||||
)
|
||||
) : (
|
||||
<NotFoundPage history={history} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import { loadModuleUi, type ModuleUiImporter } from "@ekklesia/module-ui-host";
|
||||
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
|
||||
import type { ModuleUiNavigation, ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
|
||||
import { type ReactElement, useEffect, useRef, useState } from "react";
|
||||
import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../host-protocol.js";
|
||||
import { createModuleUiContext, moduleUiSourceUrl } from "../module-context.js";
|
||||
@@ -11,6 +11,9 @@ export interface ModuleUiFrameProps {
|
||||
readonly config: EkklesiaHostConfig;
|
||||
readonly descriptor: HostModuleUiDescriptor;
|
||||
readonly principal: ModuleUiPrincipal;
|
||||
/** The module's navigation capability (ADR 0063). Must be stable for the module's life: a new one
|
||||
* remounts the module. */
|
||||
readonly navigation?: ModuleUiNavigation;
|
||||
/** Host-internal session signal. It never enters the module-facing context. */
|
||||
readonly onAuthorizationResponse?: (status: 401 | 403) => void;
|
||||
/** Test-only seam, forwarded to the generic loader so a test can hold an import pending and prove
|
||||
@@ -52,7 +55,8 @@ export interface ModuleUiFrameProps {
|
||||
export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<FrameStatus>("loading");
|
||||
const { config, descriptor, importModule, onAuthorizationResponse, principal } = props;
|
||||
const { config, descriptor, importModule, navigation, onAuthorizationResponse, principal } =
|
||||
props;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -81,6 +85,7 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
|
||||
descriptor,
|
||||
principal,
|
||||
...(importModule ? { importModule } : {}),
|
||||
...(navigation ? { navigation } : {}),
|
||||
...(onAuthorizationResponse
|
||||
? { onAuthorizationResponse: reportAuthorizationResponse }
|
||||
: {}),
|
||||
@@ -114,7 +119,7 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
|
||||
// already in progress, and a module that fails to clean up must not take the shell with it.
|
||||
void mounted?.unmount().catch(() => undefined);
|
||||
};
|
||||
}, [config, descriptor, importModule, onAuthorizationResponse, principal]);
|
||||
}, [config, descriptor, importModule, navigation, onAuthorizationResponse, principal]);
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -28,13 +28,31 @@ import { ShellIcon, type ShellIconName } from "./shell-icons.js";
|
||||
|
||||
const BACK_TO_SETTINGS: DashboardDestination = { kind: "settings" };
|
||||
|
||||
/** Back plus title for a page inside Settings (DESIGN.md §4.8). Back goes to Settings; once the
|
||||
* module navigation contract carries a return target, a validated one takes precedence. */
|
||||
function settingsBack(history: HostHistory, returnTarget?: unknown) {
|
||||
/**
|
||||
* Where a Settings page was opened from (ADR 0063): the raw `?return=` value, judged by
|
||||
* `parseReturnTarget` inside `dashboardBack`, and the names of the modules a person might be going
|
||||
* back to, so Back can say "Back to Signage".
|
||||
*/
|
||||
export interface SettingsReturn {
|
||||
readonly target: unknown;
|
||||
readonly moduleName: (moduleId: string) => string | undefined;
|
||||
}
|
||||
|
||||
/** Back plus title for a page inside Settings (DESIGN.md §4.8). Back goes to Settings — or, when a
|
||||
* module opened this page, to exactly the page it was opened from, if that is a real Dashboard page.
|
||||
* Never the browser's own `history.back()`. */
|
||||
function settingsBack(history: HostHistory, returnTo?: SettingsReturn) {
|
||||
return dashboardBack(history, {
|
||||
fallback: BACK_TO_SETTINGS,
|
||||
label: (destination) => (destination.kind === "settings" ? "Back to Settings" : "Back"),
|
||||
returnTarget,
|
||||
label: (destination) => {
|
||||
if (destination.kind === "settings") {
|
||||
return "Back to Settings";
|
||||
}
|
||||
const name =
|
||||
destination.kind === "module" ? returnTo?.moduleName(destination.moduleId) : undefined;
|
||||
return name === undefined ? "Back" : `Back to ${name}`;
|
||||
},
|
||||
returnTarget: returnTo?.target,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -147,6 +165,7 @@ const SECTION_NOT_YET: Readonly<Record<SettingsPageSection, string>> = Object.fr
|
||||
export function DiscoverPage(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly view: DiscoverView;
|
||||
readonly returnTo?: SettingsReturn;
|
||||
}): ReactElement {
|
||||
const copy = DISCOVER_VIEW_COPY[props.view];
|
||||
return (
|
||||
@@ -155,7 +174,7 @@ export function DiscoverPage(props: {
|
||||
style={{ display: "grid", gap: designToken("spacing.xl") }}
|
||||
>
|
||||
<PageHeader
|
||||
back={settingsBack(props.history)}
|
||||
back={settingsBack(props.history, props.returnTo)}
|
||||
description={copy.description}
|
||||
eyebrow="Discover"
|
||||
title={copy.title}
|
||||
@@ -169,6 +188,7 @@ export function DiscoverPage(props: {
|
||||
export function SettingsSectionPage(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly section: SettingsPageSection;
|
||||
readonly returnTo?: SettingsReturn;
|
||||
}): ReactElement {
|
||||
const copy = SETTINGS_SECTION_COPY[props.section];
|
||||
return (
|
||||
@@ -177,7 +197,7 @@ export function SettingsSectionPage(props: {
|
||||
style={{ display: "grid", gap: designToken("spacing.xl") }}
|
||||
>
|
||||
<PageHeader
|
||||
back={settingsBack(props.history)}
|
||||
back={settingsBack(props.history, props.returnTo)}
|
||||
description={copy.description}
|
||||
title={copy.label}
|
||||
/>
|
||||
@@ -186,15 +206,17 @@ export function SettingsSectionPage(props: {
|
||||
);
|
||||
}
|
||||
|
||||
/** `/settings/<module-id>`: a running module's settings, not built yet. */
|
||||
/** `/settings/<module-id>`: a running module's settings, not built yet. A module opens it through its
|
||||
* navigation capability, which carries where it was so Back returns exactly there (ADR 0063). */
|
||||
export function ModuleSettingsPage(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly module: DashboardSettingsModule;
|
||||
readonly returnTo?: SettingsReturn;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div data-testid="module-settings" style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
||||
<PageHeader
|
||||
back={settingsBack(props.history)}
|
||||
back={settingsBack(props.history, props.returnTo)}
|
||||
description={`How ${props.module.name} works for your church.`}
|
||||
eyebrow="Settings"
|
||||
title={props.module.name}
|
||||
@@ -217,12 +239,13 @@ export type SettingsUnavailableReason = "no-access" | "module-disabled";
|
||||
export function SettingsUnavailablePage(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly reason: SettingsUnavailableReason;
|
||||
readonly returnTo?: SettingsReturn;
|
||||
}): ReactElement {
|
||||
const disabled = props.reason === "module-disabled";
|
||||
return (
|
||||
<div data-testid={disabled ? "settings-module-unavailable" : "settings-no-access"}>
|
||||
<PageHeader
|
||||
back={settingsBack(props.history)}
|
||||
back={settingsBack(props.history, props.returnTo)}
|
||||
description={
|
||||
disabled
|
||||
? "The module these settings belong to is turned off. Its settings come back when it is turned on again."
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import type { CSSProperties, ReactElement } from "react";
|
||||
import { type CSSProperties, Fragment, type ReactElement } from "react";
|
||||
import type { DashboardDestination, DashboardRoute } from "../dashboard-routes.js";
|
||||
import type {
|
||||
DashboardNavigation,
|
||||
DashboardNavigationEntry,
|
||||
DashboardSettings,
|
||||
} from "../host-protocol.js";
|
||||
import { moduleDestination, resolveModuleRoute } from "../module-navigation.js";
|
||||
import { DashboardLink } from "./DashboardLink.js";
|
||||
import { ModuleIcon } from "./module-icon.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
import { useNavTooltip } from "./NavTooltip.js";
|
||||
import { DISCOVER_VIEWS } from "../dashboard-routes.js";
|
||||
@@ -56,7 +58,8 @@ export function ShellBrand(props: { readonly collapsed: boolean }): ReactElement
|
||||
function NavigationItem(props: {
|
||||
readonly destination: DashboardDestination;
|
||||
readonly label: string;
|
||||
readonly icon: ShellIconName;
|
||||
/** A shell icon by name, a module's own icon, or none — a module's pages are text beneath it. */
|
||||
readonly icon?: ShellIconName | ReactElement;
|
||||
readonly current: boolean;
|
||||
readonly history: HostHistory;
|
||||
readonly presentation: NavigationPresentation;
|
||||
@@ -77,7 +80,11 @@ function NavigationItem(props: {
|
||||
to={props.destination}
|
||||
{...triggerProps}
|
||||
>
|
||||
<ShellIcon name={props.icon} />
|
||||
{props.icon === undefined ? undefined : typeof props.icon === "string" ? (
|
||||
<ShellIcon name={props.icon} />
|
||||
) : (
|
||||
props.icon
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
props.presentation.collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"
|
||||
@@ -92,6 +99,17 @@ function NavigationItem(props: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The modules in one navigation group, each with its own icon (manifest v9, ADR 0063).
|
||||
*
|
||||
* While a module is open, its declared primary pages appear beneath it, in its declared order
|
||||
* (DESIGN.md §4.4) — the same in-place expansion Settings uses (ADR 0061), so the rest of the
|
||||
* navigation stays where it was and one item carries `aria-current`: the open page, or the module
|
||||
* itself on its landing view. A route beneath a page (a detail view) marks that page. Collapsed,
|
||||
* the sidebar shows icons only, so the pages stay hidden and the module item is the current one; its
|
||||
* tooltip and accessible name are the module's name. A module that declares no pages — every
|
||||
* manifest v8 module — is one item, exactly as before.
|
||||
*/
|
||||
function ModuleItems(props: {
|
||||
readonly entries: readonly DashboardNavigationEntry[];
|
||||
readonly route: DashboardRoute;
|
||||
@@ -100,6 +118,8 @@ function ModuleItems(props: {
|
||||
readonly onNavigate?: () => void;
|
||||
readonly labelledBy?: string;
|
||||
}): ReactElement {
|
||||
const { route, presentation } = props;
|
||||
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
|
||||
return (
|
||||
<ul
|
||||
aria-labelledby={props.labelledBy}
|
||||
@@ -108,20 +128,49 @@ function ModuleItems(props: {
|
||||
>
|
||||
{props.entries.map((entry) => {
|
||||
const moduleId = entry.descriptor.id;
|
||||
const resolution =
|
||||
route.kind === "module" && route.moduleId === moduleId
|
||||
? resolveModuleRoute(entry.pages, route.path)
|
||||
: undefined;
|
||||
const showPages =
|
||||
resolution !== undefined && entry.pages.length > 0 && !presentation.collapsed;
|
||||
const openPage = resolution?.page;
|
||||
return (
|
||||
<NavigationItem
|
||||
current={props.route.kind === "module" && props.route.moduleId === moduleId}
|
||||
destination={{ kind: "module", moduleId }}
|
||||
history={props.history}
|
||||
// Every module gets the one generic icon until modules can declare their own
|
||||
// (ADR 0058); there is no table of known modules.
|
||||
icon="module"
|
||||
key={`${moduleId}@${entry.descriptor.version}`}
|
||||
label={entry.name}
|
||||
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
|
||||
presentation={props.presentation}
|
||||
testId={`select-${moduleId}`}
|
||||
/>
|
||||
<Fragment key={`${moduleId}@${entry.descriptor.version}`}>
|
||||
<NavigationItem
|
||||
current={resolution !== undefined && (openPage === undefined || !showPages)}
|
||||
destination={moduleDestination(moduleId)}
|
||||
history={props.history}
|
||||
icon={<ModuleIcon icon={entry.icon} />}
|
||||
label={entry.name}
|
||||
{...onNavigate}
|
||||
presentation={presentation}
|
||||
testId={`select-${moduleId}`}
|
||||
/>
|
||||
{showPages ? (
|
||||
<li>
|
||||
<ul
|
||||
aria-label={entry.name}
|
||||
className="ek-shell-nav__group ek-shell-nav__nested"
|
||||
data-testid={`module-subnavigation-${moduleId}`}
|
||||
style={{ listStyle: "none", margin: 0, padding: 0 }}
|
||||
>
|
||||
{entry.pages.map((page) => (
|
||||
<NavigationItem
|
||||
current={openPage?.id === page.id}
|
||||
destination={moduleDestination(moduleId, page.path)}
|
||||
history={props.history}
|
||||
key={page.id}
|
||||
label={page.label}
|
||||
{...onNavigate}
|
||||
presentation={presentation}
|
||||
testId={`select-${moduleId}-${page.id}`}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
) : undefined}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
export interface HostHistory {
|
||||
/** The current path, without query or fragment. */
|
||||
pathname(): string;
|
||||
/** Opens a new page, adding a history entry. Opening the page already showing adds none. */
|
||||
/** The current query, `""` or starting with `?`. The shell reads exactly one thing from it: the
|
||||
* place to return to (`?return=…`, ADR 0063). */
|
||||
search(): string;
|
||||
/** Opens a new page, adding a history entry. `path` may carry a query. Opening the address already
|
||||
* showing adds none. */
|
||||
push(path: string): void;
|
||||
/** Corrects the current entry in place, adding none — for a non-canonical spelling of the same
|
||||
* page. */
|
||||
@@ -28,7 +32,7 @@ export interface HostHistory {
|
||||
|
||||
/** The parts of a `Window` the browser history needs, passed in by the entry point. */
|
||||
export interface HostHistoryWindow {
|
||||
readonly location: Pick<Location, "pathname">;
|
||||
readonly location: Pick<Location, "pathname" | "search">;
|
||||
readonly history: Pick<History, "pushState" | "replaceState">;
|
||||
addEventListener(type: "popstate", listener: () => void): void;
|
||||
removeEventListener(type: "popstate", listener: () => void): void;
|
||||
@@ -47,8 +51,9 @@ export function createBrowserHostHistory(view: HostHistoryWindow): HostHistory {
|
||||
};
|
||||
return {
|
||||
pathname: () => view.location.pathname,
|
||||
search: () => view.location.search,
|
||||
push(path) {
|
||||
if (path === view.location.pathname) {
|
||||
if (path === `${view.location.pathname}${view.location.search}`) {
|
||||
return;
|
||||
}
|
||||
view.history.pushState(null, "", path);
|
||||
@@ -98,7 +103,12 @@ export function createMemoryHostHistory(initialPath = "/"): MemoryHostHistory {
|
||||
get index() {
|
||||
return index;
|
||||
},
|
||||
pathname: () => entries[index] ?? "/",
|
||||
pathname: () => (entries[index] ?? "/").split("?")[0] ?? "/",
|
||||
search: () => {
|
||||
const entry = entries[index] ?? "/";
|
||||
const query = entry.indexOf("?");
|
||||
return query === -1 ? "" : entry.slice(query);
|
||||
},
|
||||
push(path) {
|
||||
if (path === entries[index]) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
Activity,
|
||||
Archive,
|
||||
Baby,
|
||||
Bell,
|
||||
BookOpen,
|
||||
BookOpenText,
|
||||
Bookmark,
|
||||
Briefcase,
|
||||
Building,
|
||||
Bus,
|
||||
Calendar,
|
||||
CalendarCheck,
|
||||
CalendarDays,
|
||||
Camera,
|
||||
Car,
|
||||
ChartColumn,
|
||||
ChartPie,
|
||||
Church,
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
Clock,
|
||||
Coffee,
|
||||
Contact,
|
||||
CreditCard,
|
||||
Database,
|
||||
DoorOpen,
|
||||
FileText,
|
||||
Files,
|
||||
Film,
|
||||
Flag,
|
||||
Folder,
|
||||
Gift,
|
||||
Globe,
|
||||
GraduationCap,
|
||||
HandCoins,
|
||||
HandHeart,
|
||||
Handshake,
|
||||
Headphones,
|
||||
Heart,
|
||||
HeartHandshake,
|
||||
House,
|
||||
Image,
|
||||
Images,
|
||||
Inbox,
|
||||
KeyRound,
|
||||
Landmark,
|
||||
LayoutGrid,
|
||||
Library,
|
||||
ListChecks,
|
||||
ListTodo,
|
||||
Mail,
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Megaphone,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Monitor,
|
||||
MonitorPlay,
|
||||
Music,
|
||||
Newspaper,
|
||||
Notebook,
|
||||
Package,
|
||||
Palette,
|
||||
PartyPopper,
|
||||
Phone,
|
||||
Presentation,
|
||||
Printer,
|
||||
Projector,
|
||||
Radio,
|
||||
Receipt,
|
||||
School,
|
||||
Send,
|
||||
Shield,
|
||||
ShoppingBag,
|
||||
Sparkles,
|
||||
Star,
|
||||
Sun,
|
||||
TabletSmartphone,
|
||||
Tag,
|
||||
Tent,
|
||||
Ticket,
|
||||
Trophy,
|
||||
Truck,
|
||||
Tv,
|
||||
UserCheck,
|
||||
UserRound,
|
||||
Users,
|
||||
UsersRound,
|
||||
Utensils,
|
||||
Video,
|
||||
Wallet,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { ReactElement } from "react";
|
||||
import { type DashboardModuleIconName, isDashboardModuleIconName } from "../module-icons.js";
|
||||
import { ShellIcon } from "./shell-icons.js";
|
||||
|
||||
/**
|
||||
* The Lucide components for the icons a module may declare (manifest v9, ADR 0063), by name.
|
||||
*
|
||||
* Each icon is imported by name, so the Dashboard bundles exactly this curated set and never the
|
||||
* whole library. Typed against `DASHBOARD_MODULE_ICON_NAMES`, so the map can neither miss a name
|
||||
* the platform accepts nor carry one it does not; the shell's tests prove each entry is a real Lucide
|
||||
* component.
|
||||
*/
|
||||
export const MODULE_ICON_COMPONENTS: Readonly<Record<DashboardModuleIconName, LucideIcon>> =
|
||||
Object.freeze({
|
||||
activity: Activity,
|
||||
archive: Archive,
|
||||
baby: Baby,
|
||||
bell: Bell,
|
||||
"book-open": BookOpen,
|
||||
"book-open-text": BookOpenText,
|
||||
bookmark: Bookmark,
|
||||
briefcase: Briefcase,
|
||||
building: Building,
|
||||
bus: Bus,
|
||||
calendar: Calendar,
|
||||
"calendar-check": CalendarCheck,
|
||||
"calendar-days": CalendarDays,
|
||||
camera: Camera,
|
||||
car: Car,
|
||||
"chart-column": ChartColumn,
|
||||
"chart-pie": ChartPie,
|
||||
church: Church,
|
||||
"clipboard-check": ClipboardCheck,
|
||||
"clipboard-list": ClipboardList,
|
||||
clock: Clock,
|
||||
coffee: Coffee,
|
||||
contact: Contact,
|
||||
"credit-card": CreditCard,
|
||||
database: Database,
|
||||
"door-open": DoorOpen,
|
||||
"file-text": FileText,
|
||||
files: Files,
|
||||
film: Film,
|
||||
flag: Flag,
|
||||
folder: Folder,
|
||||
gift: Gift,
|
||||
globe: Globe,
|
||||
"graduation-cap": GraduationCap,
|
||||
"hand-coins": HandCoins,
|
||||
"hand-heart": HandHeart,
|
||||
handshake: Handshake,
|
||||
headphones: Headphones,
|
||||
heart: Heart,
|
||||
"heart-handshake": HeartHandshake,
|
||||
house: House,
|
||||
image: Image,
|
||||
images: Images,
|
||||
inbox: Inbox,
|
||||
"key-round": KeyRound,
|
||||
landmark: Landmark,
|
||||
"layout-grid": LayoutGrid,
|
||||
library: Library,
|
||||
"list-checks": ListChecks,
|
||||
"list-todo": ListTodo,
|
||||
mail: Mail,
|
||||
map: MapIcon,
|
||||
"map-pin": MapPin,
|
||||
megaphone: Megaphone,
|
||||
"message-circle": MessageCircle,
|
||||
"message-square": MessageSquare,
|
||||
mic: Mic,
|
||||
monitor: Monitor,
|
||||
"monitor-play": MonitorPlay,
|
||||
music: Music,
|
||||
newspaper: Newspaper,
|
||||
notebook: Notebook,
|
||||
package: Package,
|
||||
palette: Palette,
|
||||
"party-popper": PartyPopper,
|
||||
phone: Phone,
|
||||
presentation: Presentation,
|
||||
printer: Printer,
|
||||
projector: Projector,
|
||||
radio: Radio,
|
||||
receipt: Receipt,
|
||||
school: School,
|
||||
send: Send,
|
||||
shield: Shield,
|
||||
"shopping-bag": ShoppingBag,
|
||||
sparkles: Sparkles,
|
||||
star: Star,
|
||||
sun: Sun,
|
||||
"tablet-smartphone": TabletSmartphone,
|
||||
tag: Tag,
|
||||
tent: Tent,
|
||||
ticket: Ticket,
|
||||
trophy: Trophy,
|
||||
truck: Truck,
|
||||
tv: Tv,
|
||||
"user-check": UserCheck,
|
||||
"user-round": UserRound,
|
||||
users: Users,
|
||||
"users-round": UsersRound,
|
||||
utensils: Utensils,
|
||||
video: Video,
|
||||
wallet: Wallet,
|
||||
wrench: Wrench,
|
||||
});
|
||||
|
||||
/**
|
||||
* A module's icon: the Lucide icon it declared, or the generic module icon when it declared none or
|
||||
* one this Dashboard cannot draw. Decorative, like every navigation icon (ADR 0058): the module's
|
||||
* name is always beside it or in its control's accessible name.
|
||||
*/
|
||||
export function ModuleIcon(props: {
|
||||
readonly icon: string | undefined;
|
||||
readonly size?: number;
|
||||
}): ReactElement {
|
||||
if (!isDashboardModuleIconName(props.icon)) {
|
||||
return <ShellIcon name="module" {...(props.size === undefined ? {} : { size: props.size })} />;
|
||||
}
|
||||
const Icon = MODULE_ICON_COMPONENTS[props.icon];
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
data-icon={`module:${props.icon}`}
|
||||
focusable="false"
|
||||
size={props.size ?? 20}
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
type ModuleUiLink,
|
||||
type ModuleUiLinkClickEvent,
|
||||
type ModuleUiLocation,
|
||||
type ModuleUiNavigation,
|
||||
ModuleUiNavigationError,
|
||||
type ModuleUiSettingsLocation,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import {
|
||||
type DashboardDestination,
|
||||
dashboardPath,
|
||||
dashboardPathReturningTo,
|
||||
parseDashboardPath,
|
||||
} from "../dashboard-routes.js";
|
||||
import {
|
||||
type DashboardModulePage,
|
||||
moduleDestination,
|
||||
moduleRouteFor,
|
||||
resolveModuleRoute,
|
||||
} from "../module-navigation.js";
|
||||
import { isBrowserHandledClick } from "./DashboardLink.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
|
||||
export interface CreateModuleUiNavigationOptions {
|
||||
readonly moduleId: string;
|
||||
/** The module's declared primary pages; empty for a single-page module. */
|
||||
readonly pages: readonly DashboardModulePage[];
|
||||
readonly history: HostHistory;
|
||||
/** Whether this module has a Settings page this viewer may open (ADR 0061) — read each time it is
|
||||
* asked, so a Settings view that finishes loading after the module mounted is still honoured. */
|
||||
readonly hasSettings: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Dashboard's implementation of a module's navigation capability (ADR 0063).
|
||||
*
|
||||
* A narrow handle over the shell's own history, scoped to one module. It can only ever produce
|
||||
* addresses beneath `/<module-id>/` — a declared page or a route beneath one — or this module's
|
||||
* Settings page; it hands the module no history object, no router, and no way to name another
|
||||
* module or platform page. The module stays mounted as it moves between its pages: the shell keys
|
||||
* the mounted frame by module identity, not by path, and this reports each move to subscribers.
|
||||
*/
|
||||
export function createModuleUiNavigation(
|
||||
options: CreateModuleUiNavigationOptions,
|
||||
): ModuleUiNavigation {
|
||||
const { history, moduleId, pages } = options;
|
||||
|
||||
const currentPath = (): string | undefined => {
|
||||
const route = parseDashboardPath(history.pathname());
|
||||
return route.kind === "module" && route.moduleId === moduleId ? route.path : undefined;
|
||||
};
|
||||
|
||||
const locate = (path: string): ModuleUiLocation =>
|
||||
Object.freeze({ page: resolveModuleRoute(pages, path)?.page?.id, path });
|
||||
|
||||
// One frozen snapshot per distinct location, so `location()` is stable between moves — what a
|
||||
// React external-store read needs.
|
||||
let snapshot = locate(currentPath() ?? "");
|
||||
const refresh = (): boolean => {
|
||||
const path = currentPath();
|
||||
if (path === undefined || path === snapshot.path) {
|
||||
return false;
|
||||
}
|
||||
snapshot = locate(path);
|
||||
return true;
|
||||
};
|
||||
|
||||
// One subscription to the address bar, shared by every listener the module registers, and the
|
||||
// path they were last told about — so every listener hears every move exactly once, however
|
||||
// often the module reads `location()` in between.
|
||||
const listeners = new Set<() => void>();
|
||||
let stopWatching: (() => void) | undefined;
|
||||
let notifiedPath = snapshot.path;
|
||||
const notify = (): void => {
|
||||
refresh();
|
||||
if (snapshot.path === notifiedPath) {
|
||||
return;
|
||||
}
|
||||
notifiedPath = snapshot.path;
|
||||
for (const listener of [...listeners]) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
const routeFor = (path: unknown): string => {
|
||||
const route = moduleRouteFor(pages, path);
|
||||
if (route === "invalid") {
|
||||
throw new ModuleUiNavigationError("MODULE_NAVIGATION_PATH_INVALID");
|
||||
}
|
||||
if (route === "unknown") {
|
||||
throw new ModuleUiNavigationError("MODULE_NAVIGATION_PATH_UNKNOWN");
|
||||
}
|
||||
return dashboardPath(moduleDestination(moduleId, route.path));
|
||||
};
|
||||
|
||||
const linkTo = (href: string): ModuleUiLink =>
|
||||
Object.freeze({
|
||||
href,
|
||||
onClick(event: ModuleUiLinkClickEvent) {
|
||||
if (isBrowserHandledClick(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
history.push(href);
|
||||
},
|
||||
});
|
||||
|
||||
const settingsDestination: DashboardDestination = { kind: "module-settings", moduleId };
|
||||
const settingsHref = (): string => {
|
||||
// Read the address bar now: a module with no listener of its own may have moved since.
|
||||
refresh();
|
||||
return dashboardPathReturningTo(
|
||||
settingsDestination,
|
||||
moduleDestination(moduleId, snapshot.path),
|
||||
);
|
||||
};
|
||||
const settings: ModuleUiSettingsLocation = Object.freeze({
|
||||
link: () => linkTo(settingsHref()),
|
||||
open: () => history.push(settingsHref()),
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
link: (path: string) => linkTo(routeFor(path)),
|
||||
location: () => {
|
||||
refresh();
|
||||
return snapshot;
|
||||
},
|
||||
navigate(path: string, navigateOptions?: { readonly replace?: boolean }) {
|
||||
const href = routeFor(path);
|
||||
if (navigateOptions?.replace === true) {
|
||||
history.replace(href);
|
||||
} else {
|
||||
history.push(href);
|
||||
}
|
||||
},
|
||||
get settings() {
|
||||
return options.hasSettings() ? settings : undefined;
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (listeners.size === 1) {
|
||||
stopWatching = history.subscribe(notify);
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
stopWatching?.();
|
||||
stopWatching = undefined;
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
* /settings/<module-id> a module's settings (and later its own deeper settings pages)
|
||||
* /account your account
|
||||
* /account/<section> a personal settings page: appearance
|
||||
* /<module-id>[/…] a module's page, and later its own sub-pages
|
||||
* /<module-id> a module's landing view
|
||||
* /<module-id>/<path> one of its declared pages, or a route beneath one (ADR 0063)
|
||||
* ```
|
||||
*
|
||||
* The server serves the page at `/<module-id>` only for a module that is actually installed with a
|
||||
@@ -31,6 +32,15 @@
|
||||
*/
|
||||
export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const);
|
||||
|
||||
/**
|
||||
* The host's top-level namespaces that are **not** Dashboard pages: the API, served assets, and the
|
||||
* display host. No address beneath them is ever a Dashboard page or module route, so nothing — a
|
||||
* typed URL, a link, or a return target (ADR 0063) — can make the Dashboard treat `/api/…` as a page
|
||||
* to open or link to. Together with {@link DASHBOARD_PLATFORM_PAGES} these are exactly Core's
|
||||
* reserved top-level names (`RESERVED_TOP_LEVEL_NAMES`); `apps/api` tests hold the lists together.
|
||||
*/
|
||||
export const HOST_NAMESPACES = Object.freeze(["api", "assets", "display"] as const);
|
||||
|
||||
/**
|
||||
* The platform's own Settings sections, in their navigation order (ADR 0061). Every other name under
|
||||
* `/settings/` is a module's settings, at `/settings/<module-id>`. Module lifecycle — finding,
|
||||
@@ -72,7 +82,10 @@ export type DiscoverView = (typeof DISCOVER_VIEWS)[number];
|
||||
/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */
|
||||
export type DashboardRoute =
|
||||
| { readonly kind: "overview" }
|
||||
| { readonly kind: "module"; readonly moduleId: string }
|
||||
/** `path` is relative to the module's root: `""` for the landing view, `"timelines"`,
|
||||
* `"media/sermons/123"`. Whether the module has such a page is the module's declaration's to say,
|
||||
* so the shell resolves it against the navigation, not here (ADR 0063). */
|
||||
| { readonly kind: "module"; readonly moduleId: string; readonly path: string }
|
||||
| { readonly kind: "settings" }
|
||||
| { readonly kind: "settings-section"; readonly section: SettingsPageSection }
|
||||
| { readonly kind: "discover"; readonly view: DiscoverView }
|
||||
@@ -85,6 +98,7 @@ export type DashboardRoute =
|
||||
export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>;
|
||||
|
||||
const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
|
||||
const hostNamespaces: ReadonlySet<string> = new Set(HOST_NAMESPACES);
|
||||
const settingsSections: ReadonlySet<string> = new Set(SETTINGS_SECTIONS);
|
||||
|
||||
const discoverViews: ReadonlySet<string> = new Set(DISCOVER_VIEWS);
|
||||
@@ -102,6 +116,40 @@ function isAccountSection(value: string): value is AccountSection {
|
||||
return accountSections.has(value);
|
||||
}
|
||||
|
||||
/** The most segments, and characters, a module route may have beneath its root. Generous for detail
|
||||
* routes (`media/sermons/123`), bounded so a URL cannot carry anything else. */
|
||||
export const MODULE_ROUTE_MAX_SEGMENTS = 8;
|
||||
export const MODULE_ROUTE_MAX_LENGTH = 512;
|
||||
const MODULE_ROUTE_SEGMENT_PATTERN = /^[A-Za-z0-9._~-]+$/u;
|
||||
|
||||
/**
|
||||
* Reads a module-relative route (ADR 0063): `""` for the root, otherwise segments of unreserved URL
|
||||
* characters joined by `/`, none of them `.` or `..`. One leading `/` is the root's own spelling and
|
||||
* is dropped; nothing else is normalized. Anything else — a scheme, a query, a fragment, an empty
|
||||
* segment, percent-encoding, a backslash, traversal — is `undefined`, so a route can only ever name a
|
||||
* place inside its own module.
|
||||
*/
|
||||
export function parseModuleRoute(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const path = value.startsWith("/") ? value.slice(1) : value;
|
||||
if (path === "") {
|
||||
return "";
|
||||
}
|
||||
if (path.length > MODULE_ROUTE_MAX_LENGTH) {
|
||||
return undefined;
|
||||
}
|
||||
const segments = path.split("/");
|
||||
return segments.length <= MODULE_ROUTE_MAX_SEGMENTS &&
|
||||
segments.every(
|
||||
(segment) =>
|
||||
segment !== "." && segment !== ".." && MODULE_ROUTE_SEGMENT_PATTERN.test(segment),
|
||||
)
|
||||
? path
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function decodeSegment(segment: string): string | undefined {
|
||||
try {
|
||||
const decoded = decodeURIComponent(segment);
|
||||
@@ -118,9 +166,10 @@ function decodeSegment(segment: string): string | undefined {
|
||||
* decoded here, never validated against a pattern: the shell compares it with the navigation the
|
||||
* server resolved, and an id that matches no entry is an unavailable module, not a malformed URL.
|
||||
*
|
||||
* Everything below `/<module-id>/` belongs to that module's own pages (`/signage/timelines`), which
|
||||
* need a contract that does not exist yet, so it is `not-found` for now rather than silently showing
|
||||
* the module's first page. Settings and Account pages are likewise `not-found` until they exist. One
|
||||
* Everything below `/<module-id>/` belongs to that module's own pages (`/signage/timelines`, ADR
|
||||
* 0063): read here as a module-relative route, and resolved by the shell against the pages the module
|
||||
* declares, so a route no page owns is `not-found` there rather than silently showing the module's
|
||||
* first page. Settings and Account pages beyond those that exist are `not-found` too. One
|
||||
* trailing slash is tolerated, as a person typing a URL expects; the shell then replaces it with the
|
||||
* canonical spelling.
|
||||
*/
|
||||
@@ -140,13 +189,17 @@ export function parseDashboardPath(pathname: string): DashboardRoute {
|
||||
if (first === "account") {
|
||||
return parseAccountPath(meaningful.slice(1));
|
||||
}
|
||||
if (first === undefined || platformPages.has(first)) {
|
||||
if (first === undefined || platformPages.has(first) || hostNamespaces.has(first)) {
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
if (meaningful.length === 1) {
|
||||
return { kind: "module", moduleId: first };
|
||||
const rest = meaningful.slice(1);
|
||||
// `/signage//` is not `/signage/`: only one trailing slash is tolerated, and an empty segment
|
||||
// anywhere beneath a module is not a route.
|
||||
if (rest.some((segment) => segment === "")) {
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
return { kind: "not-found" };
|
||||
const path = parseModuleRoute(rest.join("/"));
|
||||
return path === undefined ? { kind: "not-found" } : { kind: "module", moduleId: first, path };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,7 +253,9 @@ export function dashboardPath(route: DashboardDestination): string {
|
||||
case "overview":
|
||||
return "/";
|
||||
case "module":
|
||||
return `/${encodeURIComponent(route.moduleId)}`;
|
||||
return route.path === ""
|
||||
? `/${encodeURIComponent(route.moduleId)}`
|
||||
: `/${encodeURIComponent(route.moduleId)}/${route.path}`;
|
||||
case "settings":
|
||||
return "/settings";
|
||||
case "settings-section":
|
||||
@@ -215,3 +270,30 @@ export function dashboardPath(route: DashboardDestination): string {
|
||||
return `/account/${route.section}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one carrier for "where to come back to" (ADR 0063): a `return` query parameter on the page
|
||||
* that was opened, holding the Dashboard address of the page that opened it —
|
||||
* `/settings/signage?return=%2Fsignage%2Ftimelines`. A query rather than history state, so it
|
||||
* survives a refresh and a link opened in a new tab; it is only ever read through
|
||||
* `parseReturnTarget`, so nothing but a real same-origin Dashboard page is ever used from it.
|
||||
*/
|
||||
export const RETURN_TARGET_PARAMETER = "return";
|
||||
|
||||
/** `destination`'s address, carrying `returnTo` as the place to come back to. */
|
||||
export function dashboardPathReturningTo(
|
||||
destination: DashboardDestination,
|
||||
returnTo: DashboardDestination,
|
||||
): string {
|
||||
const query = new URLSearchParams({ [RETURN_TARGET_PARAMETER]: dashboardPath(returnTo) });
|
||||
return `${dashboardPath(destination)}?${query.toString()}`;
|
||||
}
|
||||
|
||||
/** The raw, unvalidated return target in a query string, for `parseReturnTarget` to judge. */
|
||||
export function returnTargetFromSearch(search: string): string | undefined {
|
||||
try {
|
||||
return new URLSearchParams(search).get(RETURN_TARGET_PARAMETER) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
} from "@ekklesia/design-tokens";
|
||||
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
|
||||
import { SETTINGS_SECTIONS, type SettingsSection } from "./dashboard-routes.js";
|
||||
import {
|
||||
type DashboardModulePage,
|
||||
isDashboardModulePage,
|
||||
isModuleIconName,
|
||||
} from "./module-navigation.js";
|
||||
|
||||
/**
|
||||
* How this shell reaches the Ekklesia API.
|
||||
@@ -640,6 +645,11 @@ export function moduleDisplayDescriptorPath(moduleId: string): string {
|
||||
export interface DashboardNavigationEntry {
|
||||
readonly name: string;
|
||||
readonly descriptor: HostModuleUiDescriptor;
|
||||
/** The Lucide name the module declared (manifest v9, ADR 0063), or `undefined` for the generic
|
||||
* module icon. */
|
||||
readonly icon: string | undefined;
|
||||
/** Its primary pages, in its declared order; empty for a single-page module. */
|
||||
readonly pages: readonly DashboardModulePage[];
|
||||
}
|
||||
|
||||
export interface DashboardNavigationGroup {
|
||||
@@ -662,12 +672,48 @@ export interface DashboardNavigation {
|
||||
readonly ungrouped: readonly DashboardNavigationEntry[];
|
||||
}
|
||||
|
||||
function parseNavigationEntry(entry: unknown): DashboardNavigationEntry {
|
||||
const name = nestedString(entry, "name");
|
||||
if (name === undefined || !isRecord(entry)) {
|
||||
/** Strict: the module's declared icon (`null` for none) and pages, each in the manifest's grammar,
|
||||
* with no repeated page id or path. */
|
||||
function parseModuleNavigation(value: unknown): {
|
||||
readonly icon: string | undefined;
|
||||
readonly pages: readonly DashboardModulePage[];
|
||||
} {
|
||||
if (
|
||||
!hasExactKeys(value, ["icon", "pages"]) ||
|
||||
(value.icon !== null && !isModuleIconName(value.icon)) ||
|
||||
!Array.isArray(value.pages) ||
|
||||
value.pages.length > 12 ||
|
||||
!value.pages.every(isDashboardModulePage)
|
||||
) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
return Object.freeze({ descriptor: parseDescriptor(entry.module), name });
|
||||
const pages = value.pages as readonly DashboardModulePage[];
|
||||
if (
|
||||
new Set(pages.map((page) => page.id)).size !== pages.length ||
|
||||
new Set(pages.map((page) => page.path)).size !== pages.length
|
||||
) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
return Object.freeze({
|
||||
icon: value.icon === null ? undefined : (value.icon as string),
|
||||
pages: Object.freeze(
|
||||
pages.map((page) => Object.freeze({ id: page.id, label: page.label, path: page.path })),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function parseNavigationEntry(entry: unknown): DashboardNavigationEntry {
|
||||
const name = nestedString(entry, "name");
|
||||
if (name === undefined || !hasExactKeys(entry, ["module", "name", "navigation"])) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
const navigation = parseModuleNavigation(entry.navigation);
|
||||
return Object.freeze({
|
||||
descriptor: parseDescriptor(entry.module),
|
||||
icon: navigation.icon,
|
||||
name,
|
||||
pages: navigation.pages,
|
||||
});
|
||||
}
|
||||
|
||||
/** Strict, total validation, exactly like the inventory parser: a malformed group or entry fails
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type ModuleUiApiClient,
|
||||
type ModuleUiContributions,
|
||||
type ModuleUiHostContext,
|
||||
type ModuleUiNavigation,
|
||||
type ModuleUiPrincipal,
|
||||
type ModuleUiRealtimeClient,
|
||||
type ModuleUiRealtimeError,
|
||||
@@ -272,6 +273,9 @@ export interface CreateModuleUiContextOptions {
|
||||
/** Test-only seam, forwarded to the generic contribution loader so a test can drive a contributed
|
||||
* renderer without a real network. Production composition leaves it unset. */
|
||||
readonly importModule?: (url: string) => Promise<unknown>;
|
||||
/** The module's navigation capability (ADR 0063), from a host whose pages are addressable — the
|
||||
* Dashboard. Omitted, the context carries `navigation: undefined`. */
|
||||
readonly navigation?: ModuleUiNavigation;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,6 +360,9 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo
|
||||
: Object.freeze({ url: resolveApiUrl(options.config, options.descriptor.displayPageUrl) }),
|
||||
moduleId: options.descriptor.id,
|
||||
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
|
||||
// Only a host with addressable module pages supplies navigation (ADR 0063); the display host
|
||||
// shows one screen and supplies none.
|
||||
navigation: options.navigation,
|
||||
principal: options.principal,
|
||||
realtime: createModuleRealtimeClient(options.config, options.descriptor),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* The Lucide icons the Dashboard can draw for a module (manifest v9, ADR 0063), by Lucide name.
|
||||
*
|
||||
* The same curated set `@ekklesia/contracts` validates manifests against (`MODULE_NAVIGATION_ICONS`).
|
||||
* A surface may not import that package — its type graph is the server's (ADR 0024) — so the list is
|
||||
* repeated here, and `apps/api` tests hold the two equal: the platform cannot accept an icon the
|
||||
* Dashboard cannot draw. The browser's map from these names to Lucide components is
|
||||
* `browser/module-icon.tsx`, which imports exactly these icons and no others.
|
||||
*
|
||||
* Framework-free and exported on its own subpath, so the server's parity test reads the list without
|
||||
* importing React.
|
||||
*/
|
||||
export const DASHBOARD_MODULE_ICON_NAMES = Object.freeze([
|
||||
"activity",
|
||||
"archive",
|
||||
"baby",
|
||||
"bell",
|
||||
"book-open",
|
||||
"book-open-text",
|
||||
"bookmark",
|
||||
"briefcase",
|
||||
"building",
|
||||
"bus",
|
||||
"calendar",
|
||||
"calendar-check",
|
||||
"calendar-days",
|
||||
"camera",
|
||||
"car",
|
||||
"chart-column",
|
||||
"chart-pie",
|
||||
"church",
|
||||
"clipboard-check",
|
||||
"clipboard-list",
|
||||
"clock",
|
||||
"coffee",
|
||||
"contact",
|
||||
"credit-card",
|
||||
"database",
|
||||
"door-open",
|
||||
"file-text",
|
||||
"files",
|
||||
"film",
|
||||
"flag",
|
||||
"folder",
|
||||
"gift",
|
||||
"globe",
|
||||
"graduation-cap",
|
||||
"hand-coins",
|
||||
"hand-heart",
|
||||
"handshake",
|
||||
"headphones",
|
||||
"heart",
|
||||
"heart-handshake",
|
||||
"house",
|
||||
"image",
|
||||
"images",
|
||||
"inbox",
|
||||
"key-round",
|
||||
"landmark",
|
||||
"layout-grid",
|
||||
"library",
|
||||
"list-checks",
|
||||
"list-todo",
|
||||
"mail",
|
||||
"map",
|
||||
"map-pin",
|
||||
"megaphone",
|
||||
"message-circle",
|
||||
"message-square",
|
||||
"mic",
|
||||
"monitor",
|
||||
"monitor-play",
|
||||
"music",
|
||||
"newspaper",
|
||||
"notebook",
|
||||
"package",
|
||||
"palette",
|
||||
"party-popper",
|
||||
"phone",
|
||||
"presentation",
|
||||
"printer",
|
||||
"projector",
|
||||
"radio",
|
||||
"receipt",
|
||||
"school",
|
||||
"send",
|
||||
"shield",
|
||||
"shopping-bag",
|
||||
"sparkles",
|
||||
"star",
|
||||
"sun",
|
||||
"tablet-smartphone",
|
||||
"tag",
|
||||
"tent",
|
||||
"ticket",
|
||||
"trophy",
|
||||
"truck",
|
||||
"tv",
|
||||
"user-check",
|
||||
"user-round",
|
||||
"users",
|
||||
"users-round",
|
||||
"utensils",
|
||||
"video",
|
||||
"wallet",
|
||||
"wrench",
|
||||
] as const);
|
||||
|
||||
export type DashboardModuleIconName = (typeof DASHBOARD_MODULE_ICON_NAMES)[number];
|
||||
|
||||
const iconNames: ReadonlySet<string> = new Set(DASHBOARD_MODULE_ICON_NAMES);
|
||||
|
||||
export function isDashboardModuleIconName(value: unknown): value is DashboardModuleIconName {
|
||||
return typeof value === "string" && iconNames.has(value);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { type DashboardDestination, parseModuleRoute } from "./dashboard-routes.js";
|
||||
|
||||
/**
|
||||
* Resolving a module's own routes (ADR 0063), free of React and browser globals.
|
||||
*
|
||||
* A module declares its primary pages; the shell opens:
|
||||
*
|
||||
* - its **root**, `/<module-id>`, which is its landing view and never itself a declared page, so no
|
||||
* page has two addresses;
|
||||
* - each **declared page**, `/<module-id>/<page-path>`;
|
||||
* - any route **beneath** a declared page, `/<module-id>/<page-path>/…`, which the module owns —
|
||||
* detail views (`media/sermons/123`) need no manifest entry and no sidebar entry.
|
||||
*
|
||||
* Everything else under a module is the host's `not-found`, and the module is not mounted for it:
|
||||
* an undeclared path never silently opens some other page. A module that declares no pages — every
|
||||
* manifest v8 module — has its root and nothing else, exactly as before.
|
||||
*/
|
||||
|
||||
export interface DashboardModulePage {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
/** Relative to the module root: `timelines`, `media/library`. */
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
/** Where a route lands inside its module: the declared page it is or lies beneath, `undefined` at the
|
||||
* root. `undefined` as a whole means the module has no such route. */
|
||||
export interface ModuleRouteResolution {
|
||||
readonly page: DashboardModulePage | undefined;
|
||||
}
|
||||
|
||||
export function resolveModuleRoute(
|
||||
pages: readonly DashboardModulePage[],
|
||||
path: string,
|
||||
): ModuleRouteResolution | undefined {
|
||||
if (path === "") {
|
||||
return { page: undefined };
|
||||
}
|
||||
const page = pages.find(
|
||||
(candidate) => path === candidate.path || path.startsWith(`${candidate.path}/`),
|
||||
);
|
||||
return page === undefined ? undefined : { page };
|
||||
}
|
||||
|
||||
/** `path` as one of this module's routes the shell would open, or `undefined`. Accepts the same
|
||||
* spellings `parseModuleRoute` does: an optional leading `/`, unreserved segments, no traversal. */
|
||||
export function moduleRouteFor(
|
||||
pages: readonly DashboardModulePage[],
|
||||
path: unknown,
|
||||
): { readonly path: string; readonly resolution: ModuleRouteResolution } | "invalid" | "unknown" {
|
||||
const route = parseModuleRoute(path);
|
||||
if (route === undefined) {
|
||||
return "invalid";
|
||||
}
|
||||
const resolution = resolveModuleRoute(pages, route);
|
||||
return resolution === undefined ? "unknown" : { path: route, resolution };
|
||||
}
|
||||
|
||||
export function moduleDestination(moduleId: string, path = ""): DashboardDestination {
|
||||
return { kind: "module", moduleId, path };
|
||||
}
|
||||
|
||||
const PAGE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||
const PAGE_PATH_SEGMENT_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
||||
const ICON_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is refused.
|
||||
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u;
|
||||
|
||||
/** The manifest's page grammar (`@ekklesia/contracts`), re-checked on what the server sends. */
|
||||
export function isDashboardModulePage(value: unknown): value is DashboardModulePage {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
if (keys.length !== 3 || keys[0] !== "id" || keys[1] !== "label" || keys[2] !== "path") {
|
||||
return false;
|
||||
}
|
||||
const { id, label, path } = record;
|
||||
return (
|
||||
typeof id === "string" &&
|
||||
id.length <= 64 &&
|
||||
PAGE_ID_PATTERN.test(id) &&
|
||||
typeof label === "string" &&
|
||||
label.trim().length > 0 &&
|
||||
label.length <= 60 &&
|
||||
!CONTROL_CHARACTER_PATTERN.test(label) &&
|
||||
typeof path === "string" &&
|
||||
path.length <= 120 &&
|
||||
path.split("/").length <= 4 &&
|
||||
path.split("/").every((segment) => PAGE_PATH_SEGMENT_PATTERN.test(segment))
|
||||
);
|
||||
}
|
||||
|
||||
/** A syntactically valid Lucide name. Whether the Dashboard can draw it is the icon map's question:
|
||||
* a name it does not know is drawn as the generic module icon, never refused. */
|
||||
export function isModuleIconName(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length <= 64 && ICON_NAME_PATTERN.test(value);
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACCOUNT_SECTIONS,
|
||||
DASHBOARD_PLATFORM_PAGES,
|
||||
dashboardPathReturningTo,
|
||||
HOST_NAMESPACES,
|
||||
parseModuleRoute,
|
||||
returnTargetFromSearch,
|
||||
dashboardPath,
|
||||
parseDashboardPath,
|
||||
DISCOVER_VIEWS,
|
||||
@@ -27,13 +31,25 @@ describe("parseDashboardPath", () => {
|
||||
});
|
||||
|
||||
it("names a module page by its id at the top level, tolerating one trailing slash", () => {
|
||||
expect(parseDashboardPath("/signage")).toEqual({ kind: "module", moduleId: "signage" });
|
||||
expect(parseDashboardPath("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
|
||||
expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people" });
|
||||
expect(parseDashboardPath("/signage")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "",
|
||||
});
|
||||
expect(parseDashboardPath("/signage/")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "",
|
||||
});
|
||||
expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people", path: "" });
|
||||
});
|
||||
|
||||
it("decodes a percent-encoded segment, so an id is compared as the server spells it", () => {
|
||||
expect(parseDashboardPath("/sign%61ge")).toEqual({ kind: "module", moduleId: "signage" });
|
||||
expect(parseDashboardPath("/sign%61ge")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("never reads a platform page as a module page", () => {
|
||||
@@ -65,7 +81,9 @@ describe("parseDashboardPath", () => {
|
||||
"/accounts",
|
||||
]) {
|
||||
expect(parseDashboardPath(pathname), pathname).toEqual(
|
||||
pathname === "/accounts" ? { kind: "module", moduleId: "accounts" } : { kind: "not-found" },
|
||||
pathname === "/accounts"
|
||||
? { kind: "module", moduleId: "accounts", path: "" }
|
||||
: { kind: "not-found" },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -76,10 +94,13 @@ describe("parseDashboardPath", () => {
|
||||
"//",
|
||||
"//signage",
|
||||
"/signage//",
|
||||
// A module's own sub-pages need a contract that does not exist yet (ADR 0057).
|
||||
"/signage/timelines",
|
||||
"/signage/timelines/abc",
|
||||
"/signage/a//b",
|
||||
"/%E0%A4%A",
|
||||
// The host's non-Dashboard namespaces are never pages, however deep (ADR 0063).
|
||||
"/api",
|
||||
"/api/v1/principal",
|
||||
"/assets/ekklesia-dashboard/app.js",
|
||||
"/display/signage",
|
||||
// Deeper Settings pages are reserved for later (ADR 0061).
|
||||
"/settings/displays/add",
|
||||
"/settings/signage/timelines",
|
||||
@@ -166,27 +187,112 @@ describe("Settings routes (ADR 0061)", () => {
|
||||
describe("dashboardPath", () => {
|
||||
it("spells each route canonically, with no implementation prefix", () => {
|
||||
expect(dashboardPath({ kind: "overview" })).toBe("/");
|
||||
expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/signage");
|
||||
expect(dashboardPath({ kind: "module", moduleId: "signage", path: "" })).toBe("/signage");
|
||||
});
|
||||
|
||||
it("encodes a module id so it can never escape its own segment", () => {
|
||||
expect(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/a%2F..%2Fb");
|
||||
expect(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({
|
||||
expect(dashboardPath({ kind: "module", moduleId: "a/../b", path: "" })).toBe("/a%2F..%2Fb");
|
||||
expect(
|
||||
parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b", path: "" })),
|
||||
).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "a/../b",
|
||||
path: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips through the parser", () => {
|
||||
for (const route of [
|
||||
{ kind: "overview" } as const,
|
||||
{ kind: "module", moduleId: "example" } as const,
|
||||
{ kind: "module", moduleId: "example", path: "" } as const,
|
||||
{ kind: "module", moduleId: "example", path: "records" } as const,
|
||||
{ kind: "module", moduleId: "media", path: "sermons/2026-10-11_service~1.mp4" } as const,
|
||||
]) {
|
||||
expect(parseDashboardPath(dashboardPath(route))).toEqual(route);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("module routes (ADR 0063)", () => {
|
||||
it("reads everything beneath a module's root as that module's own route", () => {
|
||||
expect(parseDashboardPath("/signage/timelines")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "timelines",
|
||||
});
|
||||
expect(parseDashboardPath("/signage/timelines/")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "timelines",
|
||||
});
|
||||
expect(parseDashboardPath("/media/sermons/123")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "media",
|
||||
path: "sermons/123",
|
||||
});
|
||||
});
|
||||
|
||||
it("spells a module route canonically, its path beneath the encoded module id", () => {
|
||||
expect(dashboardPath({ kind: "module", moduleId: "signage", path: "timelines" })).toBe(
|
||||
"/signage/timelines",
|
||||
);
|
||||
expect(dashboardPath({ kind: "module", moduleId: "signage", path: "" })).toBe("/signage");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", ""],
|
||||
["/", ""],
|
||||
["timelines", "timelines"],
|
||||
["/timelines", "timelines"],
|
||||
["media/sermons/123", "media/sermons/123"],
|
||||
["a.b_c~d-e", "a.b_c~d-e"],
|
||||
])("accepts the module route %j as %j", (input, expected) => {
|
||||
expect(parseModuleRoute(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"../calendar",
|
||||
"media/../../settings",
|
||||
"./timelines",
|
||||
"media/.",
|
||||
"//evil.example.test",
|
||||
"https://evil.example.test",
|
||||
"javascript:alert(1)",
|
||||
"timelines?x=1",
|
||||
"timelines#top",
|
||||
"media%2F..%2Fapi",
|
||||
"media\\library",
|
||||
"media//library",
|
||||
"timelines/",
|
||||
"time lines",
|
||||
"a/b/c/d/e/f/g/h/i",
|
||||
`${"a".repeat(513)}`,
|
||||
])("refuses the module route %j", (input) => {
|
||||
expect(parseModuleRoute(input)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses anything that is not a string", () => {
|
||||
for (const value of [undefined, null, 1, ["timelines"], { path: "timelines" }]) {
|
||||
expect(parseModuleRoute(value)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("names exactly the host's non-Dashboard namespaces", () => {
|
||||
expect(HOST_NAMESPACES).toEqual(["api", "assets", "display"]);
|
||||
});
|
||||
|
||||
it("carries a return target on its own query parameter, readable only as a raw value", () => {
|
||||
const href = dashboardPathReturningTo(
|
||||
{ kind: "module-settings", moduleId: "signage" },
|
||||
{ kind: "module", moduleId: "signage", path: "timelines" },
|
||||
);
|
||||
expect(href).toBe("/settings/signage?return=%2Fsignage%2Ftimelines");
|
||||
expect(returnTargetFromSearch("?return=%2Fsignage%2Ftimelines")).toBe("/signage/timelines");
|
||||
expect(returnTargetFromSearch("")).toBeUndefined();
|
||||
expect(returnTargetFromSearch("?other=1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the route locator is published for the API that serves the page", () => {
|
||||
it("is a narrow package subpath, not the shell root barrel", async () => {
|
||||
const manifest = JSON.parse(
|
||||
|
||||
@@ -251,10 +251,13 @@ describe("display host — full-viewport boundary", () => {
|
||||
"contributions",
|
||||
"display",
|
||||
"moduleId",
|
||||
"navigation",
|
||||
"permissions",
|
||||
"principal",
|
||||
"realtime",
|
||||
]);
|
||||
// A display is one screen with no pages: the display host offers no navigation (ADR 0063).
|
||||
expect(seenContext?.navigation).toBeUndefined();
|
||||
expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
||||
expect(isolatedText(query("display-module-container"))).toBe("permitted");
|
||||
expect(
|
||||
@@ -352,6 +355,7 @@ describe("display host — full-viewport boundary", () => {
|
||||
version: "1.2.0",
|
||||
},
|
||||
name: "Announcements",
|
||||
navigation: { icon: null, pages: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ function entry(id: string, name: string, version = "0.1.0", permissions: readonl
|
||||
version,
|
||||
},
|
||||
name,
|
||||
navigation: { icon: null, pages: [] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("the browser host history", () => {
|
||||
const view = {
|
||||
addEventListener: vi.fn(),
|
||||
history: { pushState: vi.fn(), replaceState: vi.fn() },
|
||||
location: { pathname: "/" },
|
||||
location: { pathname: "/", search: "" },
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
const history = createBrowserHostHistory(view);
|
||||
@@ -87,7 +87,7 @@ describe("the browser host history", () => {
|
||||
const view = {
|
||||
addEventListener: vi.fn(),
|
||||
history: { pushState: vi.fn(), replaceState: vi.fn() },
|
||||
location: { pathname: "/" },
|
||||
location: { pathname: "/", search: "" },
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
const history = createBrowserHostHistory(view);
|
||||
@@ -100,6 +100,37 @@ describe("the browser host history", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the query, which carries only where to return to (ADR 0063)", () => {
|
||||
it("reads the browser's query, and pushes an address that carries one", () => {
|
||||
const view = {
|
||||
addEventListener: vi.fn(),
|
||||
history: { pushState: vi.fn(), replaceState: vi.fn() },
|
||||
location: { pathname: "/settings/signage", search: "?return=%2Fsignage" },
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
const history = createBrowserHostHistory(view);
|
||||
|
||||
expect(history.search()).toBe("?return=%2Fsignage");
|
||||
// The address already showing, query included, adds no entry; the same path with another query
|
||||
// is another address.
|
||||
history.push("/settings/signage?return=%2Fsignage");
|
||||
expect(view.history.pushState).not.toHaveBeenCalled();
|
||||
history.push("/settings/signage");
|
||||
expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/settings/signage");
|
||||
});
|
||||
|
||||
it("keeps the query beside the path in memory, as the browser does", () => {
|
||||
const history = createMemoryHostHistory("/settings/signage?return=%2Fsignage%2Fmedia");
|
||||
|
||||
expect(history.pathname()).toBe("/settings/signage");
|
||||
expect(history.search()).toBe("?return=%2Fsignage%2Fmedia");
|
||||
history.push("/signage/media");
|
||||
expect(history.search()).toBe("");
|
||||
history.go(-1);
|
||||
expect(history.search()).toBe("?return=%2Fsignage%2Fmedia");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the in-memory host history", () => {
|
||||
it("behaves like the browser's: push truncates forward entries, go steps within bounds", () => {
|
||||
const history = createMemoryHostHistory("/");
|
||||
|
||||
@@ -78,7 +78,13 @@ const navigationPayload = {
|
||||
{
|
||||
id: "media",
|
||||
label: "Media",
|
||||
modules: [{ module: inventoryPayload.modules[0], name: "Example Module" }],
|
||||
modules: [
|
||||
{
|
||||
module: inventoryPayload.modules[0],
|
||||
name: "Example Module",
|
||||
navigation: { icon: null, pages: [] },
|
||||
},
|
||||
],
|
||||
order: 10,
|
||||
},
|
||||
],
|
||||
@@ -95,6 +101,7 @@ const navigationPayload = {
|
||||
version: "2.0.0",
|
||||
},
|
||||
name: "Calendar",
|
||||
navigation: { icon: null, pages: [] },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -111,7 +118,10 @@ const exampleDescriptor: HostModuleUiDescriptor = {
|
||||
};
|
||||
|
||||
function navigationWithModule(module: unknown): unknown {
|
||||
return { groups: [], ungrouped: [{ module, name: "Example Module" }] };
|
||||
return {
|
||||
groups: [],
|
||||
ungrouped: [{ module, name: "Example Module", navigation: { icon: null, pages: [] } }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("host protocol — API origin (ADR 0045)", () => {
|
||||
@@ -257,9 +267,11 @@ describe("host protocol — Dashboard navigation", () => {
|
||||
|
||||
expect(navigation.groups.map((group) => [group.id, group.label])).toEqual([["media", "Media"]]);
|
||||
expect(navigation.groups[0]?.modules).toEqual([
|
||||
{ descriptor: exampleDescriptor, name: "Example Module" },
|
||||
{ descriptor: exampleDescriptor, icon: undefined, name: "Example Module", pages: [] },
|
||||
]);
|
||||
expect(navigation.ungrouped).toEqual([
|
||||
{ descriptor: calendarDescriptor, icon: undefined, name: "Calendar", pages: [] },
|
||||
]);
|
||||
expect(navigation.ungrouped).toEqual([{ descriptor: calendarDescriptor, name: "Calendar" }]);
|
||||
});
|
||||
|
||||
it("rejects a malformed navigation document rather than rendering half of it", () => {
|
||||
@@ -281,6 +293,7 @@ describe("host protocol — Dashboard navigation", () => {
|
||||
ui: { url: "https://evil.example.test/x.js" },
|
||||
},
|
||||
name: "Example",
|
||||
navigation: { icon: null, pages: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -556,7 +569,7 @@ describe("module UI host context", () => {
|
||||
expect(context.display?.url).toBe("https://api.example.test/display/example");
|
||||
});
|
||||
|
||||
it("exposes exactly the seven contract members and the browser principal", () => {
|
||||
it("exposes exactly the eight contract members and the browser principal", () => {
|
||||
const { implementation } = recordingFetch();
|
||||
|
||||
const context = contextFor(implementation);
|
||||
@@ -566,11 +579,14 @@ describe("module UI host context", () => {
|
||||
"contributions",
|
||||
"display",
|
||||
"moduleId",
|
||||
"navigation",
|
||||
"permissions",
|
||||
"principal",
|
||||
"realtime",
|
||||
]);
|
||||
expect(context.moduleId).toBe("example");
|
||||
// Navigation comes only from a host with addressable pages (ADR 0063); this one supplies none.
|
||||
expect(context.navigation).toBeUndefined();
|
||||
expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
||||
});
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ function entry(id: string, name: string, version = "0.1.0") {
|
||||
version,
|
||||
},
|
||||
name,
|
||||
navigation: { icon: null, pages: [] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,15 @@ describe("the shell builds no module knowledge into its bundle", () => {
|
||||
// mean the shell had an opinion about an arrangement it must only render.
|
||||
// Deliberately distinctive terms: a generic word like "media" appears inside React's own
|
||||
// bundled source and would make this assertion meaningless rather than strict.
|
||||
for (const term of ["planning", "sunday-production", "livestream", "calendar", "ungrouped-"]) {
|
||||
// "calendar" is no longer one of them: it is a generic Lucide icon a module may declare
|
||||
// (ADR 0063), so the bundle names it on purpose.
|
||||
for (const term of [
|
||||
"planning",
|
||||
"sunday-production",
|
||||
"livestream",
|
||||
"youth-ministry",
|
||||
"ungrouped-",
|
||||
]) {
|
||||
expect(bundle.toLowerCase(), term).not.toContain(term);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
import {
|
||||
MODULE_UI_ABI_VERSION,
|
||||
type ModuleUiHostContext,
|
||||
type ModuleUiNavigation,
|
||||
ModuleUiNavigationError,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import * as lucide from "lucide-react";
|
||||
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 { MODULE_ICON_COMPONENTS } from "../src/browser/module-icon.js";
|
||||
import { createMemorySidebarPreference } from "../src/browser/sidebar-preference.js";
|
||||
import type { DashboardSettings } from "../src/host-protocol.js";
|
||||
import { DASHBOARD_MODULE_ICON_NAMES } from "../src/module-icons.js";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module navigation (manifest v9, ADR 0063): a module's own icon and primary pages in the global
|
||||
* sidebar, its real nested routes, the navigation capability a mounted module is handed through the
|
||||
* real mounting boundary, and module → Settings → back to exactly where it was — beside a
|
||||
* manifest v8 module that keeps behaving as it always has.
|
||||
*/
|
||||
|
||||
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,
|
||||
navigation: { icon: string | null; pages: { id: string; label: string; path: string }[] } = {
|
||||
icon: null,
|
||||
pages: [],
|
||||
},
|
||||
) {
|
||||
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,
|
||||
navigation,
|
||||
};
|
||||
}
|
||||
|
||||
const SIGNAGE_PAGES = [
|
||||
{ id: "playback", label: "Playback", path: "playback" },
|
||||
{ id: "timelines", label: "Timelines", path: "timelines" },
|
||||
{ id: "media", label: "Media", path: "media" },
|
||||
];
|
||||
|
||||
/** A manifest v9 module with an icon and three pages, and a manifest v8 module with neither. */
|
||||
const NAVIGATION = {
|
||||
groups: [],
|
||||
ungrouped: [
|
||||
entry("signage", "Signage", { icon: "monitor-play", pages: SIGNAGE_PAGES }),
|
||||
entry("calendar", "Calendar"),
|
||||
],
|
||||
};
|
||||
|
||||
/** Signage declares configuration and the viewer may change it; Calendar has no settings; Archive
|
||||
* is installed but turned off, so it is in Settings but not in the navigation. */
|
||||
const SETTINGS: DashboardSettings = {
|
||||
modules: [
|
||||
{ available: false, id: "archive", name: "Archive" },
|
||||
{ available: true, id: "signage", name: "Signage" },
|
||||
],
|
||||
sections: [],
|
||||
};
|
||||
|
||||
interface Mounts {
|
||||
imported: string[];
|
||||
mounted: string[];
|
||||
unmounted: string[];
|
||||
contexts: Map<string, ModuleUiHostContext>;
|
||||
}
|
||||
|
||||
async function render(
|
||||
path: string,
|
||||
options: { readonly history?: MemoryHostHistory; readonly compact?: boolean } = {},
|
||||
) {
|
||||
const history = options.history ?? createMemoryHostHistory(path);
|
||||
const titles: string[] = [];
|
||||
const mounts: Mounts = { contexts: new Map(), imported: [], mounted: [], unmounted: [] };
|
||||
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
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")) {
|
||||
return Response.json(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 (url: string) => {
|
||||
const moduleId = /module-ui\/([a-z-]+)\//u.exec(url)?.[1] ?? "unknown";
|
||||
mounts.imported.push(moduleId);
|
||||
return {
|
||||
default: {
|
||||
abi: MODULE_UI_ABI_VERSION,
|
||||
id: moduleId,
|
||||
mount(element: Element, context: ModuleUiHostContext) {
|
||||
mounts.contexts.set(moduleId, context);
|
||||
const show = (): void => {
|
||||
element.textContent = `${moduleId}:${context.navigation?.location().path ?? "-"}`;
|
||||
};
|
||||
show();
|
||||
const unsubscribe = context.navigation?.subscribe(show);
|
||||
mounts.mounted.push(moduleId);
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
mounts.unmounted.push(moduleId);
|
||||
};
|
||||
},
|
||||
version: "0.1.0",
|
||||
},
|
||||
};
|
||||
}}
|
||||
setDocumentTitle={(title) => titles.push(title)}
|
||||
sidebarPreference={createMemorySidebarPreference()}
|
||||
viewport={createMemoryHostViewport(options.compact ?? false)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { history, mounts, 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, init: MouseEventInit = {}): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** What the mounted module rendered, read through its isolated (Shadow DOM) root. */
|
||||
function moduleText(): string {
|
||||
const host = container.querySelector("[data-ekklesia-module-ui]");
|
||||
return host?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? "";
|
||||
}
|
||||
|
||||
const current = () =>
|
||||
[...container.querySelectorAll('nav [aria-current="page"]')].map(
|
||||
(element) => (element as HTMLElement).dataset.testid,
|
||||
);
|
||||
|
||||
function navigationOf(mounts: Mounts, moduleId = "signage"): ModuleUiNavigation {
|
||||
const navigation = mounts.contexts.get(moduleId)?.navigation;
|
||||
if (!navigation) {
|
||||
throw new Error(`no navigation for ${moduleId}`);
|
||||
}
|
||||
return navigation;
|
||||
}
|
||||
|
||||
describe("a manifest v9 module's routes", () => {
|
||||
it("opens the module root as its landing view, with its pages beneath it in the sidebar", async () => {
|
||||
const { mounts, titles } = await render("/signage");
|
||||
|
||||
expect(mounts.mounted).toEqual(["signage"]);
|
||||
expect(moduleText()).toBe("signage:");
|
||||
expect(titles.at(-1)).toBe("Signage");
|
||||
expect(current()).toEqual(["select-signage"]);
|
||||
const pages = [...required("module-subnavigation-signage").querySelectorAll("a")];
|
||||
expect(pages.map((link) => link.textContent)).toEqual(["Playback", "Timelines", "Media"]);
|
||||
expect(pages.map((link) => link.getAttribute("href"))).toEqual([
|
||||
"/signage/playback",
|
||||
"/signage/timelines",
|
||||
"/signage/media",
|
||||
]);
|
||||
expect(required("module-subnavigation-signage").getAttribute("aria-label")).toBe("Signage");
|
||||
});
|
||||
|
||||
it("opens a declared page directly from its deep link, as a refresh or a bookmark does", async () => {
|
||||
const { mounts, titles } = await render("/signage/timelines");
|
||||
|
||||
expect(mounts.mounted).toEqual(["signage"]);
|
||||
expect(navigationOf(mounts).location()).toEqual({ page: "timelines", path: "timelines" });
|
||||
expect(moduleText()).toBe("signage:timelines");
|
||||
expect(current()).toEqual(["select-signage-timelines"]);
|
||||
expect(titles.at(-1)).toBe("Timelines · Signage");
|
||||
});
|
||||
|
||||
it("opens a route beneath a declared page — a detail view — marking its page", async () => {
|
||||
const { mounts } = await render("/signage/media/sermons/2026-10-11");
|
||||
|
||||
expect(navigationOf(mounts).location()).toEqual({
|
||||
page: "media",
|
||||
path: "media/sermons/2026-10-11",
|
||||
});
|
||||
expect(current()).toEqual(["select-signage-media"]);
|
||||
});
|
||||
|
||||
it("is not found at a path no declared page owns, and never mounts the module for it", async () => {
|
||||
for (const path of ["/signage/nope", "/signage/timeline", "/signage/Timelines"]) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
root = createRoot(container);
|
||||
const { mounts, titles } = await render(path);
|
||||
|
||||
expect(query("host-not-found"), path).not.toBeNull();
|
||||
expect(mounts.imported, path).toEqual([]);
|
||||
expect(titles.at(-1), path).toBe("Page not found");
|
||||
expect(current(), path).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("moves between its own pages without remounting, and Back and Forward walk them", async () => {
|
||||
const { history, mounts } = await render("/signage");
|
||||
|
||||
await click(required("select-signage-timelines"));
|
||||
expect(history.entries).toEqual(["/signage", "/signage/timelines"]);
|
||||
expect(moduleText()).toBe("signage:timelines");
|
||||
await click(required("select-signage-media"));
|
||||
|
||||
await act(async () => history.go(-1));
|
||||
expect(moduleText()).toBe("signage:timelines");
|
||||
expect(current()).toEqual(["select-signage-timelines"]);
|
||||
await act(async () => history.go(-1));
|
||||
expect(moduleText()).toBe("signage:");
|
||||
await act(async () => history.go(2));
|
||||
expect(moduleText()).toBe("signage:media");
|
||||
|
||||
// One import and one mount for the whole walk: the module was never torn down.
|
||||
expect(mounts.imported).toEqual(["signage"]);
|
||||
expect(mounts.mounted).toEqual(["signage"]);
|
||||
expect(mounts.unmounted).toEqual([]);
|
||||
});
|
||||
|
||||
it("moves focus to the page when the module moves to another of its pages", async () => {
|
||||
await render("/signage");
|
||||
const main = required("host-main-region");
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
|
||||
await click(required("select-signage-playback"));
|
||||
|
||||
expect(document.activeElement).toBe(main);
|
||||
});
|
||||
|
||||
it("keeps the exact page across a refresh", async () => {
|
||||
const history = createMemoryHostHistory("/signage");
|
||||
await render("/signage", { history });
|
||||
await click(required("select-signage-media"));
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
root = createRoot(container);
|
||||
const { mounts } = await render("/signage/media");
|
||||
|
||||
expect(navigationOf(mounts).location().path).toBe("media");
|
||||
expect(current()).toEqual(["select-signage-media"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a released manifest v8 module", () => {
|
||||
it("is one page, its root, with the generic icon and no sub-navigation", async () => {
|
||||
const { mounts } = await render("/calendar");
|
||||
|
||||
expect(mounts.mounted).toEqual(["calendar"]);
|
||||
expect(moduleText()).toBe("calendar:");
|
||||
expect(current()).toEqual(["select-calendar"]);
|
||||
expect(query("module-subnavigation-calendar")).toBeNull();
|
||||
expect(required("select-calendar").querySelector("svg")?.dataset.icon).toBe("module");
|
||||
});
|
||||
|
||||
it("is not found beneath its root, exactly as before", async () => {
|
||||
const { mounts } = await render("/calendar/events/456");
|
||||
|
||||
expect(query("host-not-found")).not.toBeNull();
|
||||
expect(mounts.imported).toEqual([]);
|
||||
});
|
||||
|
||||
it("still receives navigation — its root, and no Settings link when it has no settings", async () => {
|
||||
const { mounts } = await render("/calendar");
|
||||
const navigation = navigationOf(mounts, "calendar");
|
||||
|
||||
expect(navigation.location()).toEqual({ page: undefined, path: "" });
|
||||
expect(navigation.settings).toBeUndefined();
|
||||
expect(() => navigation.navigate("events")).toThrowError(
|
||||
expect.objectContaining({ code: "MODULE_NAVIGATION_PATH_UNKNOWN" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modules that are not running", () => {
|
||||
it("says a turned-off module is unavailable at its root and beneath it, mounting nothing", async () => {
|
||||
for (const path of ["/archive", "/archive/timelines", "/archive/a/b"]) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
root = createRoot(container);
|
||||
const { mounts } = await render(path);
|
||||
|
||||
expect(query("host-module-unavailable"), path).not.toBeNull();
|
||||
expect(mounts.imported, path).toEqual([]);
|
||||
// A module that is not running is absent from the navigation.
|
||||
expect(query("select-archive"), path).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sidebar", () => {
|
||||
it("draws a module's declared Lucide icon, and the generic icon for one that declares none", async () => {
|
||||
await render("/");
|
||||
|
||||
expect(required("select-signage").querySelector("svg")?.dataset.icon).toBe(
|
||||
"module:monitor-play",
|
||||
);
|
||||
expect(required("select-signage").querySelector("svg")?.getAttribute("aria-hidden")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(required("select-calendar").querySelector("svg")?.dataset.icon).toBe("module");
|
||||
});
|
||||
|
||||
it("shows a module's pages only while that module is open", async () => {
|
||||
await render("/");
|
||||
expect(query("module-subnavigation-signage")).toBeNull();
|
||||
|
||||
await click(required("select-signage"));
|
||||
expect(query("module-subnavigation-signage")).not.toBeNull();
|
||||
|
||||
await click(required("select-calendar"));
|
||||
expect(query("module-subnavigation-signage")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapsed, hides the pages and marks the module itself, named for assistive technology", async () => {
|
||||
await render("/signage/timelines");
|
||||
await click(container.querySelector('[aria-label="Collapse navigation"]') as Element);
|
||||
|
||||
expect(query("module-subnavigation-signage")).toBeNull();
|
||||
expect(current()).toEqual(["select-signage"]);
|
||||
const label = required("select-signage").querySelector("[data-nav-label]");
|
||||
expect(label?.textContent).toBe("Signage");
|
||||
expect(label?.className).toBe("ek-shell-visually-hidden");
|
||||
});
|
||||
|
||||
it("uses the identical hierarchy in the phone drawer, which closes once a page is chosen", async () => {
|
||||
const { history } = await render("/signage", { compact: true });
|
||||
|
||||
await click(required("mobile-navigation-trigger"));
|
||||
const drawer = required("navigation-drawer");
|
||||
const pages = [
|
||||
...drawer.querySelectorAll('[data-testid="module-subnavigation-signage"] a'),
|
||||
] as HTMLElement[];
|
||||
expect(pages.map((link) => link.textContent)).toEqual(["Playback", "Timelines", "Media"]);
|
||||
for (const link of pages) {
|
||||
expect(link.style.minHeight).toBe("var(--ekklesia-control-height-lg)");
|
||||
}
|
||||
|
||||
await click(pages[1] as HTMLElement);
|
||||
expect(history.pathname()).toBe("/signage/timelines");
|
||||
expect(query("navigation-drawer")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the module navigation capability", () => {
|
||||
it("navigates to the module's own routes, adding or replacing a history entry", async () => {
|
||||
const { history, mounts } = await render("/signage");
|
||||
const navigation = navigationOf(mounts);
|
||||
|
||||
await act(async () => navigation.navigate("timelines"));
|
||||
expect(history.entries).toEqual(["/signage", "/signage/timelines"]);
|
||||
await act(async () => navigation.navigate("/media/sermons/9"));
|
||||
expect(history.pathname()).toBe("/signage/media/sermons/9");
|
||||
expect(navigation.location()).toEqual({ page: "media", path: "media/sermons/9" });
|
||||
await act(async () => navigation.navigate("media", { replace: true }));
|
||||
expect(history.entries).toEqual(["/signage", "/signage/timelines", "/signage/media"]);
|
||||
await act(async () => navigation.navigate(""));
|
||||
expect(navigation.location()).toEqual({ page: undefined, path: "" });
|
||||
expect(mounts.mounted).toEqual(["signage"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["../calendar", "MODULE_NAVIGATION_PATH_INVALID"],
|
||||
["timelines/../../calendar", "MODULE_NAVIGATION_PATH_INVALID"],
|
||||
["//evil.example.test", "MODULE_NAVIGATION_PATH_INVALID"],
|
||||
["https://evil.example.test/", "MODULE_NAVIGATION_PATH_INVALID"],
|
||||
["javascript:alert(1)", "MODULE_NAVIGATION_PATH_INVALID"],
|
||||
["timelines?x=1", "MODULE_NAVIGATION_PATH_INVALID"],
|
||||
["timelines#top", "MODULE_NAVIGATION_PATH_INVALID"],
|
||||
["nope", "MODULE_NAVIGATION_PATH_UNKNOWN"],
|
||||
["timeline", "MODULE_NAVIGATION_PATH_UNKNOWN"],
|
||||
])("refuses %j — no other module, platform page, or undeclared page", async (path, code) => {
|
||||
const { history, mounts } = await render("/signage");
|
||||
const navigation = navigationOf(mounts);
|
||||
|
||||
expect(() => navigation.navigate(path)).toThrowError(expect.objectContaining({ code }));
|
||||
expect(() => navigation.link(path)).toThrowError(ModuleUiNavigationError);
|
||||
expect(history.entries).toEqual(["/signage"]);
|
||||
});
|
||||
|
||||
it("names nothing it refused in its error", async () => {
|
||||
const { mounts } = await render("/signage");
|
||||
const failure = (() => {
|
||||
try {
|
||||
navigationOf(mounts).navigate("../calendar?token=secret");
|
||||
} catch (error) {
|
||||
return error as Error;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
expect(failure).toBeInstanceOf(ModuleUiNavigationError);
|
||||
expect(`${failure?.message}${failure?.stack}`).not.toMatch(/calendar|secret/u);
|
||||
});
|
||||
|
||||
it("makes real links: in place on an ordinary click, to the browser with a modifier", async () => {
|
||||
const { history, mounts } = await render("/signage");
|
||||
const link = navigationOf(mounts).link("timelines");
|
||||
expect(link.href).toBe("/signage/timelines");
|
||||
|
||||
const ordinary = {
|
||||
altKey: false,
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
defaultPrevented: false,
|
||||
metaKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
shiftKey: false,
|
||||
};
|
||||
await act(async () => link.onClick(ordinary));
|
||||
expect(ordinary.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(history.pathname()).toBe("/signage/timelines");
|
||||
|
||||
const newTab = { ...ordinary, metaKey: true, preventDefault: vi.fn() };
|
||||
await act(async () => navigationOf(mounts).link("media").onClick(newTab));
|
||||
expect(newTab.preventDefault).not.toHaveBeenCalled();
|
||||
expect(history.pathname()).toBe("/signage/timelines");
|
||||
});
|
||||
|
||||
it("reports moves made by the host — the sidebar, Back — to the mounted module", async () => {
|
||||
const { history, mounts } = await render("/signage");
|
||||
const listener = vi.fn();
|
||||
navigationOf(mounts).subscribe(listener);
|
||||
|
||||
await click(required("select-signage-playback"));
|
||||
await act(async () => history.go(-1));
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("module → Settings → back to exactly where it was", () => {
|
||||
it("opens the module's Settings page carrying its current page as the place to return to", async () => {
|
||||
const { history, mounts } = await render("/signage/timelines");
|
||||
const settings = navigationOf(mounts).settings;
|
||||
|
||||
expect(settings?.link().href).toBe("/settings/signage?return=%2Fsignage%2Ftimelines");
|
||||
await act(async () => settings?.open());
|
||||
|
||||
expect(history.entries).toEqual([
|
||||
"/signage/timelines",
|
||||
"/settings/signage?return=%2Fsignage%2Ftimelines",
|
||||
]);
|
||||
expect(query("module-settings")).not.toBeNull();
|
||||
const back =
|
||||
required("module-settings").querySelector<HTMLAnchorElement>(".ek-page-header__back");
|
||||
expect(back?.getAttribute("href")).toBe("/signage/timelines");
|
||||
expect(back?.textContent).toContain("Back to Signage");
|
||||
|
||||
await click(back as HTMLAnchorElement);
|
||||
expect(history.pathname()).toBe("/signage/timelines");
|
||||
expect(current()).toEqual(["select-signage-timelines"]);
|
||||
});
|
||||
|
||||
it("returns to where the module is now, even if it never read its location since moving", async () => {
|
||||
const { history, mounts } = await render("/signage");
|
||||
const settings = navigationOf(mounts).settings;
|
||||
mounts.contexts.get("signage")?.navigation?.subscribe(() => undefined)();
|
||||
|
||||
await act(async () => history.push("/signage/media"));
|
||||
|
||||
expect(settings?.link().href).toBe("/settings/signage?return=%2Fsignage%2Fmedia");
|
||||
});
|
||||
|
||||
it("keeps the place to return to across a refresh of the Settings page", async () => {
|
||||
await render("/settings/signage?return=%2Fsignage%2Fmedia%2Fsermons%2F9");
|
||||
|
||||
const back = required("module-settings").querySelector(".ek-page-header__back");
|
||||
expect(back?.getAttribute("href")).toBe("/signage/media/sermons/9");
|
||||
expect(back?.textContent).toContain("Back to Signage");
|
||||
});
|
||||
|
||||
it("keeps it when the Settings address is respelled", async () => {
|
||||
const { history } = await render("/settings/signage/?return=%2Fsignage%2Fmedia");
|
||||
|
||||
expect(history.entries).toEqual(["/settings/signage?return=%2Fsignage%2Fmedia"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["another origin", "https://evil.example.test/signage"],
|
||||
["a protocol-relative URL", "//evil.example.test"],
|
||||
["a script", "javascript:alert(1)"],
|
||||
["the API", "/api/v1/principal"],
|
||||
["a page the module does not declare", "/signage/nope"],
|
||||
["a module that is not running", "/archive"],
|
||||
["a module that does not exist", "/nowhere"],
|
||||
["traversal", "/signage/../api"],
|
||||
["a nested query", "/signage?return=https://evil.example.test"],
|
||||
])("ignores a return target naming %s, going back to Settings instead", async (_label, target) => {
|
||||
await render(`/settings/signage?return=${encodeURIComponent(target)}`);
|
||||
|
||||
const back = required("module-settings").querySelector(".ek-page-header__back");
|
||||
expect(back?.getAttribute("href")).toBe("/settings");
|
||||
expect(back?.textContent).toContain("Back to Settings");
|
||||
});
|
||||
|
||||
it("offers no Settings link to a module whose settings this viewer cannot open", async () => {
|
||||
const { mounts } = await render("/calendar");
|
||||
|
||||
expect(navigationOf(mounts, "calendar").settings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("module icons", () => {
|
||||
it("maps exactly the names the platform accepts, each to a real Lucide component", () => {
|
||||
expect(Object.keys(MODULE_ICON_COMPONENTS).sort()).toEqual(
|
||||
[...DASHBOARD_MODULE_ICON_NAMES].sort(),
|
||||
);
|
||||
const exports = lucide as unknown as Record<string, unknown>;
|
||||
for (const name of DASHBOARD_MODULE_ICON_NAMES) {
|
||||
const pascal = name
|
||||
.split("-")
|
||||
.map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`)
|
||||
.join("");
|
||||
expect(MODULE_ICON_COMPONENTS[name], name).toBe(exports[pascal]);
|
||||
}
|
||||
});
|
||||
|
||||
it("draws the generic icon for a name this Dashboard cannot draw", async () => {
|
||||
NAVIGATION.ungrouped.push(entry("press", "Press", { icon: "not-yet-an-icon", pages: [] }));
|
||||
try {
|
||||
await render("/");
|
||||
expect(required("select-press").querySelector("svg")?.dataset.icon).toBe("module");
|
||||
} finally {
|
||||
NAVIGATION.ungrouped.pop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,16 @@ import { parseReturnTarget } from "../src/return-target.js";
|
||||
describe("parseReturnTarget", () => {
|
||||
it("accepts a Dashboard page on this origin, as its canonical destination", () => {
|
||||
expect(parseReturnTarget("/")).toEqual({ kind: "overview" });
|
||||
expect(parseReturnTarget("/signage")).toEqual({ kind: "module", moduleId: "signage" });
|
||||
expect(parseReturnTarget("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
|
||||
expect(parseReturnTarget("/signage")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "",
|
||||
});
|
||||
expect(parseReturnTarget("/signage/")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "",
|
||||
});
|
||||
// Settings pages are Dashboard pages too (ADR 0061).
|
||||
expect(parseReturnTarget("/settings")).toEqual({ kind: "settings" });
|
||||
expect(parseReturnTarget("/settings/signage")).toEqual({
|
||||
@@ -59,13 +67,29 @@ describe("parseReturnTarget", () => {
|
||||
"/signage?x=1",
|
||||
"/signage#top",
|
||||
"/api/v1/principal",
|
||||
"/api/auth/sign-out",
|
||||
"/assets/ekklesia-dashboard/app.js",
|
||||
"/display/signage",
|
||||
"/settings/displays/add",
|
||||
"/signage/timelines",
|
||||
"/signage//",
|
||||
"/signage/a//b",
|
||||
]) {
|
||||
expect(parseReturnTarget(value), value).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a module's own deeper route, whose page the host then checks (ADR 0063)", () => {
|
||||
expect(parseReturnTarget("/signage/timelines")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "signage",
|
||||
path: "timelines",
|
||||
});
|
||||
expect(parseReturnTarget("/media/sermons/123")).toEqual({
|
||||
kind: "module",
|
||||
moduleId: "media",
|
||||
path: "sermons/123",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboardBack", () => {
|
||||
@@ -84,7 +108,13 @@ describe("dashboardBack", () => {
|
||||
});
|
||||
|
||||
it("falls back to the page's own parent when the target is missing or unsafe", () => {
|
||||
for (const returnTarget of [undefined, "https://evil.example/", "//evil.example", "/nope/x"]) {
|
||||
for (const returnTarget of [
|
||||
undefined,
|
||||
"https://evil.example/",
|
||||
"//evil.example",
|
||||
"/api/v1/principal",
|
||||
"/signage//",
|
||||
]) {
|
||||
const history = createMemoryHostHistory("/calendar");
|
||||
const back = dashboardBack(history, {
|
||||
fallback: { kind: "overview" },
|
||||
|
||||
@@ -53,6 +53,7 @@ function entry(id: string, name: string) {
|
||||
version: "0.1.0",
|
||||
},
|
||||
name,
|
||||
navigation: { icon: null, pages: [] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ function entry(id: string, name: string, version = "0.1.0") {
|
||||
version,
|
||||
},
|
||||
name,
|
||||
navigation: { icon: null, pages: [] },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/index.ts",
|
||||
"src/dashboard-assets.ts",
|
||||
"src/dashboard-routes.ts",
|
||||
"src/display-assets.ts",
|
||||
"src/host-protocol.ts",
|
||||
"src/module-context.ts"
|
||||
"src/index.ts",
|
||||
"src/module-context.ts",
|
||||
"src/module-icons.ts",
|
||||
"src/module-navigation.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user