feat: add Dashboard navigation as presentation configuration
Phase 6D pass 1. The shell's navigation was a flat list of module ids, which is not a usable experience — and the obvious fix carries an obvious trap. Letting a module declare its folder, or modelling "Media" as something Livestream and Video belong to, turns a rendering preference into architecture: a folder becomes an owner, membership starts to look like a dependency, and the flat module graph acquires a hierarchy nobody decided on. So the rule comes first: a Dashboard group is a label on a rendering bucket. No manifest, no version, no artifact, no API, no lifecycle; nothing depends on it. Livestream depends on Video because it calls Video's functionality, and that orders startup. Livestream is shown under Media, and that orders nothing. No parent module, child module, owning module, or group-derived dependency appears anywhere in the contracts. No manifest change and no version 5. `manifest.name` has been required since version 1 and already is the module's product identity, so the shell renders "Livestream" instead of `livestream` with nothing added. `suggestedGroup` is deliberately deferred rather than shipped: it would force a hard manifest-version cutover for a hint, and its only correct semantics needs initialization logic that risks a suggestion overwriting an administrator's decision. Defaulting and ownership are different things. Two Core tables (migration 0003) hold groups and placements — server-side, because a church configures its Dashboard rather than one browser; platform-global, because no organization model exists to scope them to. `group_id` is `on delete set null`, so deleting a group ungroups its modules and a dangling reference is impossible. `module_id` carries no foreign key, because modules are packages rather than rows, so a placement for an uninstalled module is retained and restores its position on reinstall. `resolveDashboardNavigation` is a pure function of configuration plus the mountable set: an unplaced module is ungrouped and visible with no initialization write, an absent module renders nothing, empty groups are kept, and ordering is total with the module id as final tie-break. `GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of the host-neutral module UI inventory rather than merged into it — each generic descriptor is embedded untouched, so a standalone host never inherits Dashboard semantics. Reading needs the permission the inventory already needs; organizing needs the new platform permission `core.dashboard.navigation.manage` through a narrow, idempotent, closed-validation mutation contract. No editor UI. The flatness rule is executable: a test discovers real fixtures with a genuine dependency, arranges them four ways including both in one folder and the dependency inverted in presentation, and asserts discovery order, dependency declarations, and the module set stay byte-identical. The end-to-end test organizes a real installation through the real API, mounts a module from inside a group, moves it, and asserts the navigation entry is byte-identical across the move and still mounts the same artifact. The shell's client for the generic inventory became dead code and was removed rather than kept speculatively; the endpoint itself is unchanged. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ac5b2b7f6c
commit
bd34c9c714
@@ -0,0 +1,156 @@
|
||||
import type { DashboardConfiguration } from "./store.js";
|
||||
|
||||
/**
|
||||
* One module as the Dashboard needs to present it: its identity, its administrator-visible name,
|
||||
* and nothing about where it lives on disk.
|
||||
*
|
||||
* `name` is the module's own `manifest.name` — its product identity, which every manifest has
|
||||
* already required since version 1. The Dashboard renders it rather than the module id, and a module
|
||||
* declaring it is *not* the module declaring its navigation: a name answers "what is this product
|
||||
* called", while a group answers "where does this church want it", and only the second is
|
||||
* configuration an administrator owns.
|
||||
*/
|
||||
export interface DashboardNavigationModule {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface DashboardNavigationGroupView<Module extends DashboardNavigationModule> {
|
||||
/** The stable key, unchanged by a rename. Callers use it to address the group, never to display. */
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly order: number;
|
||||
readonly modules: readonly Module[];
|
||||
}
|
||||
|
||||
export interface DashboardNavigationView<Module extends DashboardNavigationModule> {
|
||||
readonly groups: readonly DashboardNavigationGroupView<Module>[];
|
||||
/**
|
||||
* Modules that belong to no group: those an administrator deliberately placed outside every group,
|
||||
* and those never configured at all — a newly installed module, most importantly. Both render
|
||||
* here, which is what guarantees a new module is visible the moment it is discovered, with no
|
||||
* initialization write, no manual database edit, and no migration.
|
||||
*/
|
||||
readonly ungrouped: readonly Module[];
|
||||
}
|
||||
|
||||
/** Placed modules first, in the administrator's order; never-configured modules after them, by id.
|
||||
* A newcomer therefore appears without interrupting an ordering someone chose, and the module id is
|
||||
* always the final tie-break, so two modules sharing a `sortOrder` still order identically on every
|
||||
* request and in every process. */
|
||||
interface SortKey {
|
||||
readonly configured: 0 | 1;
|
||||
readonly order: number;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
function compareSortKeys(left: SortKey, right: SortKey): number {
|
||||
if (left.configured !== right.configured) {
|
||||
return left.configured - right.configured;
|
||||
}
|
||||
if (left.order !== right.order) {
|
||||
return left.order - right.order;
|
||||
}
|
||||
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves stored Dashboard configuration against the modules that are actually mountable right now.
|
||||
*
|
||||
* Pure and synchronous: no database, no filesystem, no clock, no module code. It is given the stored
|
||||
* arrangement and the current module set and returns what to render, so every rule below is
|
||||
* directly testable without PostgreSQL.
|
||||
*
|
||||
* ## The rules, exactly
|
||||
*
|
||||
* - **A module with no placement is ungrouped and visible.** Nothing is written to make that true.
|
||||
* Installing a module is enough to see it; an administrator moves it afterwards if they want to.
|
||||
* - **A stored placement for a module that is not installed renders nothing, and is retained.** The
|
||||
* store keeps the row, so uninstalling and reinstalling a module restores its position rather than
|
||||
* silently resetting it. A dead navigation entry never appears.
|
||||
* - **A placement naming a group that no longer exists cannot occur** — the schema's
|
||||
* `on delete set null` makes the module ungrouped when its group is deleted — but if one somehow
|
||||
* did, it is treated as ungrouped rather than dropped, so no module can be made invisible by a
|
||||
* configuration inconsistency.
|
||||
* - **Empty groups are kept.** A group an administrator created and has not filled yet, or whose
|
||||
* only module is temporarily uninstalled, still renders; hiding it would look like the group had
|
||||
* been deleted.
|
||||
* - **Ordering is total.** Groups by `(order, id)`; modules by `(configured, order, id)`. Colliding
|
||||
* order values never produce a nondeterministic result.
|
||||
*
|
||||
* ## What this function structurally cannot do
|
||||
*
|
||||
* It never reads a manifest, a dependency, a capability, an entrypoint, or a permission, and it
|
||||
* returns no edge between two modules. Two modules sharing a group are exactly as related as two
|
||||
* modules that do not: grouping is a rendering decision computed here, downstream of everything that
|
||||
* decides what a module *is* (ADR 0027). Nothing upstream — discovery, dependency ordering, runtime
|
||||
* startup, authorization — ever calls this.
|
||||
*/
|
||||
export function resolveDashboardNavigation<Module extends DashboardNavigationModule>(
|
||||
configuration: DashboardConfiguration,
|
||||
mountableModules: readonly Module[],
|
||||
): DashboardNavigationView<Module> {
|
||||
const placementsByModuleId = new Map(
|
||||
configuration.placements.map((placement) => [placement.moduleId, placement] as const),
|
||||
);
|
||||
const groupIds = new Set(configuration.groups.map((group) => group.id));
|
||||
|
||||
const modulesByGroupId = new Map<string, Array<{ key: SortKey; module: Module }>>();
|
||||
const ungrouped: Array<{ key: SortKey; module: Module }> = [];
|
||||
|
||||
for (const module of mountableModules) {
|
||||
const placement = placementsByModuleId.get(module.id);
|
||||
const key: SortKey = {
|
||||
configured: placement ? 0 : 1,
|
||||
id: module.id,
|
||||
order: placement?.sortOrder ?? 0,
|
||||
};
|
||||
const groupId =
|
||||
placement?.groupId !== undefined &&
|
||||
placement.groupId !== null &&
|
||||
groupIds.has(placement.groupId)
|
||||
? placement.groupId
|
||||
: undefined;
|
||||
if (groupId === undefined) {
|
||||
ungrouped.push({ key, module });
|
||||
continue;
|
||||
}
|
||||
const bucket = modulesByGroupId.get(groupId);
|
||||
if (bucket) {
|
||||
bucket.push({ key, module });
|
||||
} else {
|
||||
modulesByGroupId.set(groupId, [{ key, module }]);
|
||||
}
|
||||
}
|
||||
|
||||
const sortModules = (entries: Array<{ key: SortKey; module: Module }>): readonly Module[] =>
|
||||
Object.freeze(
|
||||
[...entries]
|
||||
.sort((left, right) => compareSortKeys(left.key, right.key))
|
||||
.map((entry) => entry.module),
|
||||
);
|
||||
|
||||
const groups = [...configuration.groups]
|
||||
.sort((left, right) =>
|
||||
left.sortOrder !== right.sortOrder
|
||||
? left.sortOrder - right.sortOrder
|
||||
: left.id < right.id
|
||||
? -1
|
||||
: left.id > right.id
|
||||
? 1
|
||||
: 0,
|
||||
)
|
||||
.map((group) =>
|
||||
Object.freeze({
|
||||
id: group.id,
|
||||
label: group.label,
|
||||
modules: sortModules(modulesByGroupId.get(group.id) ?? []),
|
||||
order: group.sortOrder,
|
||||
}),
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
groups: Object.freeze(groups),
|
||||
ungrouped: sortModules(ungrouped),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user