feat: strengthen module contracts and shared WebUI runtime
This commit is contained in:
81
webui/package-lock.json
generated
81
webui/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
213
webui/src/api/notificationSummary.ts
Normal file
213
webui/src/api/notificationSummary.ts
Normal 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;
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
119
webui/src/components/DashboardWidgetContent.tsx
Normal file
119
webui/src/components/DashboardWidgetContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
472
webui/src/components/ReferenceSelect.tsx
Normal file
472
webui/src/components/ReferenceSelect.tsx
Normal 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");
|
||||
}
|
||||
379
webui/src/components/SearchableSelect.tsx
Normal file
379
webui/src/components/SearchableSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
163
webui/src/platform/referenceProviders.ts
Normal file
163
webui/src/platform/referenceProviders.ts
Normal 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");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user