feat: strengthen module contracts and shared WebUI runtime

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent 53e947935a
commit 68328f3d8e
57 changed files with 4358 additions and 371 deletions

View File

@@ -25,6 +25,7 @@
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
"@govoplan/views-webui": "file:../../govoplan-views/webui",
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
@@ -37,7 +38,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": "7.18.2",
"read-excel-file": "^9.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
@@ -46,7 +47,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
}
},
"../../govoplan-access/webui": {
@@ -60,7 +61,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -76,7 +77,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -95,7 +96,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -111,7 +112,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -128,7 +129,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -140,7 +141,7 @@
},
"../../govoplan-campaign/webui": {
"name": "@govoplan/campaign-webui",
"version": "0.1.11",
"version": "0.1.12",
"dependencies": {
"read-excel-file": "9.2.0"
},
@@ -152,7 +153,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -168,7 +169,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -185,7 +186,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
@@ -202,7 +203,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
@@ -220,7 +221,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -239,7 +240,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -258,7 +259,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -279,7 +280,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -296,7 +297,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -315,7 +316,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -334,7 +335,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -352,7 +353,23 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-postbox/webui": {
"name": "@govoplan/postbox-webui",
"version": "0.1.1",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -369,7 +386,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -387,7 +404,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -404,7 +421,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
@@ -1205,6 +1222,10 @@
"resolved": "../../govoplan-policy/webui",
"link": true
},
"node_modules/@govoplan/postbox-webui": {
"resolved": "../../govoplan-postbox/webui",
"link": true
},
"node_modules/@govoplan/scheduling-webui": {
"resolved": "../../govoplan-scheduling/webui",
"link": true
@@ -2395,9 +2416,9 @@
}
},
"node_modules/react-router": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
"integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2418,13 +2439,13 @@
}
},
"node_modules/react-router-dom": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz",
"integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
"dev": true,
"license": "MIT",
"dependencies": {
"react-router": "7.18.0"
"react-router": "7.18.2"
},
"engines": {
"node": ">=20.0.0"

View File

@@ -56,6 +56,7 @@
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
"@govoplan/views-webui": "file:../../govoplan-views/webui",
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
@@ -68,7 +69,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": "7.18.2",
"read-excel-file": "^9.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
@@ -77,7 +78,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"allowScripts": {
"esbuild@0.25.12": true

View File

@@ -18,6 +18,7 @@ const packageByModule = {
organizations: "@govoplan/organizations-webui",
ops: "@govoplan/ops-webui",
policy: "@govoplan/policy-webui",
postbox: "@govoplan/postbox-webui",
scheduling: "@govoplan/scheduling-webui",
views: "@govoplan/views-webui",
workflow: "@govoplan/workflow-webui"
@@ -44,13 +45,15 @@ const cases = [
{ name: "notifications-only", modules: ["notifications"] },
{ name: "organizations-only", modules: ["organizations"] },
{ name: "idm-with-organizations", modules: ["organizations", "idm"] },
{ name: "postbox-only", modules: ["postbox"] },
{ name: "campaign-only", modules: ["campaigns"] },
{ name: "campaign-with-postbox", modules: ["campaigns", "postbox"] },
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
{ name: "scheduling-only", modules: ["scheduling"] },
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling"] }
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox"] }
];
const npmExec = process.env.npm_execpath;

View File

@@ -0,0 +1,213 @@
import { useEffect, useRef, useState } from "react";
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type SharedNotificationSummary = {
total: number;
unread: number;
pending: number;
failed: number;
show_unread_badge?: boolean;
};
export type SharedNotificationSummarySnapshot = {
summary: SharedNotificationSummary | null;
loading: boolean;
error: unknown;
};
type Listener = (snapshot: SharedNotificationSummarySnapshot) => void;
type SummaryStore = {
settings: ApiSettings;
listeners: Map<Listener, number>;
snapshot: SharedNotificationSummarySnapshot;
inFlight: Promise<void> | null;
lastFetchedAt: number;
timerId: number | null;
focusListener: () => void;
changeListener: () => void;
};
const stores = new Map<string, SummaryStore>();
const EMPTY_SNAPSHOT: SharedNotificationSummarySnapshot = {
summary: null,
loading: false,
error: null
};
function storeKey(settings: ApiSettings, scopeKey: string): string {
return [
settings.apiBaseUrl,
settings.apiKey,
settings.accessToken,
scopeKey
].join("\u0000");
}
function notify(store: SummaryStore): void {
for (const listener of store.listeners.keys()) {
listener(store.snapshot);
}
}
function minimumInterval(store: SummaryStore): number {
return Math.max(
5_000,
Math.min(...Array.from(store.listeners.values()))
);
}
function refresh(store: SummaryStore, force = false): Promise<void> {
if (store.inFlight) return store.inFlight;
if (
!force &&
store.lastFetchedAt > 0 &&
Date.now() - store.lastFetchedAt < Math.min(minimumInterval(store), 5_000)
) {
return Promise.resolve();
}
if (store.snapshot.summary === null) {
store.snapshot = { ...store.snapshot, loading: true, error: null };
notify(store);
}
const request = apiFetch<SharedNotificationSummary>(
store.settings,
"/api/v1/notifications/summary"
)
.then((summary) => {
store.lastFetchedAt = Date.now();
store.snapshot = { summary, loading: false, error: null };
notify(store);
})
.catch((error) => {
store.snapshot = {
...store.snapshot,
loading: false,
error
};
notify(store);
})
.finally(() => {
if (store.inFlight === request) store.inFlight = null;
});
store.inFlight = request;
return request;
}
function schedule(store: SummaryStore): void {
if (store.timerId !== null) window.clearInterval(store.timerId);
if (store.listeners.size === 0) {
store.timerId = null;
return;
}
store.timerId = window.setInterval(
() => void refresh(store, true),
minimumInterval(store)
);
}
function createStore(settings: ApiSettings): SummaryStore {
const store = {} as SummaryStore;
store.settings = settings;
store.listeners = new Map();
store.snapshot = { ...EMPTY_SNAPSHOT };
store.inFlight = null;
store.lastFetchedAt = 0;
store.timerId = null;
store.focusListener = () => void refresh(store);
store.changeListener = () => void refresh(store, true);
return store;
}
function subscribe(
settings: ApiSettings,
scopeKey: string,
intervalMs: number,
listener: Listener
): { store: SummaryStore; unsubscribe: () => void } {
const key = storeKey(settings, scopeKey);
let store = stores.get(key);
if (!store) {
store = createStore(settings);
stores.set(key, store);
}
const firstListener = store.listeners.size === 0;
store.listeners.set(listener, Math.max(5_000, intervalMs));
if (firstListener) {
window.addEventListener("focus", store.focusListener);
window.addEventListener(
"govoplan:notifications-changed",
store.changeListener
);
}
listener(store.snapshot);
schedule(store);
void refresh(store);
return {
store,
unsubscribe: () => {
store?.listeners.delete(listener);
if (store?.listeners.size === 0) {
schedule(store);
window.removeEventListener("focus", store.focusListener);
window.removeEventListener(
"govoplan:notifications-changed",
store.changeListener
);
stores.delete(key);
} else if (store) {
schedule(store);
}
}
};
}
export function useSharedNotificationSummary(
settings: ApiSettings,
options: {
enabled: boolean;
scopeKey: string;
intervalMs?: number;
refreshKey?: string | number;
}
): SharedNotificationSummarySnapshot {
const [snapshot, setSnapshot] =
useState<SharedNotificationSummarySnapshot>(EMPTY_SNAPSHOT);
const activeStore = useRef<SummaryStore | null>(null);
const previousRefreshKey = useRef(options.refreshKey);
useEffect(() => {
if (!options.enabled) {
activeStore.current = null;
setSnapshot(EMPTY_SNAPSHOT);
return;
}
const subscription = subscribe(
settings,
options.scopeKey,
options.intervalMs ?? 60_000,
setSnapshot
);
activeStore.current = subscription.store;
return () => {
activeStore.current = null;
subscription.unsubscribe();
};
}, [
options.enabled,
options.intervalMs,
options.scopeKey,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
useEffect(() => {
if (previousRefreshKey.current === options.refreshKey) return;
previousRefreshKey.current = options.refreshKey;
if (activeStore.current) void refresh(activeStore.current, true);
}, [options.refreshKey]);
return snapshot;
}

View File

@@ -20,10 +20,26 @@ import DismissibleAlert from "./DismissibleAlert";
import FormField from "./FormField";
import LoadingFrame from "./LoadingFrame";
import PasswordField from "./PasswordField";
import {
ReferenceMultiSelect,
combineReferenceOptionProviders,
customReferenceOption,
staticReferenceOptionProvider,
type ReferenceOption
} from "./ReferenceSelect";
import { platformModuleReferenceProvider } from "../platform/referenceProviders";
import StatusBadge from "./StatusBadge";
import TableActionGroup from "./table/TableActionGroup";
import ToggleSwitch from "./ToggleSwitch";
import { useUnsavedDraftGuard } from "./UnsavedChangesGuard";
import {
usePlatformModules,
usePlatformUiCapabilities
} from "../platform/ModuleContext";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type {
CredentialReferenceSelectorsUiCapability
} from "./ReferenceSelect";
export type CredentialEnvelopeTargetOption = {
id: string;
@@ -31,11 +47,19 @@ export type CredentialEnvelopeTargetOption = {
secondary?: string | null;
};
export type CredentialEnvelopeServerOption = {
id: string;
label: string;
secondary?: string | null;
disabled?: boolean;
};
export type CredentialEnvelopeManagerProps = {
settings: ApiSettings;
scopeType: Extract<MailProfileScope, "system" | "tenant" | "user" | "group">;
scopeId?: string | null;
targetOptions?: CredentialEnvelopeTargetOption[];
serverOptions?: CredentialEnvelopeServerOption[];
targetLabel?: string;
title?: string;
canWrite: boolean;
@@ -76,10 +100,13 @@ export default function CredentialEnvelopeManager({
scopeType,
scopeId,
targetOptions = [],
serverOptions = [],
targetLabel = "Target",
title = "Credential envelopes",
canWrite
}: CredentialEnvelopeManagerProps) {
const modules = usePlatformModules();
const { translateText } = usePlatformLanguage();
const [selectedTargetId, setSelectedTargetId] = useState(scopeId ?? targetOptions[0]?.id ?? "");
const [credentials, setCredentials] = useState<CredentialEnvelopeSummary[]>([]);
const [loading, setLoading] = useState(false);
@@ -95,6 +122,56 @@ export default function CredentialEnvelopeManager({
: selectedTargetId || null;
const scopeReady = scopeType === "system" || scopeType === "tenant" || Boolean(activeScopeId);
const draftDirty = Boolean(editing) && credentialDraftKey(draft) !== savedDraftKey;
const referenceCapabilities =
usePlatformUiCapabilities<CredentialReferenceSelectorsUiCapability>(
"core.credentialReferenceSelectors"
);
const moduleReferenceOptions = useMemo<ReferenceOption[]>(
() => modules.map((module) => ({
value: module.id,
label: translateText(module.label),
description: `${module.id} · version ${module.version}`,
kind: "module",
sourceModule: "core",
provenance: { version: module.version }
})),
[modules, translateText]
);
const serverReferenceOptions = useMemo<ReferenceOption[]>(
() => serverOptions.map((server) => ({
value: server.id,
label: server.label,
description: server.secondary ?? server.id,
kind: "server",
disabled: server.disabled,
availability: server.disabled ? "inactive" : "available",
provenance: { announcedByConsumer: true }
})),
[serverOptions]
);
const moduleReferenceProvider = useMemo(
() => platformModuleReferenceProvider(settings, moduleReferenceOptions),
[moduleReferenceOptions, settings]
);
const serverReferenceProvider = useMemo(
() =>
combineReferenceOptionProviders([
staticReferenceOptionProvider(serverReferenceOptions),
...referenceCapabilities.map((capability) =>
capability.serverProvider(settings, {
scopeType,
scopeId: activeScopeId
})
)
]),
[
activeScopeId,
referenceCapabilities,
scopeType,
serverReferenceOptions,
settings
]
);
useUnsavedDraftGuard({
dirty: draftDirty,
@@ -436,11 +513,26 @@ export default function CredentialEnvelopeManager({
<p>Empty module or server lists mean every module or server allowed by scope.</p>
</header>
<div className="form-grid two">
<FormField label="Modules" help="Comma-separated module IDs, for example mail, files, calendar, addresses.">
<input value={draft.allowedModules} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedModules: event.target.value })} />
<FormField label="Modules" help="Choose installed modules that may use this credential. Empty means every module allowed by scope.">
<ReferenceMultiSelect
values={splitValues(draft.allowedModules)}
onChange={(values) => setDraft({ ...draft, allowedModules: values.join(", ") })}
provider={moduleReferenceProvider}
aria-label="Credential modules"
placeholder="Add a module"
disabled={saving}
/>
</FormField>
<FormField label="Servers" help="Optional references such as mail:server-id, files:connection-id, calendar:source-id, or addresses:source-id.">
<input value={draft.allowedServerRefs} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedServerRefs: event.target.value })} />
<FormField label="Servers" help="Choose announced servers or enter an explicit module-qualified reference. Empty means every permitted server.">
<ReferenceMultiSelect
values={splitValues(draft.allowedServerRefs)}
onChange={(values) => setDraft({ ...draft, allowedServerRefs: values.join(", ") })}
provider={serverReferenceProvider}
createCustomOption={(value) => customReferenceOption(value, "server")}
aria-label="Credential servers"
placeholder="Add a server reference"
disabled={saving}
/>
</FormField>
<ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} onChange={(inheritToLowerScopes) => setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" />
<ToggleSwitch checked={draft.isActive} disabled={saving} onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" />

View File

@@ -0,0 +1,119 @@
import {
useEffect,
useState,
type ReactNode
} from "react";
import { Link } from "react-router-dom";
import { adminErrorMessage } from "./admin/adminUtils";
export type DashboardWidgetDataState<T> = {
data: T | null;
loading: boolean;
error: string;
};
export function useDashboardWidgetData<T>(
load: () => Promise<T>,
refreshKey: number
): DashboardWidgetDataState<T> {
const [state, setState] = useState<DashboardWidgetDataState<T>>({
data: null,
loading: true,
error: ""
});
useEffect(() => {
let active = true;
setState((current) => ({
...current,
loading: true,
error: ""
}));
void load()
.then((data) => {
if (active) setState({ data, loading: false, error: "" });
})
.catch((error: unknown) => {
if (!active) return;
setState((current) => ({
...current,
loading: false,
error: adminErrorMessage(error)
}));
});
return () => {
active = false;
};
}, [load, refreshKey]);
return state;
}
export type DashboardWidgetListItem = {
id: string;
title: ReactNode;
detail?: ReactNode;
meta?: ReactNode;
leading?: ReactNode;
trailing?: ReactNode;
to?: string;
};
export function DashboardWidgetList({
items,
emptyText
}: {
items: DashboardWidgetListItem[];
emptyText: ReactNode;
}) {
if (!items.length) {
return <p className="muted dashboard-contribution-empty">{emptyText}</p>;
}
return (
<ul className="dashboard-contribution-list">
{items.map((item) => {
const content = (
<>
{item.leading && (
<span className="dashboard-contribution-leading">
{item.leading}
</span>
)}
<span className="dashboard-contribution-copy">
<span className="dashboard-contribution-title">
{item.title}
</span>
{item.detail && (
<span className="dashboard-contribution-detail">
{item.detail}
</span>
)}
</span>
{item.meta && (
<span className="dashboard-contribution-meta">
{item.meta}
</span>
)}
{item.trailing && (
<span className="dashboard-contribution-trailing">
{item.trailing}
</span>
)}
</>
);
return (
<li key={item.id}>
{item.to ? (
<Link className="dashboard-contribution-row" to={item.to}>
{content}
</Link>
) : (
<div className="dashboard-contribution-row">{content}</div>
)}
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,472 @@
import { useEffect, useMemo, useState } from "react";
import { X } from "lucide-react";
import SearchableSelect, {
filterSearchableSelectOptions,
type SearchableSelectCreateCustomOption,
type SearchableSelectOption,
type SearchableSelectProps
} from "./SearchableSelect";
import IconButton from "./IconButton";
import type { ApiSettings } from "../types";
export type ReferenceAvailability =
| "available"
| "inactive"
| "unavailable";
export type ReferenceOption = SearchableSelectOption & {
kind?: string | null;
availability?: ReferenceAvailability;
sourceModule?: string | null;
provenance?: Record<string, unknown>;
custom?: boolean;
};
export type ReferenceProviderContext = {
limit: number;
selectedValues: readonly string[];
signal: AbortSignal;
};
export type ReferenceOptionProvider = {
search: (
query: string,
context: ReferenceProviderContext
) => Promise<readonly ReferenceOption[]>;
resolve?: (
values: readonly string[],
context: Omit<ReferenceProviderContext, "limit"> & { limit?: number }
) => Promise<readonly ReferenceOption[]>;
};
export type ConfigurationReferenceSelectorsUiCapability = {
tenantProvider: (settings: ApiSettings) => ReferenceOptionProvider;
changeRequestProvider: (
settings: ApiSettings,
options: {
purpose: string;
tenantId?: string | null;
}
) => ReferenceOptionProvider;
};
export type CredentialReferenceSelectorContext = {
scopeType: "system" | "tenant" | "user" | "group";
scopeId?: string | null;
};
export type CredentialReferenceSelectorsUiCapability = {
serverProvider: (
settings: ApiSettings,
context: CredentialReferenceSelectorContext
) => ReferenceOptionProvider;
};
export type ReferenceSelectProps = Omit<
SearchableSelectProps,
"options" | "loadOptions" | "selectedOption"
> & {
provider: ReferenceOptionProvider;
selectedOption?: ReferenceOption | null;
};
export type ReferenceMultiSelectProps = {
id?: string;
values: readonly string[];
onChange: (values: string[]) => void;
provider: ReferenceOptionProvider;
selectedOptions?: readonly ReferenceOption[];
createCustomOption?: SearchableSelectCreateCustomOption;
"aria-label"?: string;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
disabled?: boolean;
minQueryLength?: number;
searchLimit?: number;
className?: string;
};
const EMPTY_REFERENCE_OPTIONS: readonly ReferenceOption[] = [];
export default function ReferenceSelect({
provider,
value,
selectedOption,
searchLimit = 50,
...props
}: ReferenceSelectProps) {
const [resolved, setResolved] = useState<ReferenceOption | null>(
selectedOption?.value === value ? selectedOption : null
);
useEffect(() => {
if (!value) {
setResolved(null);
return;
}
setResolved(
selectedOption?.value === value
? selectedOption
: unavailableReferenceOption(value)
);
}, [selectedOption, value]);
useEffect(() => {
if (!value || selectedOption?.value === value || !provider.resolve) return;
const controller = new AbortController();
void provider
.resolve([value], {
selectedValues: [value],
signal: controller.signal,
limit: 1
})
.then((options) => {
if (!controller.signal.aborted) {
setResolved(
options.find((option) => option.value === value)
?? unavailableReferenceOption(value)
);
}
})
.catch((error: unknown) => {
if (
!controller.signal.aborted
&& !(error instanceof DOMException && error.name === "AbortError")
) {
setResolved(unavailableReferenceOption(value));
}
});
return () => controller.abort();
}, [provider, selectedOption?.value, value]);
return (
<SearchableSelect
{...props}
value={value}
searchLimit={searchLimit}
selectedOption={resolved}
loadOptions={(query, context) =>
provider.search(query, {
...context,
selectedValues: value ? [value] : []
})
}
/>
);
}
export function ReferenceMultiSelect({
id,
values,
onChange,
provider,
selectedOptions = EMPTY_REFERENCE_OPTIONS,
createCustomOption,
"aria-label": ariaLabel = "Select references",
placeholder = "Add a reference",
searchPlaceholder,
emptyText,
disabled = false,
minQueryLength,
searchLimit = 50,
className = ""
}: ReferenceMultiSelectProps) {
const normalizedValues = useMemo(
() => [...new Set(values.map((value) => value.trim()).filter(Boolean))],
[values]
);
const normalizedValuesKey = normalizedValues.join("\u0000");
const [resolvedOptions, setResolvedOptions] = useState<
readonly ReferenceOption[]
>(selectedOptions);
useEffect(() => {
setResolvedOptions(selectedOptions);
}, [selectedOptions]);
useEffect(() => {
if (!normalizedValues.length || !provider.resolve) return;
const controller = new AbortController();
void provider
.resolve(normalizedValues, {
selectedValues: normalizedValues,
signal: controller.signal,
limit: normalizedValues.length
})
.then((options) => {
if (!controller.signal.aborted) setResolvedOptions(options);
})
.catch((error: unknown) => {
if (
!controller.signal.aborted
&& !(error instanceof DOMException && error.name === "AbortError")
) {
setResolvedOptions(EMPTY_REFERENCE_OPTIONS);
}
});
return () => controller.abort();
}, [normalizedValuesKey, provider]);
const optionsByValue = useMemo(
() =>
new Map(
[...selectedOptions, ...resolvedOptions].map((option) => [
option.value,
option
])
),
[resolvedOptions, selectedOptions]
);
const selected = normalizedValues.map(
(value) =>
optionsByValue.get(value)
?? (createCustomOption?.(value) as ReferenceOption | null | undefined)
?? unavailableReferenceOption(value)
);
const rootClassName = ["reference-multi-select", className]
.filter(Boolean)
.join(" ");
function add(value: string, option: SearchableSelectOption | null) {
if (!value || normalizedValues.includes(value)) return;
if (option) {
setResolvedOptions((current) => [
...current.filter((item) => item.value !== value),
option as ReferenceOption
]);
}
onChange([...normalizedValues, value]);
}
function remove(value: string) {
onChange(normalizedValues.filter((item) => item !== value));
}
return (
<div className={rootClassName}>
{selected.length ? (
<div
className="reference-multi-select-values"
role="list"
aria-label={`${ariaLabel} selected`}
>
{selected.map((option) => (
<div
key={option.value}
className={[
"reference-multi-select-value",
option.availability
? `is-${option.availability}`
: "",
option.custom ? "is-custom" : ""
]
.filter(Boolean)
.join(" ")}
role="listitem"
>
<span className="reference-multi-select-value-copy">
<strong>{option.label}</strong>
<small>{referenceOptionDetail(option)}</small>
</span>
<IconButton
label={`Remove ${option.label}`}
icon={<X size={15} aria-hidden="true" />}
onClick={() => remove(option.value)}
disabled={disabled}
/>
</div>
))}
</div>
) : null}
<SearchableSelect
id={id}
aria-label={ariaLabel}
value=""
onChange={add}
loadOptions={async (query, context) => {
const options = await provider.search(query, {
...context,
selectedValues: normalizedValues
});
return options.filter(
(option) => !normalizedValues.includes(option.value)
);
}}
createCustomOption={(value) => {
if (normalizedValues.includes(value)) return null;
return createCustomOption?.(value) ?? null;
}}
placeholder={placeholder}
searchPlaceholder={searchPlaceholder}
emptyText={emptyText}
disabled={disabled}
minQueryLength={minQueryLength}
searchLimit={searchLimit}
/>
</div>
);
}
export function staticReferenceOptionProvider(
options: readonly ReferenceOption[]
): ReferenceOptionProvider {
const byValue = new Map(options.map((option) => [option.value, option]));
return {
async search(query, context) {
if (context.signal.aborted) throw abortError();
return filterSearchableSelectOptions(
options,
query,
context.limit
) as ReferenceOption[];
},
async resolve(values, context) {
if (context.signal.aborted) throw abortError();
return values.map(
(value) => byValue.get(value) ?? unavailableReferenceOption(value)
);
}
};
}
export function combineReferenceOptionProviders(
providers: readonly ReferenceOptionProvider[]
): ReferenceOptionProvider {
if (providers.length === 1) return providers[0];
async function collect(
operation: (
provider: ReferenceOptionProvider
) => Promise<readonly ReferenceOption[]>
): Promise<ReferenceOption[]> {
const results = await Promise.allSettled(providers.map(operation));
const successful = results.filter(
(
result
): result is PromiseFulfilledResult<readonly ReferenceOption[]> =>
result.status === "fulfilled"
);
if (!successful.length) {
const failure = results.find(
(result): result is PromiseRejectedResult =>
result.status === "rejected"
);
throw failure?.reason ?? new Error("Reference options are unavailable.");
}
return mergeReferenceOptions(
successful.flatMap((result) => [...result.value])
);
}
return {
async search(query, context) {
const options = await collect((provider) =>
provider.search(query, context)
);
return options.slice(0, context.limit);
},
async resolve(values, context) {
const options = await collect((provider) =>
provider.resolve
? provider.resolve(values, context)
: provider.search("", {
...context,
limit: context.limit ?? Math.max(values.length, 50)
})
);
const byValue = new Map(options.map((option) => [option.value, option]));
return values.map(
(value) =>
byValue.get(value)
?? unavailableReferenceOption(value)
);
}
};
}
export function wildcardReferenceOption(
value: string,
kind = "pattern"
): ReferenceOption | null {
const clean = value.trim();
if (!clean || !/[*?[\]]/.test(clean)) return null;
return {
value: clean,
label: clean,
description: "Custom wildcard pattern",
kind,
availability: "available",
custom: true,
provenance: { input: "custom_pattern" }
};
}
export function customReferenceOption(
value: string,
kind = "custom"
): ReferenceOption | null {
const clean = value.trim();
if (!clean) return null;
return {
value: clean,
label: clean,
description: "Custom reference",
kind,
availability: "available",
custom: true,
provenance: { input: "custom_reference" }
};
}
export function unavailableReferenceOption(
value: string,
label = "Unavailable reference"
): ReferenceOption {
return {
value,
label,
description: value,
availability: "unavailable",
disabled: true,
provenance: { retainedReference: true }
};
}
function mergeReferenceOptions(
options: readonly ReferenceOption[]
): ReferenceOption[] {
const merged = new Map<string, ReferenceOption>();
for (const option of options) {
const current = merged.get(option.value);
if (!current || referenceOptionPriority(option) > referenceOptionPriority(current)) {
merged.set(option.value, option);
}
}
return [...merged.values()];
}
function referenceOptionPriority(option: ReferenceOption): number {
if (option.availability === "unavailable") return 0;
if (option.availability === "inactive" || option.disabled) return 1;
return 2;
}
function referenceOptionDetail(option: ReferenceOption): string {
const state = option.custom
? option.kind === "pattern"
? "Pattern"
: "Custom"
: option.availability === "inactive"
? "Inactive"
: option.availability === "unavailable"
? "Unavailable"
: "";
return [state, option.description, option.value !== option.label ? option.value : ""]
.filter(Boolean)
.join(" · ");
}
function abortError(): DOMException {
return new DOMException("The operation was aborted.", "AbortError");
}

View File

@@ -0,0 +1,379 @@
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type FocusEvent,
type KeyboardEvent
} from "react";
import { ChevronDown, Search } from "lucide-react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
export type SearchableSelectOption = {
value: string;
label: string;
description?: string | null;
searchText?: string | null;
disabled?: boolean;
};
export type SearchableSelectLoadOptions = (
query: string,
options: { limit: number; signal: AbortSignal }
) => Promise<readonly SearchableSelectOption[]>;
export type SearchableSelectCreateCustomOption = (
value: string
) => SearchableSelectOption | null;
export type SearchableSelectProps = {
id?: string;
value: string;
onChange: (
value: string,
option: SearchableSelectOption | null
) => void;
options?: readonly SearchableSelectOption[];
loadOptions?: SearchableSelectLoadOptions;
createCustomOption?: SearchableSelectCreateCustomOption;
selectedOption?: SearchableSelectOption | null;
"aria-label"?: string;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
loadingText?: string;
errorText?: string;
disabled?: boolean;
required?: boolean;
minQueryLength?: number;
searchLimit?: number;
debounceMs?: number;
className?: string;
};
const EMPTY_OPTIONS: readonly SearchableSelectOption[] = [];
export function filterSearchableSelectOptions(
options: readonly SearchableSelectOption[],
query: string,
limit = 50
): SearchableSelectOption[] {
const needle = query.trim().toLocaleLowerCase();
const filtered = needle
? options.filter((option) =>
[
option.label,
option.description ?? "",
option.searchText ?? "",
option.value
]
.join(" ")
.toLocaleLowerCase()
.includes(needle)
)
: [...options];
return filtered.slice(0, Math.max(1, Math.floor(limit)));
}
export default function SearchableSelect({
id,
value,
onChange,
options = EMPTY_OPTIONS,
loadOptions,
createCustomOption,
selectedOption,
"aria-label": ariaLabel = "Select an option",
placeholder = "Select an option",
searchPlaceholder = "Search...",
emptyText = "No matching options.",
loadingText = "Loading...",
errorText = "Options could not be loaded.",
disabled = false,
required = false,
minQueryLength = 0,
searchLimit = 50,
debounceMs = 200,
className = ""
}: SearchableSelectProps) {
const { translateText } = usePlatformLanguage();
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
const pickerId = id || `searchable-select-${generatedId}`;
const listboxId = `${pickerId}-results`;
const statusId = `${pickerId}-status`;
const rootRef = useRef<HTMLDivElement | null>(null);
const resolvedSelected = useMemo(
() =>
selectedOption ??
options.find((option) => option.value === value) ??
null,
[options, selectedOption, value]
);
const selectedLabel = resolvedSelected?.label ?? value;
const [inputValue, setInputValue] = useState(selectedLabel);
const [results, setResults] =
useState<readonly SearchableSelectOption[]>(EMPTY_OPTIONS);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const normalizedMinLength = Math.max(0, Math.floor(minQueryLength));
const normalizedLimit = Math.max(1, Math.min(200, Math.floor(searchLimit)));
const visibleResults = useMemo(() => {
const customValue = inputValue.trim();
const customOption = customValue ? createCustomOption?.(customValue) : null;
if (
!customOption
|| results.some((option) => option.value === customOption.value)
) {
return results;
}
return [...results, customOption];
}, [createCustomOption, inputValue, results]);
useEffect(() => {
if (!open) setInputValue(selectedLabel);
}, [open, selectedLabel]);
useEffect(() => {
if (!open || disabled) {
setLoading(false);
setFailed(false);
setResults(EMPTY_OPTIONS);
return;
}
const query = inputValue.trim();
if (query.length < normalizedMinLength) {
setLoading(false);
setFailed(false);
setResults(EMPTY_OPTIONS);
return;
}
if (!loadOptions) {
setResults(filterSearchableSelectOptions(options, query, normalizedLimit));
setLoading(false);
setFailed(false);
return;
}
const controller = new AbortController();
const timer = window.setTimeout(() => {
setLoading(true);
setFailed(false);
void loadOptions(query, {
limit: normalizedLimit,
signal: controller.signal
})
.then((nextOptions) => {
if (!controller.signal.aborted) setResults(nextOptions);
})
.catch((error: unknown) => {
if (
controller.signal.aborted ||
(error instanceof DOMException && error.name === "AbortError")
) {
return;
}
setResults(EMPTY_OPTIONS);
setFailed(true);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
}, Math.max(0, Math.floor(debounceMs)));
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [
debounceMs,
disabled,
inputValue,
loadOptions,
normalizedLimit,
normalizedMinLength,
open,
options
]);
useEffect(() => {
const selectedIndex = visibleResults.findIndex(
(option) => option.value === value && !option.disabled
);
const firstEnabledIndex = visibleResults.findIndex(
(option) => !option.disabled
);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : firstEnabledIndex);
}, [value, visibleResults]);
function choose(option: SearchableSelectOption) {
if (disabled || option.disabled) return;
onChange(option.value, option);
setInputValue(option.label);
setOpen(false);
setActiveIndex(-1);
}
function moveActive(delta: 1 | -1) {
const enabledIndexes = visibleResults
.map((option, index) => ({ option, index }))
.filter(({ option }) => !option.disabled)
.map(({ index }) => index);
if (!enabledIndexes.length) {
setActiveIndex(-1);
return;
}
const currentPosition = enabledIndexes.indexOf(activeIndex);
const nextPosition =
currentPosition < 0
? delta > 0
? 0
: enabledIndexes.length - 1
: (currentPosition + delta + enabledIndexes.length) %
enabledIndexes.length;
setActiveIndex(enabledIndexes[nextPosition]);
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
moveActive(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setOpen(true);
moveActive(-1);
return;
}
if (event.key === "Enter" && open && activeIndex >= 0) {
const option = visibleResults[activeIndex];
if (option && !option.disabled) {
event.preventDefault();
choose(option);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
setInputValue(selectedLabel);
setActiveIndex(-1);
}
}
function closeOnFocusLeave(event: FocusEvent<HTMLDivElement>) {
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && rootRef.current?.contains(nextTarget)) {
return;
}
setOpen(false);
setInputValue(selectedLabel);
setActiveIndex(-1);
}
const minimumLengthMissing =
inputValue.trim().length < normalizedMinLength;
const visibleStatus = loading
? loadingText
: failed
? errorText
: minimumLengthMissing
? `Type at least ${normalizedMinLength} characters.`
: !visibleResults.length
? emptyText
: "";
const rootClassName = ["searchable-select", className]
.filter(Boolean)
.join(" ");
return (
<div
ref={rootRef}
className={rootClassName}
onBlur={closeOnFocusLeave}
>
<div className="searchable-select-control">
<Search size={17} aria-hidden="true" />
<input
id={pickerId}
type="search"
role="combobox"
value={inputValue}
disabled={disabled}
required={required && !value}
placeholder={translateText(
open ? searchPlaceholder : placeholder
)}
aria-label={translateText(ariaLabel)}
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listboxId}
aria-activedescendant={
open && activeIndex >= 0
? `${pickerId}-option-${activeIndex}`
: undefined
}
aria-describedby={statusId}
aria-busy={loading}
onFocus={() => {
if (!open) setInputValue("");
setOpen(true);
}}
onChange={(event) => {
setInputValue(event.target.value);
setOpen(true);
setFailed(false);
if (value) onChange("", null);
}}
onKeyDown={handleKeyDown}
/>
<ChevronDown size={17} aria-hidden="true" />
</div>
{open && !disabled && (
<div
id={listboxId}
className="searchable-select-results"
role="listbox"
aria-label={translateText(ariaLabel)}
>
{visibleResults.map((option, index) => (
<button
key={option.value}
id={`${pickerId}-option-${index}`}
type="button"
role="option"
aria-selected={option.value === value}
className={[
"searchable-select-option",
option.value === value ? "is-selected" : "",
index === activeIndex ? "is-active" : ""
]
.filter(Boolean)
.join(" ")}
disabled={option.disabled}
onMouseEnter={() => {
if (!option.disabled) setActiveIndex(index);
}}
onClick={() => choose(option)}
>
<strong>{option.label}</strong>
{option.description && <small>{option.description}</small>}
</button>
))}
{visibleStatus && (
<p className="searchable-select-message">
{translateText(visibleStatus)}
</p>
)}
</div>
)}
<span id={statusId} className="sr-only" aria-live="polite">
{translateText(visibleStatus)}
</span>
</div>
);
}

View File

@@ -3,6 +3,7 @@ export * from "./types";
export * from "./api/client";
export * from "./api/auth";
export * from "./api/platform";
export * from "./api/notificationSummary";
export * from "./api/adminCommon";
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "./api/mailContracts";
export type {
@@ -60,7 +61,7 @@ export { default as ColorPickerField } from "./components/ColorPickerField";
export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel";
export type { CredentialFieldsProps, CredentialPanelProps, CredentialValues } from "./components/CredentialPanel";
export { default as CredentialEnvelopeManager } from "./components/CredentialEnvelopeManager";
export type { CredentialEnvelopeManagerProps, CredentialEnvelopeTargetOption } from "./components/CredentialEnvelopeManager";
export type { CredentialEnvelopeManagerProps, CredentialEnvelopeServerOption, CredentialEnvelopeTargetOption } from "./components/CredentialEnvelopeManager";
export {
createCredentialEnvelope,
deleteCredentialEnvelope,
@@ -112,6 +113,34 @@ export { default as PolicySourcePath } from "./components/PolicySourcePath";
export type { PolicySourcePathItem, PolicySourcePathProps } from "./components/PolicySourcePath";
export { default as PolicyLockedHint } from "./components/PolicyLockedHint";
export type { PolicyLockedHintProps } from "./components/PolicyLockedHint";
export { default as SearchableSelect, filterSearchableSelectOptions } from "./components/SearchableSelect";
export type { SearchableSelectCreateCustomOption, SearchableSelectLoadOptions, SearchableSelectOption, SearchableSelectProps } from "./components/SearchableSelect";
export {
default as ReferenceSelect,
ReferenceMultiSelect,
combineReferenceOptionProviders,
customReferenceOption,
staticReferenceOptionProvider,
unavailableReferenceOption,
wildcardReferenceOption
} from "./components/ReferenceSelect";
export type {
ReferenceAvailability,
ConfigurationReferenceSelectorsUiCapability,
CredentialReferenceSelectorContext,
CredentialReferenceSelectorsUiCapability,
ReferenceMultiSelectProps,
ReferenceOption,
ReferenceOptionProvider,
ReferenceProviderContext,
ReferenceSelectProps
} from "./components/ReferenceSelect";
export {
apiReferenceOptionProvider,
platformModuleReferenceProvider
} from "./platform/referenceProviders";
export { DashboardWidgetList, useDashboardWidgetData } from "./components/DashboardWidgetContent";
export type { DashboardWidgetDataState, DashboardWidgetListItem } from "./components/DashboardWidgetContent";
export { default as SegmentedControl } from "./components/SegmentedControl";
export type { SegmentedControlOption, SegmentedControlProps, SegmentedControlSize, SegmentedControlWidth } from "./components/SegmentedControl";
export { default as SelectionList, SelectionListItem } from "./components/SelectionList";

View File

@@ -6,18 +6,13 @@ import LanguageMenu from "./LanguageMenu";
import LoginModal from "../features/auth/LoginModal";
import DismissibleAlert from "../components/DismissibleAlert";
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
import { apiFetch, isApiError } from "../api/client";
import { useSharedNotificationSummary } from "../api/notificationSummary";
import { logout, switchTenant } from "../api/auth";
import { hasAnyScope } from "../utils/permissions";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import { usePlatformModules, usePlatformUiCapability } from "../platform/ModuleContext";
import { useEffectiveView } from "../platform/ViewContext";
type NotificationSummary = {
unread: number;
show_unread_badge?: boolean;
};
type Props = {
settings: ApiSettings;
auth: AuthInfo | null;
@@ -35,7 +30,6 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
const [loginOpen, setLoginOpen] = useState(false);
const [switchingTenantId, setSwitchingTenantId] = useState<string | null>(null);
const [tenantError, setTenantError] = useState("");
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const accountRef = useRef<HTMLDivElement>(null);
const tenantRef = useRef<HTMLDivElement>(null);
const { translateText } = usePlatformLanguage();
@@ -55,7 +49,19 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
"system:tenants:suspend"]
);
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
const showContextSelectors = Boolean(
auth && ((activeTenant && showTenantControl) || ViewSelector)
);
const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications"));
const notificationSummary = useSharedNotificationSummary(settings, {
enabled: Boolean(auth && notificationsAvailable),
scopeKey: "current-session",
intervalMs: 60_000,
refreshKey: `${activeTenant?.id ?? ""}:${auth?.user?.id ?? ""}`
}).summary;
const unreadNotificationCount = notificationSummary?.show_unread_badge === false ?
0 :
Math.max(0, Number(notificationSummary?.unread) || 0);
const notificationBadgeLabel = unreadNotificationCount > 99 ? "99+" : String(unreadNotificationCount);
useEffect(() => {
@@ -72,41 +78,6 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
return () => window.removeEventListener("mousedown", onPointerDown);
}, []);
useEffect(() => {
if (!auth || !notificationsAvailable) {
setUnreadNotificationCount(0);
return;
}
let cancelled = false;
async function refreshNotificationSummary() {
try {
const summary = await apiFetch<NotificationSummary>(settings, "/api/v1/notifications/summary", { cache: "no-store" });
if (!cancelled) {
setUnreadNotificationCount(summary.show_unread_badge === false ? 0 : Math.max(0, Number(summary.unread) || 0));
}
} catch (error) {
if (!cancelled) {
setUnreadNotificationCount(0);
}
if (!isApiError(error, 401, 403, 404)) {
console.error("Failed to load notification summary", error);
}
}
}
void refreshNotificationSummary();
const intervalId = window.setInterval(refreshNotificationSummary, 60_000);
window.addEventListener("focus", refreshNotificationSummary);
window.addEventListener("govoplan:notifications-changed", refreshNotificationSummary);
return () => {
cancelled = true;
window.clearInterval(intervalId);
window.removeEventListener("focus", refreshNotificationSummary);
window.removeEventListener("govoplan:notifications-changed", refreshNotificationSummary);
};
}, [activeTenant?.id, auth?.user?.id, notificationsAvailable, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
function handleLogin(response: LoginResponse) {
onAuthChange(response, "");
}
@@ -185,8 +156,10 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
</button>
}
{auth && activeTenant && showTenantControl &&
<div className="tenant-selector" ref={tenantRef}>
{auth && showContextSelectors &&
<div className="titlebar-context-selectors">
{activeTenant && showTenantControl &&
<div className="tenant-selector" ref={tenantRef}>
<span className="tenant-label">{translateText("i18n:govoplan-core.tenant_label_prefix")}</span>
{canSwitchTenant ?
<>
@@ -218,13 +191,15 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
<strong>{activeTenant.name}</strong>
}
</div>
}
{ViewSelector &&
<ViewSelector settings={settings} auth={auth} projection={projection} />
}
</div>
}
<div className="titlebar-spacer" />
{auth && ViewSelector &&
<ViewSelector settings={settings} auth={auth} projection={projection} />
}
<LanguageMenu />
<HelpMenu auth={auth} />

View File

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

View File

@@ -0,0 +1,163 @@
import {
apiFetch,
apiPath,
type ApiQueryParams
} from "../api/client";
import { fetchPlatformModules } from "../api/platform";
import { filterSearchableSelectOptions } from "../components/SearchableSelect";
import {
unavailableReferenceOption,
type ReferenceOption,
type ReferenceOptionProvider
} from "../components/ReferenceSelect";
import type { ApiSettings } from "../types";
type ApiReferenceOptionPayload = {
value: string;
label: string;
description?: string | null;
kind?: string | null;
availability?: ReferenceOption["availability"];
disabled?: boolean;
source_module?: string | null;
provenance?: Record<string, unknown>;
};
export function apiReferenceOptionProvider(
settings: ApiSettings,
path: string,
params: ApiQueryParams = {}
): ReferenceOptionProvider {
async function load(
query: string,
selectedValues: readonly string[],
limit: number,
signal: AbortSignal
): Promise<ReferenceOption[]> {
const response = await apiFetch<{
options: ApiReferenceOptionPayload[];
provider_available: boolean;
}>(
settings,
apiPath(path, {
...params,
q: query,
selected: selectedValues,
limit
}),
{ signal }
);
return response.options.map(referenceOptionFromPayload);
}
return {
search: (query, context) =>
load(query, context.selectedValues, context.limit, context.signal),
resolve: (values, context) =>
load("", values, context.limit ?? values.length, context.signal)
};
}
export function platformModuleReferenceProvider(
settings: ApiSettings,
presentedOptions: readonly ReferenceOption[] = []
): ReferenceOptionProvider {
const presentedByValue = new Map(
presentedOptions.map((option) => [option.value, option])
);
let cataloguePromise: Promise<ReferenceOption[]> | null = null;
async function catalogue(signal: AbortSignal): Promise<ReferenceOption[]> {
cataloguePromise ??= loadPlatformModuleOptions(
settings,
presentedOptions,
presentedByValue
).catch((error: unknown) => {
cataloguePromise = null;
throw error;
});
const options = await cataloguePromise;
if (signal.aborted) throw abortError();
return options;
}
return {
async search(query, context) {
const options = await catalogue(context.signal);
return filterSearchableSelectOptions(
options,
query,
context.limit
) as ReferenceOption[];
},
async resolve(values, context) {
const options = await catalogue(context.signal);
const byValue = new Map(
[...presentedOptions, ...options].map((option) => [
option.value,
option
])
);
return values.map(
(value) =>
byValue.get(value)
?? unavailableReferenceOption(value, "Unavailable module")
);
}
};
}
async function loadPlatformModuleOptions(
settings: ApiSettings,
presentedOptions: readonly ReferenceOption[],
presentedByValue: ReadonlyMap<string, ReferenceOption>
): Promise<ReferenceOption[]> {
let modules;
try {
modules = (await fetchPlatformModules(settings)).modules;
} catch (error) {
if (presentedOptions.length) return [...presentedOptions];
throw error;
}
return modules
.filter((module) => module.enabled)
.map((module) => {
const presented = presentedByValue.get(module.id);
return {
value: module.id,
label: presented?.label ?? module.name ?? module.id,
description: presented?.description
?? `${module.id} · version ${module.version}`,
searchText: presented?.searchText
?? `${module.id} ${module.name} ${module.version}`,
kind: "module",
availability: presented?.availability ?? "available",
disabled: presented?.disabled ?? false,
sourceModule: "core",
provenance: {
version: module.version,
activeRegistry: true,
...(presented?.provenance ?? {})
}
} satisfies ReferenceOption;
});
}
function referenceOptionFromPayload(
option: ApiReferenceOptionPayload
): ReferenceOption {
return {
value: option.value,
label: option.label,
description: option.description,
kind: option.kind,
availability: option.availability,
disabled: option.disabled,
sourceModule: option.source_module,
provenance: option.provenance
};
}
function abortError(): DOMException {
return new DOMException("The operation was aborted.", "AbortError");
}

View File

@@ -1547,6 +1547,150 @@
.people-picker-search-control:focus-within > svg {
color: var(--primary);
}
.searchable-select {
position: relative;
min-width: 0;
}
.searchable-select-control {
position: relative;
display: flex;
align-items: center;
}
.searchable-select-control > svg {
position: absolute;
z-index: 1;
color: var(--muted);
pointer-events: none;
}
.searchable-select-control > svg:first-child {
left: 12px;
}
.searchable-select-control > svg:last-child {
right: 12px;
}
.searchable-select-control input {
width: 100%;
padding-right: 38px;
padding-left: 38px;
}
.searchable-select-control:focus-within > svg:first-child {
color: var(--primary);
}
.searchable-select-results {
position: absolute;
top: calc(100% + 5px);
right: 0;
left: 0;
z-index: var(--z-popover, 50);
display: grid;
max-height: 320px;
overflow: auto;
padding: 5px;
border: var(--border-line-dark);
border-radius: var(--radius-sm);
background: var(--surface);
box-shadow: var(--shadow-popover);
}
.searchable-select-option {
appearance: none;
display: grid;
gap: 2px;
width: 100%;
min-width: 0;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
text-align: left;
}
.searchable-select-option:hover:not(:disabled),
.searchable-select-option.is-active:not(:disabled) {
background: var(--hover-tint-soft);
}
.searchable-select-option.is-selected {
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
}
.searchable-select-option:focus-visible {
outline: var(--focus-outline);
outline-offset: -2px;
}
.searchable-select-option:disabled {
cursor: not-allowed;
opacity: .55;
}
.searchable-select-option strong,
.searchable-select-option small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.searchable-select-option small,
.searchable-select-message {
color: var(--muted);
font-size: 12px;
}
.searchable-select-message {
margin: 0;
padding: 10px;
}
.reference-multi-select {
display: grid;
min-width: 0;
gap: 7px;
}
.reference-multi-select-values {
display: grid;
min-width: 0;
max-height: 190px;
gap: 4px;
overflow: auto;
}
.reference-multi-select-value {
display: flex;
min-width: 0;
min-height: 38px;
align-items: center;
justify-content: space-between;
gap: 8px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
padding: 5px 6px 5px 9px;
}
.reference-multi-select-value.is-inactive,
.reference-multi-select-value.is-unavailable {
border-color: color-mix(in srgb, var(--warning) 45%, transparent);
background: var(--warning-soft);
}
.reference-multi-select-value.is-custom {
border-style: dashed;
}
.reference-multi-select-value-copy {
display: grid;
min-width: 0;
gap: 1px;
}
.reference-multi-select-value-copy strong,
.reference-multi-select-value-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reference-multi-select-value-copy strong {
color: var(--text-strong);
font-size: 13px;
}
.reference-multi-select-value-copy small {
color: var(--muted);
font-size: 11px;
}
.people-picker-results {
display: grid;
max-height: 360px;
@@ -3440,6 +3584,63 @@
gap: 8px;
min-width: 0;
}
.dashboard-contribution-list {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.dashboard-contribution-list > li + li {
border-top: var(--border-line);
}
.dashboard-contribution-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
min-height: 52px;
padding: 8px 2px;
color: inherit;
text-decoration: none;
}
a.dashboard-contribution-row:hover {
background: var(--hover-tint-soft);
}
.dashboard-contribution-leading,
.dashboard-contribution-trailing {
display: inline-flex;
align-items: center;
color: var(--muted);
}
.dashboard-contribution-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.dashboard-contribution-title,
.dashboard-contribution-detail,
.dashboard-contribution-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-contribution-title {
font-weight: 700;
}
.dashboard-contribution-detail,
.dashboard-contribution-meta {
color: var(--muted);
font-size: 12px;
}
.dashboard-contribution-empty {
margin: 0;
padding: 18px 2px;
}
.dashboard-contribution-footer {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
@media (max-width: 900px) {
.admin-details-grid,
.admin-tenant-assignment-row {

View File

@@ -27,6 +27,7 @@
.icon-rail.compact { width: 58px; }
.app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
.titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: flex; align-items: center; padding: 0 18px; gap: 36px; z-index: 100; box-shadow: var(--shadow-chrome); }
.titlebar-context-selectors { display: flex; align-items: center; min-width: 0; gap: 12px; }
.tenant-selector { height: 40px; min-width: 210px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--surface); display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: var(--shadow-xs); }
.tenant-label, .tenant-caret, .muted { color: var(--muted); }
.block { display: block; }

View File

@@ -410,11 +410,43 @@ export type DashboardWidgetSize = "small" | "medium" | "wide" | "full";
export type DashboardWidgetTone = "neutral" | "good" | "warning" | "danger" | "info";
export type DashboardWidgetConfigurationValue =
| string
| number
| boolean
| null;
export type DashboardWidgetConfiguration = Record<
string,
DashboardWidgetConfigurationValue
>;
export type DashboardWidgetConfigurationOption = {
value: string;
label: string;
};
export type DashboardWidgetConfigurationField = {
id: string;
label: string;
description?: string;
kind: "text" | "number" | "boolean" | "select";
required?: boolean;
placeholder?: string;
min?: number;
max?: number;
step?: number;
options?: DashboardWidgetConfigurationOption[];
};
export type DashboardWidgetRenderContext = PlatformRouteContext & {
modules: PlatformWebModule[];
effectiveView: EffectiveViewProjection | null;
instanceId: string;
widgetId: string;
refreshKey: number;
size: DashboardWidgetSize;
configuration: DashboardWidgetConfiguration;
};
export type DashboardWidgetContribution = {
@@ -428,6 +460,9 @@ export type DashboardWidgetContribution = {
defaultVisible?: boolean;
defaultSize?: DashboardWidgetSize;
supportedSizes?: DashboardWidgetSize[];
allowMultiple?: boolean;
defaultConfiguration?: DashboardWidgetConfiguration;
configurationFields?: DashboardWidgetConfigurationField[];
refreshIntervalMs?: number;
anyOf?: string[];
allOf?: string[];

View File

@@ -1,4 +1,5 @@
import type {
DashboardWidgetsUiCapability,
OrganizationFunctionActionContext,
OrganizationFunctionActionContribution,
PlatformWebModule
@@ -71,6 +72,51 @@ for (const testCase of cases) {
assert(uiCapability("files.fileExplorer", [access, files]) === filesCapability, "files capability should return the module-provided object");
assert(uiCapability("mail.profiles", [access, mail]) === mailCapability, "mail capability should return the module-provided object");
const configurableDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "files.recent",
surfaceId: "files.widget.recent",
title: "Recent files",
moduleId: "files",
supportedSizes: ["medium", "wide"],
defaultConfiguration: { limit: 10, includeFolders: false },
configurationFields: [
{
id: "limit",
label: "Items",
kind: "number",
min: 1,
max: 50
},
{
id: "includeFolders",
label: "Include folders",
kind: "boolean"
}
],
render: ({ configuration, effectiveView, instanceId }) =>
`${instanceId}:${effectiveView?.activeViewId ?? "full"}:${String(configuration.limit)}`
}
]
};
const dashboardProvider: PlatformWebModule = {
id: "dashboard-provider",
label: "Dashboard provider",
version: "test",
uiCapabilities: {
"dashboard.widgets": configurableDashboardWidgets
}
};
const resolvedDashboardWidgets = uiCapability<DashboardWidgetsUiCapability>(
"dashboard.widgets",
[dashboardProvider]
);
assert(
resolvedDashboardWidgets?.widgets[0].configurationFields?.length === 2,
"dashboard widget capabilities should retain provider-defined configuration"
);
const routes = routeContributionsForModules([access, files, mail, campaigns]).map((route) => route.path);
assert(routes.join(",") === "/campaigns,/files,/mail", "routes should aggregate and sort by contribution order");

View File

@@ -3,6 +3,14 @@ function assert(condition: unknown, message = "assertion failed"): void {
}
import { renderToStaticMarkup } from "react-dom/server";
import SearchableSelect, {
filterSearchableSelectOptions
} from "../src/components/SearchableSelect";
import {
ReferenceMultiSelect,
staticReferenceOptionProvider,
wildcardReferenceOption
} from "../src/components/ReferenceSelect";
import SelectionList, { SelectionListItem } from "../src/components/SelectionList";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
@@ -50,3 +58,87 @@ const nativeLabelMarkup = renderToStaticMarkup(
</SelectionList>
);
assert(nativeLabelMarkup.includes('aria-label="Already translated"'), "native accessible labels pass through unchanged");
const directoryOptions = [
{
value: "account-1",
label: "Ada Lovelace",
description: "ada@example.test"
},
{
value: "group-1",
label: "Finance",
searchText: "cost centre"
}
];
assert(
filterSearchableSelectOptions(directoryOptions, "example").length === 1,
"searchable selects match option descriptions"
);
assert(
filterSearchableSelectOptions(directoryOptions, "cost centre")[0]?.value ===
"group-1",
"searchable selects include explicit search aliases"
);
const searchableMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<SearchableSelect
id="assignment-target"
aria-label="Assignment target"
value="account-1"
options={directoryOptions}
onChange={() => undefined}
/>
</PlatformLanguageProvider>
);
assert(
searchableMarkup.includes('role="combobox"'),
"searchable selects expose combobox semantics"
);
assert(
searchableMarkup.includes('aria-controls="assignment-target-results"'),
"searchable selects associate their input and result list"
);
assert(
searchableMarkup.includes('value="Ada Lovelace"'),
"searchable selects render labels instead of technical ids"
);
const referenceMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<ReferenceMultiSelect
id="policy-references"
aria-label="Policy references"
values={["group-1", "legacy-*", "removed"]}
onChange={() => undefined}
selectedOptions={[
{
value: "group-1",
label: "Finance",
description: "Access group"
}
]}
provider={staticReferenceOptionProvider([
{
value: "group-1",
label: "Finance",
description: "Access group"
}
])}
createCustomOption={(value) => wildcardReferenceOption(value)}
/>
</PlatformLanguageProvider>
);
assert(
referenceMarkup.includes("Finance"),
"reference multi-selects render known labels"
);
assert(
referenceMarkup.includes("Pattern"),
"reference multi-selects distinguish wildcard patterns"
);
assert(
referenceMarkup.includes("Unavailable reference"),
"reference multi-selects retain unavailable values"
);

View File

@@ -35,6 +35,8 @@
"src/components/people/PeoplePicker.tsx",
"src/components/people/peoplePickerTypes.ts",
"src/components/ResourceAccessExplanation.tsx",
"src/components/ReferenceSelect.tsx",
"src/components/SearchableSelect.tsx",
"src/components/SelectionList.tsx",
"src/components/mail/MailServerSettingsPanel.tsx",
"src/components/Button.tsx",

View File

@@ -28,6 +28,7 @@ const defaultWebModulePackages = [
"@govoplan/organizations-webui",
"@govoplan/ops-webui",
"@govoplan/policy-webui",
"@govoplan/postbox-webui",
"@govoplan/scheduling-webui",
"@govoplan/views-webui",
"@govoplan/workflow-webui"
@@ -91,12 +92,14 @@ export default defineConfig({
chunkSizeWarningLimit: 1200
},
resolve: {
preserveSymlinks: true,
preserveSymlinks: false,
dedupe: [
"@xyflow/react",
"lucide-react",
"react",
"react-dom",
"react-router-dom"
"react-router-dom",
"read-excel-file"
],
alias: [
{ find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) },
@@ -125,6 +128,7 @@ export default defineConfig({
fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-postbox/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))