feat(shell): Settings hierarchy, Discover, and /settings/<module-id>
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m29s
CI / image-build (pull_request) Successful in 1m4s
CI / deploy-smoke (same-origin) (pull_request) Successful in 54s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m56s

The Settings foundation (ADR 0061):

- URL model. /settings is a real landing page. The platform sections
  are /settings/general, /branding, /displays, and /system. Module
  lifecycle is Discover: /settings/discover/browse, /installed, and
  /updates. /settings/discover is respelled in place to Browse's one
  canonical address. A module's settings are /settings/<module-id>,
  never nested under Discover. Deeper Settings addresses are
  reserved. "Modules" stays the user-facing word: "Discover modules",
  "Installed modules", "Module updates".
- Collisions. Core's one reserved list gains the Settings section
  names (branding, discover, displays, general, system) as a second
  documented group, so no module id can shadow a section; `modules` is
  an ordinary id. apps/api tests hold it equal to the shell's sections.
- Navigation. One Settings item in the same sidebar expands in place
  into Platform and Modules (and Discover's three views) while in
  Settings, and the phone drawer has the same hierarchy. The rest of
  the navigation stays.
- Permissions. GET /api/v1/dashboard/settings evaluates, through
  Core's evaluator, which destinations the viewer may see:
  - Displays: core.devices.manage
  - Discover: core.modules.lifecycle.manage
  - a module's settings: core.modules.configuration.manage, for each
    module that declares configuration (ADR 0029)
  - General, Branding, System: the platform owner, until each has its
    own permission
  These are presentation hints; every data route keeps its own
  enforcement. With nothing visible, there is no Settings link.
- Pages are honest placeholders with Back to Settings. A disabled
  module's settings say it is turned off; a section or module the
  person cannot see says so without detail; an unknown name is not
  found; a failed load withholds the link and offers Retry.
- The deploy smoke opens Settings by deep link, refreshes a section,
  enters Discover, opens the module's settings, uses the phone
  drawer, and returns to the Dashboard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-14 22:22:50 -05:00
co-authored by Claude Opus 5.5
parent 34692ac05f
commit 89ef4b5ff6
30 changed files with 2343 additions and 50 deletions
+57 -7
View File
@@ -37,6 +37,15 @@ import { LoginForm } from "./LoginForm.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js";
import { ResetPasswordForm } from "./ResetPasswordForm.js";
import { ShellLayout } from "./ShellLayout.js";
import { resolveSettingsPage, type SettingsPage, settingsPageTitle } from "./settings-catalog.js";
import {
DiscoverPage,
ModuleSettingsPage,
SettingsHomePage,
SettingsLoadFailedPage,
SettingsSectionPage,
SettingsUnavailablePage,
} from "./SettingsPages.js";
import { createMemorySidebarPreference, type SidebarPreference } from "./sidebar-preference.js";
import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js";
@@ -167,10 +176,34 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry
];
}
function SettingsPageView(props: {
readonly page: SettingsPage;
readonly history: HostHistory;
readonly onRetry: () => void;
}): ReactElement {
const { page, history } = props;
switch (page.kind) {
case "home":
return <SettingsHomePage history={history} settings={page.settings} />;
case "section":
return <SettingsSectionPage history={history} section={page.section} />;
case "discover":
return <DiscoverPage history={history} view={page.view} />;
case "module":
return <ModuleSettingsPage history={history} module={page.module} />;
case "unavailable":
return <SettingsUnavailablePage history={history} reason={page.reason} />;
case "load-failed":
return <SettingsLoadFailedPage onRetry={props.onRetry} />;
case "not-found":
return <NotFoundPage history={history} />;
}
}
/** A stable identity for "which page is showing", so a non-canonical spelling of the same page is
* not a page change. */
function routeKey(route: DashboardRoute): string {
return route.kind === "module" ? `module:${route.moduleId}` : route.kind;
return route.kind === "not-found" ? "not-found" : dashboardPath(route);
}
function HostMessage(props: {
@@ -437,14 +470,28 @@ export function HostApp(props: HostAppProps): ReactElement {
state.status === "ready" && route.kind === "module"
? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId)
: undefined;
const settingsPage =
state.status === "ready" &&
(route.kind === "settings" ||
route.kind === "settings-section" ||
route.kind === "discover" ||
route.kind === "module-settings")
? resolveSettingsPage(
route,
state.bootstrap.settings,
new Set(allEntries(state.bootstrap).map((entry) => entry.descriptor.id)),
)
: undefined;
const pageTitle =
state.status !== "ready"
? undefined
: route.kind === "overview"
? "Overview"
: route.kind === "module"
? (openEntry?.name ?? "This page isn’t available")
: "Page not found";
: settingsPage !== undefined
? settingsPageTitle(settingsPage)
: route.kind === "overview"
? "Overview"
: route.kind === "module"
? (openEntry?.name ?? "This page isn’t available")
: "Page not found";
const documentTitle = state.status === "anonymous" ? "Sign in" : pageTitle;
useEffect(() => {
@@ -566,10 +613,13 @@ export function HostApp(props: HostAppProps): ReactElement {
onSignOut={() => void signOut()}
pageTitle={pageTitle}
route={route}
settings={bootstrap.settings}
sidebarPreference={sidebarPreference}
viewport={viewport}
>
{route.kind === "overview" ? (
{settingsPage !== undefined ? (
<SettingsPageView history={history} onRetry={retryBootstrap} page={settingsPage} />
) : route.kind === "overview" ? (
<OverviewPage />
) : route.kind === "module" ? (
openEntry ? (
@@ -0,0 +1,248 @@
import { designToken } from "@ekklesia/design-tokens";
import { Button, EmptyState, ErrorState, PageHeader, Panel } from "@ekklesia/ui";
import type { ReactElement } from "react";
import type {
DashboardDestination,
DiscoverView,
SettingsPageSection,
} from "../dashboard-routes.js";
import type { DashboardSettings, DashboardSettingsModule } from "../host-protocol.js";
import { dashboardBack } from "./dashboard-back.js";
import { DashboardLink } from "./DashboardLink.js";
import type { HostHistory } from "./host-history.js";
import {
DISCOVER_VIEW_COPY,
navigableModuleSettings,
SETTINGS_SECTION_COPY,
settingsSectionDestination,
} from "./settings-catalog.js";
import { ShellIcon, type ShellIconName } from "./shell-icons.js";
/**
* The Dashboard's Settings pages (ADR 0061).
*
* This is the foundation only: the landing page, and a page at every Settings address, each honest
* about what exists. No section's real controls exist yet, so each says so plainly rather than
* showing fake fields; the work that builds a section replaces its placeholder.
*/
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) {
return dashboardBack(history, {
fallback: BACK_TO_SETTINGS,
label: (destination) => (destination.kind === "settings" ? "Back to Settings" : "Back"),
returnTarget,
});
}
function SettingsLinkList(props: {
readonly history: HostHistory;
readonly items: readonly {
readonly key: string;
readonly destination: DashboardDestination;
readonly label: string;
readonly description?: string;
readonly icon: ShellIconName;
readonly testId: string;
}[];
}): ReactElement {
return (
<ul
style={{
display: "grid",
gap: designToken("spacing.xs"),
listStyle: "none",
margin: 0,
padding: 0,
}}
>
{props.items.map((item) => (
<li key={item.key}>
<DashboardLink
className="ek-shell-settings-link"
data-testid={item.testId}
history={props.history}
style={{ minHeight: designToken("control.height-lg") }}
to={item.destination}
>
<ShellIcon name={item.icon} />
<span className="ek-shell-settings-link__copy">
<span className="ek-shell-settings-link__label">{item.label}</span>
{item.description === undefined ? undefined : (
<span className="ek-shell-settings-link__description">{item.description}</span>
)}
</span>
</DashboardLink>
</li>
))}
</ul>
);
}
/**
* `/settings`: a real landing page, not a redirect to the first section — the two groups the
* person may open, each item named and described in a line. Not a dashboard of its own.
*/
export function SettingsHomePage(props: {
readonly history: HostHistory;
readonly settings: DashboardSettings;
}): ReactElement {
const modules = navigableModuleSettings(props.settings);
const empty = props.settings.sections.length === 0 && modules.length === 0;
return (
<div data-testid="settings-home" style={{ display: "grid", gap: designToken("spacing.xl") }}>
<PageHeader description="How this installation and its modules work." title="Settings" />
{empty ? (
<EmptyState
description="Ask an administrator if you need to change how something works."
title="There are no settings for you to change"
/>
) : undefined}
{props.settings.sections.length > 0 ? (
<Panel title="Platform">
<SettingsLinkList
history={props.history}
items={props.settings.sections.map((section) => ({
description: SETTINGS_SECTION_COPY[section].description,
destination: settingsSectionDestination(section),
icon: SETTINGS_SECTION_COPY[section].icon,
key: section,
label: SETTINGS_SECTION_COPY[section].label,
testId: `settings-home-${section}`,
}))}
/>
</Panel>
) : undefined}
{modules.length > 0 ? (
<Panel title="Modules">
<SettingsLinkList
history={props.history}
items={modules.map((module) => ({
destination: { kind: "module-settings", moduleId: module.id },
icon: "module",
key: module.id,
label: module.name,
testId: `settings-home-module-${module.id}`,
}))}
/>
</Panel>
) : undefined}
</div>
);
}
const SECTION_NOT_YET: Readonly<Record<SettingsPageSection, string>> = Object.freeze({
branding: "Your church's name, logo, and colours will be managed here.",
displays:
"Paired screens will be managed here. Until then, pair and revoke displays from Display devices in the navigation.",
general: "Settings that apply across this installation will be managed here.",
system: "Version, health, and diagnostics for this installation will appear here.",
});
/** `/settings/discover/<view>`: one of Discover's views, not built yet. Module lifecycle lives
* here; configuring a module is its own `/settings/<module-id>`. */
export function DiscoverPage(props: {
readonly history: HostHistory;
readonly view: DiscoverView;
}): ReactElement {
const copy = DISCOVER_VIEW_COPY[props.view];
return (
<div
data-testid={`settings-discover-${props.view}`}
style={{ display: "grid", gap: designToken("spacing.xl") }}
>
<PageHeader
back={settingsBack(props.history)}
description={copy.description}
eyebrow="Discover"
title={copy.title}
/>
<EmptyState description={copy.notYet} title="Not available yet" />
</div>
);
}
/** `/settings/<section>`: a platform section the person may see, not built yet. */
export function SettingsSectionPage(props: {
readonly history: HostHistory;
readonly section: SettingsPageSection;
}): ReactElement {
const copy = SETTINGS_SECTION_COPY[props.section];
return (
<div
data-testid={`settings-section-${props.section}`}
style={{ display: "grid", gap: designToken("spacing.xl") }}
>
<PageHeader
back={settingsBack(props.history)}
description={copy.description}
title={copy.label}
/>
<EmptyState description={SECTION_NOT_YET[props.section]} title="Not available yet" />
</div>
);
}
/** `/settings/<module-id>`: a running module's settings, not built yet. */
export function ModuleSettingsPage(props: {
readonly history: HostHistory;
readonly module: DashboardSettingsModule;
}): ReactElement {
return (
<div data-testid="module-settings" style={{ display: "grid", gap: designToken("spacing.xl") }}>
<PageHeader
back={settingsBack(props.history)}
description={`How ${props.module.name} works for your church.`}
eyebrow="Settings"
title={props.module.name}
/>
<EmptyState
description={`${props.module.name}'s settings will be changed here.`}
title="Not available yet"
/>
</div>
);
}
export type SettingsUnavailableReason = "no-access" | "module-disabled";
/**
* A Settings address that exists but cannot be opened by this person right now. It never says
* which of "you may not" or "there is nothing here" applies for a module the person cannot
* configure, and never echoes the address.
*/
export function SettingsUnavailablePage(props: {
readonly history: HostHistory;
readonly reason: SettingsUnavailableReason;
}): ReactElement {
const disabled = props.reason === "module-disabled";
return (
<div data-testid={disabled ? "settings-module-unavailable" : "settings-no-access"}>
<PageHeader
back={settingsBack(props.history)}
description={
disabled
? "The module these settings belong to is turned off. Its settings come back when it is turned on again."
: "These settings aren't available to you. If you need to change them, ask an administrator."
}
title={disabled ? "This module is turned off" : "These settings aren't available"}
/>
</div>
);
}
/** Settings could not be loaded: the rest of the Dashboard still works, and Retry asks again. */
export function SettingsLoadFailedPage(props: { readonly onRetry: () => void }): ReactElement {
return (
<div data-testid="settings-load-failed">
<ErrorState
actions={<Button onClick={props.onRetry}>Retry</Button>}
description="The rest of the Dashboard still works. Try again in a moment."
title="Settings could not be loaded"
/>
</div>
);
}
+16 -3
View File
@@ -11,8 +11,12 @@ import {
useState,
useSyncExternalStore,
} from "react";
import type { DashboardRoute } from "../dashboard-routes.js";
import type { DashboardNavigation, EkklesiaHostConfig } from "../host-protocol.js";
import { type DashboardRoute, dashboardPath } from "../dashboard-routes.js";
import type {
DashboardNavigation,
DashboardSettings,
EkklesiaHostConfig,
} from "../host-protocol.js";
import { AccountMenu } from "./AccountMenu.js";
import { DevicePanel } from "./DevicePanel.js";
import type { HostHistory } from "./host-history.js";
@@ -40,6 +44,7 @@ export interface ShellLayoutProps {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly navigation: DashboardNavigation;
readonly settings: DashboardSettings | "unavailable";
readonly route: DashboardRoute;
readonly viewport: HostViewport;
readonly sidebarPreference: SidebarPreference;
@@ -83,6 +88,7 @@ function Sidebar(props: {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly navigation: DashboardNavigation;
readonly settings: DashboardSettings | "unavailable";
readonly route: DashboardRoute;
readonly mode: SidebarMode;
readonly onToggle: () => void;
@@ -110,6 +116,7 @@ function Sidebar(props: {
navigation={props.navigation}
presentation={presentation}
route={props.route}
settings={props.settings}
/>
{/* The transitional device panel (ADR 0036) needs room for its form; collapsed, expanding
the sidebar reveals it. Stage 1 moves it to Settings → Displays. */}
@@ -135,6 +142,7 @@ function NavigationDrawer(props: {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly navigation: DashboardNavigation;
readonly settings: DashboardSettings | "unavailable";
readonly route: DashboardRoute;
readonly onDismiss: () => void;
readonly onNavigate: () => void;
@@ -213,6 +221,7 @@ function NavigationDrawer(props: {
onNavigate={props.onNavigate}
presentation={presentation}
route={props.route}
settings={props.settings}
/>
<DevicePanel config={props.config} />
</div>
@@ -224,8 +233,10 @@ function NavigationDrawer(props: {
);
}
/** Which page is showing, by its canonical address, so moving between two Settings sections or two
* modules is a page change and a respelling of the same page is not. */
function routeKey(route: DashboardRoute): string {
return route.kind === "module" ? `module:${route.moduleId}` : route.kind;
return route.kind === "not-found" ? "not-found" : dashboardPath(route);
}
/**
@@ -321,6 +332,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
onSignOut={props.onSignOut}
onToggle={toggleMode}
route={route}
settings={props.settings}
/>
)}
<div style={{ minWidth: 0 }}>
@@ -371,6 +383,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
onNavigate={() => closeDrawer("main")}
onSignOut={props.onSignOut}
route={route}
settings={props.settings}
/>
) : undefined}
</div>
+199 -1
View File
@@ -1,10 +1,23 @@
import { designToken } from "@ekklesia/design-tokens";
import type { CSSProperties, ReactElement } from "react";
import type { DashboardDestination, DashboardRoute } from "../dashboard-routes.js";
import type { DashboardNavigation, DashboardNavigationEntry } from "../host-protocol.js";
import type {
DashboardNavigation,
DashboardNavigationEntry,
DashboardSettings,
} from "../host-protocol.js";
import { DashboardLink } from "./DashboardLink.js";
import type { HostHistory } from "./host-history.js";
import { useNavTooltip } from "./NavTooltip.js";
import { DISCOVER_VIEWS } from "../dashboard-routes.js";
import {
DISCOVER_VIEW_COPY,
hasVisibleSettings,
isSettingsRoute,
navigableModuleSettings,
SETTINGS_SECTION_COPY,
settingsSectionDestination,
} from "./settings-catalog.js";
import { ShellIcon, type ShellIconName } from "./shell-icons.js";
/**
@@ -121,10 +134,187 @@ export interface NavigationListProps {
readonly route: DashboardRoute;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
/** Which Settings destinations this person may see (ADR 0061). */
readonly settings: DashboardSettings | "unavailable";
/** Called once a destination is chosen, for the drawer to close. */
readonly onNavigate?: () => void;
}
/**
* Discover in the Settings navigation: one item that opens Browse, which — while a Discover view is
* open — shows its three views beneath it. The open view carries `aria-current`; Discover itself does
* not, so exactly one item is the current page.
*/
function DiscoverNavigation(props: {
readonly id: string;
readonly route: DashboardRoute;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
readonly onNavigate?: () => void;
}): ReactElement {
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
const open = props.route.kind === "discover";
const copy = SETTINGS_SECTION_COPY.discover;
return (
<>
<NavigationItem
current={false}
destination={settingsSectionDestination("discover")}
history={props.history}
icon={copy.icon}
label={copy.label}
{...onNavigate}
presentation={props.presentation}
testId="select-settings-discover"
/>
{open ? (
<li>
<ul
aria-label={copy.label}
className={
props.presentation.collapsed
? "ek-shell-nav__group"
: "ek-shell-nav__group ek-shell-nav__nested"
}
data-testid="discover-subnavigation"
style={{ listStyle: "none", margin: 0, padding: 0 }}
>
{DISCOVER_VIEWS.map((view) => (
<NavigationItem
current={props.route.kind === "discover" && props.route.view === view}
destination={{ kind: "discover", view }}
history={props.history}
icon={DISCOVER_VIEW_COPY[view].icon}
key={view}
label={DISCOVER_VIEW_COPY[view].label}
{...onNavigate}
presentation={props.presentation}
testId={`select-discover-${view}`}
/>
))}
</ul>
</li>
) : undefined}
</>
);
}
/**
* Settings, in the same sidebar as everything else (DESIGN.md §4.2, ADR 0061): one Settings item
* after the modules, which — while a Settings page is open — expands in place into the platform's
* own sections and each module's settings. The rest of the navigation stays where it was, so leaving
* Settings is one click on any other item, and the hierarchy is identical in the phone drawer.
*
* Only what the server said this person may see is listed, and nothing at all when that is nothing:
* no Settings link leads to a page of things they cannot change.
*/
function SettingsNavigation(props: {
readonly id: string;
readonly settings: DashboardSettings | "unavailable";
readonly route: DashboardRoute;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
readonly onNavigate?: () => void;
}): ReactElement | null {
if (!hasVisibleSettings(props.settings) || props.settings === "unavailable") {
return null;
}
const { route, settings } = props;
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
const open = isSettingsRoute(route.kind);
const modules = navigableModuleSettings(settings);
const collapsed = props.presentation.collapsed;
const listStyle = { listStyle: "none", margin: 0, padding: 0 } as const;
const labelClass = collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label";
return (
<section data-testid="settings-navigation">
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
<ul className="ek-shell-nav__group" style={listStyle}>
<NavigationItem
current={route.kind === "settings"}
destination={{ kind: "settings" }}
history={props.history}
icon="settings"
label="Settings"
{...onNavigate}
presentation={props.presentation}
testId="select-settings"
/>
</ul>
{open ? (
<div
className={collapsed ? undefined : "ek-shell-nav__nested"}
data-testid="settings-subnavigation"
>
{settings.sections.length > 0 ? (
<>
<h3 className={labelClass} id={`${props.id}-settings-platform`}>
Platform
</h3>
<ul
aria-labelledby={`${props.id}-settings-platform`}
className="ek-shell-nav__group"
style={listStyle}
>
{settings.sections.map((section) =>
section === "discover" ? (
<DiscoverNavigation
history={props.history}
id={props.id}
key={section}
{...onNavigate}
presentation={props.presentation}
route={route}
/>
) : (
<NavigationItem
current={route.kind === "settings-section" && route.section === section}
destination={settingsSectionDestination(section)}
history={props.history}
icon={SETTINGS_SECTION_COPY[section].icon}
key={section}
label={SETTINGS_SECTION_COPY[section].label}
{...onNavigate}
presentation={props.presentation}
testId={`select-settings-${section}`}
/>
),
)}
</ul>
</>
) : undefined}
{modules.length > 0 ? (
<>
<h3 className={labelClass} id={`${props.id}-settings-modules`}>
Modules
</h3>
<ul
aria-labelledby={`${props.id}-settings-modules`}
className="ek-shell-nav__group"
style={listStyle}
>
{modules.map((module) => (
<NavigationItem
current={route.kind === "module-settings" && route.moduleId === module.id}
destination={{ kind: "module-settings", moduleId: module.id }}
history={props.history}
icon="module"
key={module.id}
label={module.name}
{...onNavigate}
presentation={props.presentation}
testId={`select-module-settings-${module.id}`}
/>
))}
</ul>
</>
) : undefined}
</div>
) : undefined}
</section>
);
}
/**
* The Dashboard's one navigation hierarchy, identical in the desktop sidebar and the phone drawer
* (DESIGN.md §4.6): the Overview first, then the server's groups in the server's order, then any
@@ -203,6 +393,14 @@ export function NavigationList(props: NavigationListProps): ReactElement {
/>
</section>
) : undefined}
<SettingsNavigation
history={props.history}
id={props.id}
{...onNavigate}
presentation={props.presentation}
route={props.route}
settings={props.settings}
/>
</nav>
);
}
@@ -0,0 +1,198 @@
import type {
DashboardDestination,
DiscoverView,
SettingsPageSection,
SettingsSection,
} from "../dashboard-routes.js";
import type { DashboardSettings, DashboardSettingsModule } from "../host-protocol.js";
import type { ShellIconName } from "./shell-icons.js";
/**
* How each platform Settings section is named and described to a person (ADR 0061). One table, so
* the navigation, the Settings landing page, and each section's own page say the same thing. Plain
* words — never "Core", "platform module", or another implementation term (DESIGN.md §2.3).
*/
export const SETTINGS_SECTION_COPY: Readonly<
Record<
SettingsSection,
{ readonly label: string; readonly description: string; readonly icon: ShellIconName }
>
> = Object.freeze({
branding: {
description: "Your church's name, logo, and colours.",
icon: "settingsBranding",
label: "Branding",
},
displays: {
description: "Screens paired with this installation.",
icon: "settingsDisplays",
label: "Displays",
},
general: {
description: "How this installation works for everyone.",
icon: "settingsGeneral",
label: "General",
},
discover: {
description: "Find, install, and manage the modules your church uses.",
icon: "settingsDiscover",
label: "Discover",
},
system: {
description: "Version, health, and diagnostics for this installation.",
icon: "settingsSystem",
label: "System",
},
});
/**
* Discover's views (ADR 0061). "Discover" is where module lifecycle lives in the navigation; inside
* it the pages speak of modules — "Discover modules", "Installed modules", "Module updates" —
* because Modules is the product's word for them.
*/
export const DISCOVER_VIEW_COPY: Readonly<
Record<
DiscoverView,
{
readonly label: string;
readonly title: string;
readonly description: string;
readonly notYet: string;
readonly icon: ShellIconName;
}
>
> = Object.freeze({
browse: {
description:
"Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses.",
icon: "discoverBrowse",
label: "Browse",
notYet: "Finding and installing modules will happen here.",
title: "Discover modules",
},
installed: {
description: "The modules installed for your church.",
icon: "discoverInstalled",
label: "Installed",
notYet:
"Seeing your installed modules, turning them on or off, inspecting them, and removing them will happen here.",
title: "Installed modules",
},
updates: {
description: "Updates for your modules and for this installation.",
icon: "discoverUpdates",
label: "Updates",
notYet: "Checking for and applying module and platform updates will happen here.",
title: "Module updates",
},
});
/** Where a platform section's navigation item leads: its page, or — for Discover — Browse. */
export function settingsSectionDestination(section: SettingsSection): DashboardDestination {
return section === "discover"
? { kind: "discover", view: "browse" }
: { kind: "settings-section", section };
}
/** The module settings a person can open from navigation: running modules only. A disabled
* module's settings are not offered, though its address still says it is unavailable. */
export function navigableModuleSettings(
settings: DashboardSettings | "unavailable",
): readonly DashboardSettingsModule[] {
return settings === "unavailable" ? [] : settings.modules.filter((module) => module.available);
}
/** Whether Settings has anything to offer this person. With nothing, the Settings link is not
* shown at all, so nobody is shown administration they cannot carry out (DESIGN.md §9.2). */
export function hasVisibleSettings(settings: DashboardSettings | "unavailable"): boolean {
return (
settings !== "unavailable" &&
(settings.sections.length > 0 || navigableModuleSettings(settings).length > 0)
);
}
export function isSettingsRoute(kind: string): boolean {
return (
kind === "settings" ||
kind === "settings-section" ||
kind === "discover" ||
kind === "module-settings"
);
}
/** What a Settings address shows this person. */
export type SettingsPage =
| { readonly kind: "home"; readonly settings: DashboardSettings }
| { readonly kind: "section"; readonly section: SettingsPageSection }
| { readonly kind: "discover"; readonly view: DiscoverView }
| { readonly kind: "module"; readonly module: DashboardSettingsModule }
| { readonly kind: "unavailable"; readonly reason: "no-access" | "module-disabled" }
| { readonly kind: "load-failed" }
| { readonly kind: "not-found" };
/**
* Decides what a Settings address shows (ADR 0061), from what the server said this person may see:
*
* - a section or module they may see shows its page;
* - a disabled module's settings say it is turned off — never a silent redirect;
* - a section they may not see, or a module they know of but may not configure (or that has no
* settings), says the settings aren't available, without saying which;
* - a name that is no module they know of is not found.
*/
export function resolveSettingsPage(
route:
| { readonly kind: "settings" }
| { readonly kind: "settings-section"; readonly section: SettingsPageSection }
| { readonly kind: "discover"; readonly view: DiscoverView }
| { readonly kind: "module-settings"; readonly moduleId: string },
settings: DashboardSettings | "unavailable",
knownModuleIds: ReadonlySet<string>,
): SettingsPage {
if (settings === "unavailable") {
return { kind: "load-failed" };
}
if (route.kind === "settings") {
return { kind: "home", settings };
}
if (route.kind === "discover") {
return settings.sections.includes("discover")
? { kind: "discover", view: route.view }
: { kind: "unavailable", reason: "no-access" };
}
if (route.kind === "settings-section") {
return settings.sections.includes(route.section)
? { kind: "section", section: route.section }
: { kind: "unavailable", reason: "no-access" };
}
const module = settings.modules.find((candidate) => candidate.id === route.moduleId);
if (module) {
return module.available
? { kind: "module", module }
: { kind: "unavailable", reason: "module-disabled" };
}
return knownModuleIds.has(route.moduleId)
? { kind: "unavailable", reason: "no-access" }
: { kind: "not-found" };
}
/** The browser tab's name for a Settings page. */
export function settingsPageTitle(page: SettingsPage): string {
switch (page.kind) {
case "home":
return "Settings";
case "section":
return `${SETTINGS_SECTION_COPY[page.section].label} settings`;
case "discover":
return DISCOVER_VIEW_COPY[page.view].title;
case "module":
return `${page.module.name} settings`;
case "unavailable":
return page.reason === "module-disabled"
? "This module is turned off"
: "These settings aren't available";
case "load-failed":
return "Settings could not be loaded";
case "not-found":
return "Page not found";
}
}
@@ -1,12 +1,21 @@
import {
Blocks,
CircleUserRound,
Compass,
LayoutDashboard,
type LucideIcon,
LogOut,
Menu,
MonitorSmartphone,
Palette,
PackageCheck,
PanelLeftClose,
PanelLeftOpen,
RefreshCw,
Search,
Server,
Settings,
SlidersHorizontal,
X,
} from "lucide-react";
import type { ReactElement } from "react";
@@ -27,10 +36,19 @@ export const SHELL_ICONS = Object.freeze({
account: CircleUserRound,
closeNavigation: X,
collapseSidebar: PanelLeftClose,
discoverBrowse: Search,
discoverInstalled: PackageCheck,
discoverUpdates: RefreshCw,
expandSidebar: PanelLeftOpen,
module: Blocks,
openNavigation: Menu,
overview: LayoutDashboard,
settings: Settings,
settingsBranding: Palette,
settingsDisplays: MonitorSmartphone,
settingsDiscover: Compass,
settingsGeneral: SlidersHorizontal,
settingsSystem: Server,
signOut: LogOut,
} satisfies Record<string, LucideIcon>);
@@ -105,6 +105,15 @@ export const SHELL_STYLESHEET = `
text-transform: uppercase;
}
.ek-shell-nav__nested {
border-inline-start: ${designToken("border.width")} solid ${designToken("border.divider")};
display: grid;
gap: ${designToken("spacing.xs")};
margin-block-start: ${designToken("spacing.xs")};
margin-inline-start: ${designToken("spacing.lg")};
padding-inline-start: ${designToken("spacing.xs")};
}
.ek-shell-nav__divider {
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
margin: 0 ${designToken("spacing.xs")};
@@ -134,6 +143,46 @@ export const SHELL_STYLESHEET = `
padding-inline: 0;
}
.ek-shell-settings-link {
align-items: center;
border-radius: ${designToken("radius.md")};
box-sizing: border-box;
color: ${designToken("text.primary")};
display: flex;
gap: ${designToken("spacing.md")};
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")};
text-decoration: none;
}
.ek-shell-settings-link:hover {
background: ${designToken("state.hover-background")};
}
.ek-shell-settings-link:focus-visible {
outline: ${designToken("state.focus-visible-width")} solid ${designToken("accent.focus")};
outline-offset: ${designToken("state.focus-visible-offset")};
}
.ek-shell-settings-link svg {
color: ${designToken("text.secondary")};
flex: 0 0 auto;
}
.ek-shell-settings-link__copy {
display: grid;
gap: ${designToken("spacing.2xs")};
min-width: 0;
}
.ek-shell-settings-link__label {
font-weight: ${designToken("typography.weight-medium")};
}
.ek-shell-settings-link__description {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
}
.ek-shell-tooltip {
background: ${designToken("background.raised")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
+99 -7
View File
@@ -8,10 +8,13 @@
* Module pages and platform pages share one top-level namespace:
*
* ```text
* / Overview
* /settings[/…] Settings
* /account[/…] the account area
* /<module-id>[/…] a module's page, and later its own sub-pages
* / Overview
* /settings Settings
* /settings/<section> a platform Settings section: general, branding, displays, system
* /settings/discover/<view> finding, installing, and managing modules: browse, installed, updates
* /settings/<module-id> a module's settings (and later its own deeper settings pages)
* /account[/…] the account area
* /<module-id>[/…] a module's page, and later its own sub-pages
* ```
*
* The server serves the page at `/<module-id>` only for a module that is actually installed with a
@@ -27,16 +30,60 @@
*/
export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] 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,
* installing, turning on and off, updating — is Discover, at `/settings/discover/…`; configuring a
* module is its own `/settings/<module-id>`. Each section name is also a reserved module id in Core,
* so no module can take one, and `apps/api` tests hold the two lists together.
*/
export const SETTINGS_SECTIONS = Object.freeze([
"general",
"branding",
"displays",
"discover",
"system",
] as const);
export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
/** The sections that are one page each. Discover is a group of views instead. */
export type SettingsPageSection = Exclude<SettingsSection, "discover">;
/**
* Discover's views, in navigation order: Browse (find and install modules), Installed (see and
* manage installed modules), and Updates (module and platform updates). Browse is where Discover
* opens. They live beneath `/settings/discover/`, so none can collide with a module id.
*/
export const DISCOVER_VIEWS = Object.freeze(["browse", "installed", "updates"] as const);
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 }
| { readonly kind: "settings" }
| { readonly kind: "settings-section"; readonly section: SettingsPageSection }
| { readonly kind: "discover"; readonly view: DiscoverView }
| { readonly kind: "module-settings"; readonly moduleId: string }
| { readonly kind: "not-found" };
/** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */
export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>;
const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
const settingsSections: ReadonlySet<string> = new Set(SETTINGS_SECTIONS);
const discoverViews: ReadonlySet<string> = new Set(DISCOVER_VIEWS);
function isSettingsPageSection(value: string): value is SettingsPageSection {
return value !== "discover" && settingsSections.has(value);
}
function isDiscoverView(value: string): value is DiscoverView {
return discoverViews.has(value);
}
function decodeSegment(segment: string): string | undefined {
try {
@@ -70,6 +117,9 @@ export function parseDashboardPath(pathname: string): DashboardRoute {
}
const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1);
const first = meaningful[0] === undefined ? undefined : decodeSegment(meaningful[0]);
if (first === "settings") {
return parseSettingsPath(meaningful.slice(1));
}
if (first === undefined || platformPages.has(first)) {
return { kind: "not-found" };
}
@@ -79,11 +129,53 @@ export function parseDashboardPath(pathname: string): DashboardRoute {
return { kind: "not-found" };
}
/**
* Reads what follows `/settings`. One more segment is a platform section or, failing that, a
* module's settings. `discover` is entered at Browse: `/settings/discover` reads as Browse, whose
* canonical spelling is `/settings/discover/browse`, so the shell respells it in place and there is
* one Browse address rather than two. Anything deeper is reserved for later Settings pages and is
* `not-found` until they exist, rather than silently showing their parent.
*/
function parseSettingsPath(rest: readonly string[]): DashboardRoute {
if (rest.length === 0) {
return { kind: "settings" };
}
const name = rest[0] === undefined ? undefined : decodeSegment(rest[0]);
if (name === undefined) {
return { kind: "not-found" };
}
if (name === "discover") {
if (rest.length === 1) {
return { kind: "discover", view: "browse" };
}
const view = rest[1] === undefined ? undefined : decodeSegment(rest[1]);
return rest.length === 2 && view !== undefined && isDiscoverView(view)
? { kind: "discover", view }
: { kind: "not-found" };
}
if (rest.length !== 1) {
return { kind: "not-found" };
}
return isSettingsPageSection(name)
? { kind: "settings-section", section: name }
: { kind: "module-settings", moduleId: name };
}
/** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its
* own path segment. */
export function dashboardPath(route: DashboardDestination): string {
if (route.kind === "overview") {
return "/";
switch (route.kind) {
case "overview":
return "/";
case "module":
return `/${encodeURIComponent(route.moduleId)}`;
case "settings":
return "/settings";
case "settings-section":
return `/settings/${route.section}`;
case "discover":
return `/settings/discover/${route.view}`;
case "module-settings":
return `/settings/${encodeURIComponent(route.moduleId)}`;
}
return `/${encodeURIComponent(route.moduleId)}`;
}
+97 -2
View File
@@ -1,3 +1,4 @@
import { SETTINGS_SECTIONS, type SettingsSection } from "./dashboard-routes.js";
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
/**
@@ -36,6 +37,7 @@ export type HostEventSourceFactory = (url: string, options: HostEventSourceOptio
* code for a consumer that does not exist. */
const PRINCIPAL_PATH = "/api/v1/principal";
const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
const DASHBOARD_SETTINGS_PATH = "/api/v1/dashboard/settings";
const MODULE_DISPLAYS_PATH = "/api/v1/module-displays";
const MODULE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
@@ -861,11 +863,94 @@ export async function bootstrapDisplayHost(
});
}
/** One module's settings destination, as the server evaluated it for this viewer (ADR 0061). */
export interface DashboardSettingsModule {
readonly id: string;
readonly name: string;
/** `false` for a disabled or blocked module: its address says so, and navigation omits it. */
readonly available: boolean;
}
/** Which Settings destinations this viewer may see. Presentation hints, never authority. */
export interface DashboardSettings {
readonly sections: readonly SettingsSection[];
readonly modules: readonly DashboardSettingsModule[];
}
const settingsSectionNames: ReadonlySet<string> = new Set(SETTINGS_SECTIONS);
/**
* Strict, total validation of the Settings view: exact keys, known section names only and each at
* most once, and module ids and names as bounded strings. Anything else is a malformed response.
*/
export function parseDashboardSettings(value: unknown): DashboardSettings {
if (!hasExactKeys(value, ["modules", "sections"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const { modules, sections } = value;
if (!Array.isArray(sections) || !Array.isArray(modules)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
if (
!sections.every(
(section): section is SettingsSection =>
typeof section === "string" && settingsSectionNames.has(section),
) ||
new Set(sections).size !== sections.length
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const parsedModules = modules.map((module): DashboardSettingsModule => {
if (
!hasExactKeys(module, ["available", "id", "name"]) ||
typeof module.id !== "string" ||
module.id.length === 0 ||
module.id.length > 64 ||
typeof module.name !== "string" ||
module.name.trim().length === 0 ||
module.name.length > 120 ||
typeof module.available !== "boolean"
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ available: module.available, id: module.id, name: module.name });
});
if (new Set(parsedModules.map((module) => module.id)).size !== parsedModules.length) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
modules: Object.freeze(parsedModules),
sections: Object.freeze([...sections]),
});
}
export async function loadDashboardSettings(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<DashboardSettings> {
return parseDashboardSettings(
await requestPlatformJson(config, DASHBOARD_SETTINGS_PATH, signal, {
forbidden: "HOST_ACCESS_DENIED",
}),
);
}
export interface HostBootstrap {
readonly principal: ModuleUiPrincipal;
readonly navigation: DashboardNavigation;
/**
* The Settings destinations this viewer may see, or `"unavailable"` when they could not be
* loaded. Settings failing never stops the rest of the Dashboard from working: the Settings link
* is withheld and its pages say they could not be loaded.
*/
readonly settings: DashboardSettings | "unavailable";
}
const EMPTY_SETTINGS: DashboardSettings = Object.freeze({
modules: Object.freeze([]),
sections: Object.freeze([]),
});
const EMPTY_NAVIGATION: DashboardNavigation = Object.freeze({
groups: Object.freeze([]),
ungrouped: Object.freeze([]),
@@ -883,7 +968,17 @@ export async function bootstrapHost(
): Promise<HostBootstrap> {
const principal = await loadHostPrincipal(config, signal);
if (principal.kind === "anonymous") {
return { navigation: EMPTY_NAVIGATION, principal };
return { navigation: EMPTY_NAVIGATION, principal, settings: EMPTY_SETTINGS };
}
return { navigation: await loadDashboardNavigation(config, signal), principal };
const [navigation, settings] = await Promise.all([
loadDashboardNavigation(config, signal),
loadDashboardSettings(config, signal).catch((error: unknown) => {
if (signal?.aborted) {
throw error;
}
console.error("[ekklesia] settings navigation unavailable", error);
return "unavailable" as const;
}),
]);
return { navigation, principal, settings };
}
+4
View File
@@ -13,6 +13,8 @@ export {
bootstrapHost,
bootstrapDisplayHost,
type DashboardNavigation,
type DashboardSettings,
type DashboardSettingsModule,
type DashboardNavigationEntry,
type DashboardNavigationGroup,
type DisplayHostBootstrap,
@@ -25,10 +27,12 @@ export {
HostProtocolError,
type HostProtocolErrorCode,
loadDashboardNavigation,
loadDashboardSettings,
loadHostPrincipal,
loadModuleDisplay,
moduleDisplayDescriptorPath,
parseDashboardNavigation,
parseDashboardSettings,
parseDisplayRoute,
parseHostModuleDisplay,
parseHostPrincipal,
+79 -1
View File
@@ -7,6 +7,8 @@ import {
DASHBOARD_PLATFORM_PAGES,
dashboardPath,
parseDashboardPath,
DISCOVER_VIEWS,
SETTINGS_SECTIONS,
} from "../src/dashboard-routes.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
@@ -34,7 +36,8 @@ describe("parseDashboardPath", () => {
});
it("never reads a platform page as a module page", () => {
for (const pathname of ["/settings", "/settings/", "/account", "/account/appearance"]) {
expect(parseDashboardPath("/settings")).toEqual({ kind: "settings" });
for (const pathname of ["/account", "/account/appearance"]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
@@ -49,7 +52,10 @@ describe("parseDashboardPath", () => {
"/signage/timelines",
"/signage/timelines/abc",
"/%E0%A4%A",
// Deeper Settings pages are reserved for later (ADR 0061).
"/settings/displays/add",
"/settings/signage/timelines",
"/settings//signage",
"signage",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
@@ -57,6 +63,78 @@ describe("parseDashboardPath", () => {
});
});
describe("Settings routes (ADR 0061)", () => {
it("names exactly the platform's own Settings sections, frozen", () => {
expect(Object.isFrozen(SETTINGS_SECTIONS)).toBe(true);
expect(SETTINGS_SECTIONS).toEqual(["general", "branding", "displays", "discover", "system"]);
expect(Object.isFrozen(DISCOVER_VIEWS)).toBe(true);
expect(DISCOVER_VIEWS).toEqual(["browse", "installed", "updates"]);
});
it("has a landing page of its own, tolerating one trailing slash", () => {
expect(parseDashboardPath("/settings")).toEqual({ kind: "settings" });
expect(parseDashboardPath("/settings/")).toEqual({ kind: "settings" });
});
it("reads each platform section, and nothing else, as a platform section", () => {
for (const section of ["general", "branding", "displays", "system"] as const) {
expect(parseDashboardPath(`/settings/${section}`), section).toEqual({
kind: "settings-section",
section,
});
}
});
it("reads Discover's three views, entering Discover at Browse, its one canonical address", () => {
for (const view of DISCOVER_VIEWS) {
expect(parseDashboardPath(`/settings/discover/${view}`), view).toEqual({
kind: "discover",
view,
});
}
// `/settings/discover` means Browse, and the shell respells it `/settings/discover/browse` in
// place, so there is one meaningful Browse address rather than two.
expect(parseDashboardPath("/settings/discover")).toEqual({ kind: "discover", view: "browse" });
expect(dashboardPath({ kind: "discover", view: "browse" })).toBe("/settings/discover/browse");
for (const pathname of [
"/settings/discover/catalog",
"/settings/discover/browse/signage",
"/settings/discover//",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
it("reads every other name as a module's settings — never under /settings/modules/", () => {
expect(parseDashboardPath("/settings/signage")).toEqual({
kind: "module-settings",
moduleId: "signage",
});
expect(parseDashboardPath("/settings/sign%61ge/")).toEqual({
kind: "module-settings",
moduleId: "signage",
});
expect(parseDashboardPath("/settings/modules/signage")).toEqual({ kind: "not-found" });
// `modules` is no longer a platform section, so it is simply a module id like any other.
expect(parseDashboardPath("/settings/modules")).toEqual({
kind: "module-settings",
moduleId: "modules",
});
});
it("spells each Settings destination canonically and round-trips it", () => {
for (const [route, path] of [
[{ kind: "settings" }, "/settings"],
[{ kind: "settings-section", section: "displays" }, "/settings/displays"],
[{ kind: "discover", view: "installed" }, "/settings/discover/installed"],
[{ kind: "module-settings", moduleId: "signage" }, "/settings/signage"],
] as const) {
expect(dashboardPath(route)).toBe(path);
expect(parseDashboardPath(path)).toEqual(route);
}
});
});
describe("dashboardPath", () => {
it("spells each route canonically, with no implementation prefix", () => {
expect(dashboardPath({ kind: "overview" })).toBe("/");
+2
View File
@@ -482,6 +482,8 @@ describe("Ekklesia shell — browser authentication", () => {
"/api/auth/sign-in/email",
"/api/v1/principal",
"/api/v1/dashboard/navigation",
// Which Settings destinations the person may see, asked alongside the navigation (ADR 0061).
"/api/v1/dashboard/settings",
// The host-owned device panel loads with the rest of the shell for an administrator.
"/api/v1/devices",
]);
+56 -1
View File
@@ -2,9 +2,11 @@ import { describe, expect, it, vi } from "vitest";
import {
bootstrapHost,
type HostModuleUiDescriptor,
HostProtocolError,
loadDashboardNavigation,
loadHostPrincipal,
parseDashboardNavigation,
parseDashboardSettings,
parseHostPrincipal,
resolveApiOrigin,
} from "../src/host-protocol.js";
@@ -293,9 +295,14 @@ describe("host protocol — Dashboard navigation", () => {
});
describe("host protocol — bootstrap", () => {
it("resolves the principal and then the Dashboard navigation", async () => {
it("resolves the principal and then the Dashboard navigation and Settings together", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
"/api/v1/dashboard/settings": () =>
jsonResponse({
modules: [{ available: true, id: "example", name: "Example" }],
sections: ["displays"],
}),
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
});
@@ -304,9 +311,57 @@ describe("host protocol — bootstrap", () => {
expect(calls.map((call) => call.url)).toEqual([
`${apiBaseUrl}/api/v1/principal`,
`${apiBaseUrl}/api/v1/dashboard/navigation`,
`${apiBaseUrl}/api/v1/dashboard/settings`,
]);
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" });
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
expect(bootstrap.settings).toEqual({
modules: [{ available: true, id: "example", name: "Example" }],
sections: ["displays"],
});
});
it("keeps the Dashboard working when Settings cannot be loaded", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const { implementation } = stubFetch({
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
"/api/v1/dashboard/settings": () => jsonResponse({ code: "UNAVAILABLE" }, 503),
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
});
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
expect(bootstrap.settings).toBe("unavailable");
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
consoleError.mockRestore();
});
it("parses the Settings view strictly", () => {
expect(parseDashboardSettings({ modules: [], sections: ["general", "system"] })).toEqual({
modules: [],
sections: ["general", "system"],
});
for (const malformed of [
null,
{ sections: [] },
{ modules: [], sections: ["billing"] },
{ modules: [], sections: ["general", "general"] },
{ extra: 1, modules: [], sections: [] },
{ modules: [{ available: "yes", id: "a", name: "A" }], sections: [] },
{ modules: [{ available: true, id: "", name: "A" }], sections: [] },
{ modules: [{ available: true, id: "a", name: " " }], sections: [] },
{
modules: [
{ available: true, id: "a", name: "A" },
{ available: false, id: "a", name: "B" },
],
sections: [],
},
]) {
expect(() => parseDashboardSettings(malformed), JSON.stringify(malformed)).toThrow(
HostProtocolError,
);
}
});
it("never requests navigation for an anonymous viewer", async () => {
+5 -2
View File
@@ -196,7 +196,7 @@ describe("the Dashboard opens on the page its URL names", () => {
});
it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => {
for (const path of ["/settings/displays", "/calendar/events", "/account"]) {
for (const path of ["/settings/displays/add", "/calendar/events", "/account"]) {
await act(async () => {
root.unmount();
});
@@ -216,7 +216,10 @@ describe("the Dashboard opens on the page its URL names", () => {
// one could not turn `/settings` into a module page.
const { imported } = await render("/settings");
expect(query("host-not-found")).not.toBeNull();
// `/settings` is the Settings area (ADR 0061) — here its view could not be loaded, since this
// stub answers nothing for it — and never a module page.
expect(query("settings-load-failed")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(imported).toEqual([]);
expect(query("select-signage")?.getAttribute("href")).toBe("/signage");
expect(container.innerHTML).not.toContain("/modules/");
+7 -1
View File
@@ -9,6 +9,12 @@ describe("parseReturnTarget", () => {
expect(parseReturnTarget("/")).toEqual({ kind: "overview" });
expect(parseReturnTarget("/signage")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseReturnTarget("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
// Settings pages are Dashboard pages too (ADR 0061).
expect(parseReturnTarget("/settings")).toEqual({ kind: "settings" });
expect(parseReturnTarget("/settings/signage")).toEqual({
kind: "module-settings",
moduleId: "signage",
});
});
it("refuses anything that is not a string, or is empty or enormous", () => {
@@ -54,7 +60,7 @@ describe("parseReturnTarget", () => {
"/signage#top",
"/api/v1/principal",
"/display/signage",
"/settings",
"/settings/displays/add",
"/signage/timelines",
]) {
expect(parseReturnTarget(value), value).toBeUndefined();
@@ -0,0 +1,437 @@
import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
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 { createMemorySidebarPreference } from "../src/browser/sidebar-preference.js";
import type { DashboardSettings } from "../src/host-protocol.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
/**
* The Settings shell (ADR 0061): its landing page, the platform sections and Discover, each
* module's settings, and which of them the server said this person may see.
*/
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) {
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,
};
}
const NAVIGATION = {
groups: [],
ungrouped: [entry("signage", "Signage"), entry("calendar", "Calendar")],
};
/** An administrator who may see everything: every platform section, two modules' settings, and a
* disabled module (`archive`) whose settings exist but are not navigable. Calendar declares no
* settings, so it is a known module with no settings page. */
const ADMINISTRATOR: DashboardSettings = {
modules: [
{ available: false, id: "archive", name: "Archive" },
{ available: true, id: "signage", name: "Signage" },
],
sections: ["general", "branding", "displays", "discover", "system"],
};
interface Rendered {
readonly history: MemoryHostHistory;
readonly calls: string[];
readonly titles: string[];
}
async function render(
path: string,
options: {
readonly settings?: DashboardSettings | "fails";
readonly compact?: boolean;
readonly history?: MemoryHostHistory;
} = {},
): Promise<Rendered> {
const history = options.history ?? createMemoryHostHistory(path);
const calls: string[] = [];
const titles: string[] = [];
let settingsAttempts = 0;
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
calls.push(new URL(url).pathname);
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")) {
settingsAttempts += 1;
const settings = options.settings ?? ADMINISTRATOR;
return settings === "fails" && settingsAttempts === 1
? Response.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, { status: 503 })
: Response.json(settings === "fails" ? ADMINISTRATOR : 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 () => ({
default: {
abi: MODULE_UI_ABI_VERSION,
id: "signage",
mount(element: Element, context: ModuleUiHostContext) {
element.textContent = `${context.moduleId} mounted`;
},
version: "0.1.0",
},
})}
setDocumentTitle={(title) => titles.push(title)}
sidebarPreference={createMemorySidebarPreference()}
viewport={createMemoryHostViewport(options.compact ?? false)}
/>,
);
});
return { calls, history, 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): Promise<void> {
await act(async () => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, cancelable: true }));
});
}
const current = () =>
[...container.querySelectorAll('nav [aria-current="page"]')].map(
(element) => (element as HTMLElement).dataset.testid,
);
describe("the Settings landing page", () => {
it("is a real page, not a redirect, listing what this person may open", async () => {
const { history, titles } = await render("/settings");
expect(history.entries).toEqual(["/settings"]);
expect(query("settings-home")?.querySelector("h1")?.textContent).toBe("Settings");
expect(
[...container.querySelectorAll('[data-testid^="settings-home-"]')].map((link) =>
link.getAttribute("href"),
),
).toEqual([
"/settings/general",
"/settings/branding",
"/settings/displays",
"/settings/discover/browse",
"/settings/system",
"/settings/signage",
]);
// A disabled module's settings are not offered.
expect(query("settings-home-module-archive")).toBeNull();
// Each group is a section of the page, below its one h1.
expect(
[...container.querySelectorAll("main h2")].map((heading) => heading.textContent),
).toEqual(["Platform", "Modules"]);
expect(titles.at(-1)).toBe("Settings");
expect(current()).toEqual(["select-settings"]);
});
it("says plainly when there is nothing for this person to change", async () => {
await render("/settings", { settings: { modules: [], sections: [] } });
expect(query("settings-home")?.textContent).toContain(
"There are no settings for you to change",
);
});
});
describe("the platform sections", () => {
it("gives each a page of its own, with Back to Settings and an honest placeholder", async () => {
for (const section of ["general", "branding", "displays", "system"] as const) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
const { titles } = await render(`/settings/${section}`);
const page = required(`settings-section-${section}`);
expect(page.querySelector("h1")?.textContent, section).toBeTruthy();
expect(page.textContent, section).toContain("Not available yet");
// No fake controls.
expect(page.querySelector("input, select, textarea, form"), section).toBeNull();
const back = page.querySelector(".ek-page-header__back");
expect(back?.getAttribute("href"), section).toBe("/settings");
expect(back?.textContent, section).toContain("Back to Settings");
expect(current(), section).toEqual([`select-settings-${section}`]);
expect(titles.at(-1), section).toMatch(/ settings$/u);
}
});
it("enters Discover at Browse, its one canonical address, without adding a history entry", async () => {
const { history, titles } = await render("/settings/discover");
expect(history.entries).toEqual(["/settings/discover/browse"]);
expect(query("settings-discover-browse")?.querySelector("h1")?.textContent).toBe(
"Discover modules",
);
expect(query("settings-discover-browse")?.textContent).toContain(
"Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses.",
);
expect(titles.at(-1)).toBe("Discover modules");
expect(current()).toEqual(["select-discover-browse"]);
expect(query("select-settings-discover")?.getAttribute("href")).toBe(
"/settings/discover/browse",
);
});
it("has Browse, Installed, and Updates beneath Discover, each speaking of modules", async () => {
const { history } = await render("/settings/discover/browse");
const views = [...required("discover-subnavigation").querySelectorAll("a")];
expect(views.map((view) => view.textContent)).toEqual(["Browse", "Installed", "Updates"]);
expect(views.map((view) => view.getAttribute("href"))).toEqual([
"/settings/discover/browse",
"/settings/discover/installed",
"/settings/discover/updates",
]);
await click(required("select-discover-installed"));
expect(query("settings-discover-installed")?.querySelector("h1")?.textContent).toBe(
"Installed modules",
);
await click(required("select-discover-updates"));
expect(query("settings-discover-updates")?.querySelector("h1")?.textContent).toBe(
"Module updates",
);
expect(history.entries).toEqual([
"/settings/discover/browse",
"/settings/discover/installed",
"/settings/discover/updates",
]);
});
it("says a section is unavailable to someone who may not see it, rather than showing it", async () => {
await render("/settings/discover/installed", {
settings: { modules: [], sections: ["displays"] },
});
expect(query("settings-no-access")).not.toBeNull();
expect(query("settings-discover-installed")).toBeNull();
expect(query("select-settings-discover")).toBeNull();
});
});
describe("module settings", () => {
it("opens a running module's settings at /settings/<module-id>, as a placeholder", async () => {
const { titles } = await render("/settings/signage");
const page = required("module-settings");
expect(page.querySelector("h1")?.textContent).toBe("Signage");
expect(page.textContent).toContain("Not available yet");
expect(page.querySelector("input, select, textarea, form")).toBeNull();
expect(titles.at(-1)).toBe("Signage settings");
expect(current()).toEqual(["select-module-settings-signage"]);
});
it("says a disabled module's settings are turned off, rather than redirecting", async () => {
const { history } = await render("/settings/archive");
expect(query("settings-module-unavailable")?.textContent).toContain(
"This module is turned off",
);
expect(history.entries).toEqual(["/settings/archive"]);
expect(query("select-module-settings-archive")).toBeNull();
});
it("says a known module's settings aren't available when there are none for this person", async () => {
await render("/settings/calendar");
expect(query("settings-no-access")).not.toBeNull();
});
it("is not found for a name that is no module", async () => {
for (const path of ["/settings/definitely-not-a-module", "/settings/modules"]) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render(path);
expect(query("host-not-found"), path).not.toBeNull();
}
});
});
describe("Settings in the navigation", () => {
it("is one item after the modules, expanding into Platform and Modules only inside Settings", async () => {
const { history } = await render("/signage");
const settingsLink = required("select-settings");
expect(settingsLink.getAttribute("href")).toBe("/settings");
expect(query("settings-subnavigation")).toBeNull();
const links = [...container.querySelectorAll("nav a")].map((link) => link.textContent);
expect(links).toEqual(["Overview", "Signage", "Calendar", "Settings"]);
await click(settingsLink);
expect(history.entries).toEqual(["/signage", "/settings"]);
const sub = required("settings-subnavigation");
expect([...sub.querySelectorAll("h3")].map((heading) => heading.textContent)).toEqual([
"Platform",
"Modules",
]);
expect([...sub.querySelectorAll("a")].map((link) => link.textContent)).toEqual([
"General",
"Branding",
"Displays",
"Discover",
"System",
"Signage",
]);
// The rest of the navigation stays, so leaving Settings is one click.
await click(required("select-signage"));
expect(query("settings-subnavigation")).toBeNull();
expect(history.entries).toEqual(["/signage", "/settings", "/signage"]);
});
it("shows only the sections and module settings this person may see", async () => {
await render("/settings", {
settings: {
modules: [{ available: true, id: "signage", name: "Signage" }],
sections: ["displays"],
},
});
const sub = required("settings-subnavigation");
expect([...sub.querySelectorAll("a")].map((link) => link.textContent)).toEqual([
"Displays",
"Signage",
]);
});
it("is not offered at all to someone with nothing to change", async () => {
await render("/", { settings: { modules: [], sections: [] } });
expect(query("select-settings")).toBeNull();
});
it("is withheld when Settings cannot be loaded, whose pages say so and retry", async () => {
const { calls } = await render("/settings", { settings: "fails" });
expect(query("select-settings")).toBeNull();
expect(query("settings-load-failed")).not.toBeNull();
// The rest of the Dashboard still works.
expect(query("select-signage")).not.toBeNull();
const retry = [...required("settings-load-failed").querySelectorAll("button")].find(
(button) => button.textContent === "Retry",
) as HTMLButtonElement;
await act(async () => {
retry.click();
});
expect(query("settings-home")).not.toBeNull();
expect(calls.filter((path) => path === "/api/v1/dashboard/settings")).toHaveLength(2);
});
it("keeps the exact Settings page across a refresh, and Back and Forward walk it", async () => {
const history = createMemoryHostHistory("/settings");
await render("/settings", { history });
await click(required("select-settings-displays"));
await click(required("select-module-settings-signage"));
await act(async () => {
history.go(-1);
});
expect(query("settings-section-displays")).not.toBeNull();
await act(async () => {
history.go(1);
});
expect(query("module-settings")).not.toBeNull();
// A refresh is the same address, rendered from scratch.
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render("/settings/signage", { history: createMemoryHostHistory(history.pathname()) });
expect(query("module-settings")).not.toBeNull();
expect(current()).toEqual(["select-module-settings-signage"]);
});
it("moves focus to the page when moving between two Settings pages", async () => {
await render("/settings/general");
const main = required("host-main-region");
await click(required("select-settings-branding"));
expect(document.activeElement).toBe(main);
expect(main.getAttribute("aria-label")).toBe("Branding settings");
});
it("uses the same hierarchy in the phone drawer, which closes once a page is chosen", async () => {
const { history } = await render("/settings", { compact: true });
await click(required("mobile-navigation-trigger"));
const drawer = required("navigation-drawer");
expect(
[...drawer.querySelectorAll('[data-testid="settings-subnavigation"] a')].map(
(link) => link.textContent,
),
).toEqual(["General", "Branding", "Displays", "Discover", "System", "Signage"]);
for (const link of drawer.querySelectorAll('[data-testid="settings-subnavigation"] a')) {
expect((link as HTMLElement).style.minHeight).toBe("var(--ekklesia-control-height-lg)");
}
await click(required("select-settings-system"));
expect(query("navigation-drawer")).toBeNull();
expect(history.entries).toEqual(["/settings", "/settings/system"]);
expect(query("settings-section-system")).not.toBeNull();
});
});