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
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:
co-authored by
Claude Opus 5.5
parent
34692ac05f
commit
89ef4b5ff6
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")};
|
||||
|
||||
Reference in New Issue
Block a user