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