Add governed View surface runtime

This commit is contained in:
2026-07-28 21:04:54 +02:00
parent 13bc3d3b4e
commit ce9ef8d88f
24 changed files with 1215 additions and 39 deletions

View File

@@ -1,6 +1,8 @@
import { createContext, type ReactNode, useContext } from "react";
import type { PlatformWebModule } from "../types";
import { moduleInstalled, uiCapabilities, uiCapability } from "./modules";
import { useEffectiveView, useViewSurfaces } from "./ViewContext";
import { isViewSurfaceVisible, moduleViewSurfaceId } from "./views";
const PlatformModulesContext = createContext<PlatformWebModule[]>([]);
@@ -17,9 +19,22 @@ export function usePlatformModuleInstalled(moduleId: string): boolean {
}
export function usePlatformUiCapability<T = unknown>(capabilityName: string): T | null {
return uiCapability<T>(capabilityName, usePlatformModules());
return uiCapability<T>(capabilityName, useViewVisibleModules());
}
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
return uiCapabilities<T>(capabilityName, usePlatformModules());
return uiCapabilities<T>(capabilityName, useViewVisibleModules());
}
function useViewVisibleModules(): PlatformWebModule[] {
const modules = usePlatformModules();
const projection = useEffectiveView();
const surfaces = useViewSurfaces();
return modules.filter((module) =>
isViewSurfaceVisible(
projection,
moduleViewSurfaceId(module.id),
surfaces
)
);
}

View File

@@ -0,0 +1,73 @@
import { createContext, type ReactNode, useContext, useMemo } from "react";
import type {
EffectiveViewProjection,
PlatformViewSurface,
PlatformWebModule
} from "../types";
import {
isViewSurfaceVisible,
viewSurfaceCatalogueForModules
} from "./views";
type ViewContextValue = {
projection: EffectiveViewProjection | null;
surfaces: PlatformViewSurface[];
isVisible: (surfaceId: string | null | undefined) => boolean;
};
const ViewContext = createContext<ViewContextValue>({
projection: null,
surfaces: [],
isVisible: () => true
});
export function PlatformViewProvider({
modules,
projection,
children
}: {
modules: PlatformWebModule[];
projection: EffectiveViewProjection | null;
children: ReactNode;
}) {
const surfaces = useMemo(
() => viewSurfaceCatalogueForModules(modules),
[modules]
);
const value = useMemo<ViewContextValue>(
() => ({
projection,
surfaces,
isVisible: (surfaceId) =>
isViewSurfaceVisible(projection, surfaceId, surfaces)
}),
[projection, surfaces]
);
return <ViewContext.Provider value={value}>{children}</ViewContext.Provider>;
}
export function useEffectiveView(): EffectiveViewProjection | null {
return useContext(ViewContext).projection;
}
export function useViewSurfaces(): PlatformViewSurface[] {
return useContext(ViewContext).surfaces;
}
export function useViewSurfaceVisible(
surfaceId: string | null | undefined
): boolean {
return useContext(ViewContext).isVisible(surfaceId);
}
export function ViewSurfaceBoundary({
surfaceId,
children,
fallback = null
}: {
surfaceId: string;
children: ReactNode;
fallback?: ReactNode;
}) {
return useViewSurfaceVisible(surfaceId) ? <>{children}</> : <>{fallback}</>;
}

View File

@@ -1,4 +1,5 @@
import type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types";
import { routeViewSurfaceId } from "./views";
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
for (const module of modules) {
@@ -30,7 +31,12 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
}
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
return modules.flatMap((module) =>
(module.routes ?? []).map((route) => ({
...route,
surfaceId: route.surfaceId ?? routeViewSurfaceId(module.id, route.path)
}))
).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
export function publicRouteContributionsForModules(modules: PlatformWebModule[]): PlatformPublicRouteContribution[] {

View File

@@ -1,6 +1,6 @@
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react";
import installedWebModules from "virtual:govoplan-installed-modules";
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformWebModule } from "../types";
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule } from "../types";
import {
hasUiCapability as hasUiCapabilityForModules,
moduleInstalled as moduleInstalledForModules,
@@ -11,6 +11,12 @@ import {
uiCapability as uiCapabilityForModules } from
"./moduleLogic";
import { hasAnyScope, hasScope } from "../utils/permissions";
import {
isViewSurfaceVisible,
navigationViewSurfaceId,
routeViewSurfaceId,
viewSurfaceCatalogueForModules
} from "./views";
export const fallbackDashboardNavItem: PlatformNavItem =
{ to: "/dashboard", label: "i18n:govoplan-core.dashboard.d87f47b4", iconName: "dashboard", order: 10 };
@@ -86,10 +92,65 @@ function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavIt
iconName: item.icon ?? null,
allOf: item.required_all,
anyOf: item.required_any,
order: item.order
order: item.order,
surfaceId: item.surface_id ?? undefined
};
}
function viewSurfaceFromMetadata(
item: NonNullable<NonNullable<PlatformModuleInfo["frontend"]>["view_surfaces"]>[number]
): PlatformViewSurface {
return {
id: item.id,
moduleId: item.module_id,
kind: item.kind,
label: item.label,
parentId: item.parent_id,
description: item.description,
order: item.order,
defaultVisible: item.default_visible,
required: item.required
};
}
function mergeViewSurfaces(
module: PlatformWebModule,
info: PlatformModuleInfo
): PlatformViewSurface[] | undefined {
const surfaces = new Map<string, PlatformViewSurface>();
for (const surface of info.frontend?.view_surfaces ?? []) {
const normalized = viewSurfaceFromMetadata(surface);
surfaces.set(normalized.id, normalized);
}
for (const surface of module.viewSurfaces ?? []) {
surfaces.set(surface.id, {
...surfaces.get(surface.id),
...surface,
required: Boolean(surfaces.get(surface.id)?.required || surface.required)
});
}
return surfaces.size ? [...surfaces.values()] : undefined;
}
function routesWithServerMetadata(
module: PlatformWebModule,
info: PlatformModuleInfo
) {
const routesByPath = new Map(
(info.frontend?.routes ?? []).map((route) => [route.path, route])
);
return (module.routes ?? []).map((route) => {
const metadata = routesByPath.get(route.path);
return {
...route,
surfaceId:
metadata?.surface_id ??
route.surfaceId ??
routeViewSurfaceId(module.id, route.path)
};
});
}
function runtimeUiCapabilitiesForModule(module: PlatformWebModule, info: PlatformModuleInfo) {
const enabledNames = new Set(info.runtime_ui_capabilities ?? []);
@@ -113,7 +174,9 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
routes: routesWithServerMetadata(module, info),
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
viewSurfaces: mergeViewSurfaces(module, info),
uiCapabilities: {
...(module.uiCapabilities ?? {}),
...runtimeUiCapabilitiesForModule(module, info)
@@ -353,26 +416,42 @@ export function publicRouteContributionsForModules(modules: PlatformWebModule[])
return publicRouteContributionsForModuleList(modules);
}
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
export function dashboardWidgetsForModules(
modules: PlatformWebModule[] = localModules,
projection?: EffectiveViewProjection | null
): DashboardWidgetContribution[] {
const catalogue = viewSurfaceCatalogueForModules(modules);
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
flatMap((capability) => capability.widgets ?? []).
filter((widget) => isViewSurfaceVisible(projection, widget.surfaceId, catalogue)).
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
return [...shellNavItemsForModules(modules), ...modules.flatMap((module) => module.navItems ?? [])].
export function navItemsForModules(
modules: PlatformWebModule[],
projection?: EffectiveViewProjection | null
): PlatformNavItem[] {
const catalogue = viewSurfaceCatalogueForModules(modules);
const moduleItems = modules.flatMap((module) =>
(module.navItems ?? []).map((item) => ({
...item,
surfaceId: item.surfaceId ?? navigationViewSurfaceId(module.id, item.to)
}))
);
return [...shellNavItemsForModules(modules), ...moduleItems].
map(resolveNavItemIcon).
filter((item) => isViewSurfaceVisible(projection, item.surfaceId, catalogue)).
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules): PlatformNavItem[] {
return navItemsForModules(modules).filter((item) => {
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): PlatformNavItem[] {
return navItemsForModules(modules, projection).filter((item) => {
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
return true;
});
}
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules): string {
return visibleNavItems(auth, modules)[0]?.to ?? "/dashboard";
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): string {
return visibleNavItems(auth, modules, projection)[0]?.to ?? "/dashboard";
}

133
webui/src/platform/views.ts Normal file
View File

@@ -0,0 +1,133 @@
import type {
EffectiveViewProjection,
PlatformRouteContribution,
PlatformViewSurface,
PlatformWebModule
} from "../types";
export const PLATFORM_VIEW_CHANGED_EVENT = "govoplan:view-changed";
export function moduleViewSurfaceId(moduleId: string): string {
return `${moduleId}.module`;
}
export function navigationViewSurfaceId(moduleId: string, path: string): string {
return `${moduleId}.nav.${surfaceSlug(path)}`;
}
export function routeViewSurfaceId(moduleId: string, path: string): string {
return `${moduleId}.route.${surfaceSlug(path)}`;
}
export function viewSurfaceCatalogueForModules(
modules: PlatformWebModule[]
): PlatformViewSurface[] {
const surfaces = new Map<string, PlatformViewSurface>();
for (const module of modules) {
const rootId = moduleViewSurfaceId(module.id);
addSurface(surfaces, {
id: rootId,
moduleId: module.id,
kind: "module",
label: module.label,
order: Math.min(
...(module.navItems?.map((item) => item.order ?? 100) ?? [100])
)
});
for (const item of module.navItems ?? []) {
addSurface(surfaces, {
id: item.surfaceId ?? navigationViewSurfaceId(module.id, item.to),
moduleId: module.id,
kind: "navigation",
label: item.label,
parentId: rootId,
order: item.order
});
}
for (const route of module.routes ?? []) {
addSurface(surfaces, {
id: route.surfaceId ?? routeViewSurfaceId(module.id, route.path),
moduleId: module.id,
kind: "route",
label: route.path,
description: route.path,
parentId: rootId,
order: route.order
});
}
for (const surface of module.viewSurfaces ?? []) {
addSurface(surfaces, {
...surface,
parentId: surface.parentId ?? rootId
});
}
}
return [...surfaces.values()].sort(
(left, right) =>
(left.order ?? 100) - (right.order ?? 100) ||
left.label.localeCompare(right.label)
);
}
export function isViewSurfaceVisible(
projection: EffectiveViewProjection | null | undefined,
surfaceId: string | null | undefined,
catalogue: PlatformViewSurface[]
): boolean {
if (!surfaceId || !projection?.activeViewId) return true;
const byId = new Map(catalogue.map((surface) => [surface.id, surface]));
const surface = byId.get(surfaceId);
if (!surface) return true;
const visible = new Set(projection.visibleSurfaceIds);
let current: PlatformViewSurface | undefined = surface;
const visited = new Set<string>();
while (current) {
if (visited.has(current.id)) return false;
visited.add(current.id);
if (!current.required && !visible.has(current.id)) return false;
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return true;
}
export function visibleRoutesForProjection(
modules: PlatformWebModule[],
projection: EffectiveViewProjection | null | undefined
): PlatformRouteContribution[] {
const catalogue = viewSurfaceCatalogueForModules(modules);
return modules.flatMap((module) =>
(module.routes ?? []).filter((route) =>
isViewSurfaceVisible(
projection,
route.surfaceId ?? routeViewSurfaceId(module.id, route.path),
catalogue
)
)
);
}
export function dispatchPlatformViewChanged(): void {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(PLATFORM_VIEW_CHANGED_EVENT));
}
}
function addSurface(
target: Map<string, PlatformViewSurface>,
surface: PlatformViewSurface
): void {
const existing = target.get(surface.id);
if (!existing) {
target.set(surface.id, surface);
return;
}
target.set(surface.id, {
...existing,
...surface,
required: Boolean(existing.required || surface.required)
});
}
function surfaceSlug(value: string): string {
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "") || "root";
}