Add governed View surface runtime
This commit is contained in:
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
73
webui/src/platform/ViewContext.tsx
Normal file
73
webui/src/platform/ViewContext.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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
133
webui/src/platform/views.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user