feat: consolidate shared UI and harden browser authority for release
This commit is contained in:
+30
-7
@@ -2,7 +2,7 @@ import { Navigate, Route, Routes, useLocation } from "react-router";
|
||||
import { lazy, useEffect, useMemo, useState } from "react";
|
||||
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
||||
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||
import { AUTH_REQUIRED_EVENT, apiSettingsForAuthUpdate, clearApiReadCache, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, EffectiveViewProjection, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPalette, UserUiPreferences, ViewsRuntimeUiCapability } from "./types";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||
@@ -20,13 +20,16 @@ import {
|
||||
type WorkflowViewChangedEventDetail
|
||||
} from "./platform/views";
|
||||
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||
import { UnsavedChangesProvider, useUnsavedChanges } from "./components/UnsavedChangesGuard";
|
||||
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
|
||||
import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
|
||||
import ModuleLoadBoundary from "./components/ModuleLoadBoundary";
|
||||
import DismissibleAlert from "./components/DismissibleAlert";
|
||||
import Button from "./components/Button";
|
||||
import { i18nMessage } from "./i18n/LanguageContext";
|
||||
import { DocumentationHelpProvider } from "./components/help/DocumentationHelpLink";
|
||||
import { hasAnyScope } from "./utils/permissions";
|
||||
import { applyAppearanceOverrides } from "./components/AppearanceOverridesEditor";
|
||||
import { applyAppearanceOverrides } from "./components/appearanceOverrides";
|
||||
|
||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||
@@ -54,6 +57,7 @@ export default function App() {
|
||||
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
||||
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
|
||||
const [webModulesLoading, setWebModulesLoading] = useState(true);
|
||||
const [webModuleLoadFailures, setWebModuleLoadFailures] = useState<string[]>([]);
|
||||
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
||||
const [backendReachable, setBackendReachable] = useState(true);
|
||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
||||
@@ -172,9 +176,10 @@ export default function App() {
|
||||
}
|
||||
|
||||
function updateAuth(next: AuthUpdate | null, accessToken?: string) {
|
||||
const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings;
|
||||
clearApiReadCache();
|
||||
const nextSettings = apiSettingsForAuthUpdate(settings, next, accessToken);
|
||||
setAuth((current) => next ? normalizeAuthInfo(mergeAuthPayload(current, next)) : null);
|
||||
if (accessToken !== undefined) {
|
||||
if (nextSettings !== settings) {
|
||||
setSettings(nextSettings);
|
||||
saveApiSettings(nextSettings);
|
||||
}
|
||||
@@ -328,6 +333,7 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setWebModuleLoadFailures([]);
|
||||
if (!auth) {
|
||||
setLocalWebModules([]);
|
||||
setRemoteWebModules([]);
|
||||
@@ -345,12 +351,18 @@ export default function App() {
|
||||
}
|
||||
|
||||
async function loadWebModules() {
|
||||
const local = await loadInstalledWebModules(platformModules);
|
||||
const failedIds: string[] = [];
|
||||
const local = await loadInstalledWebModules(platformModules, (moduleId) => failedIds.push(moduleId));
|
||||
if (cancelled) return;
|
||||
setLocalWebModules(local);
|
||||
setWebModuleLoadFailures(failedIds);
|
||||
setWebModulesLoading(false);
|
||||
const remote = await loadRemoteWebModules(platformModules, local);
|
||||
if (!cancelled) setRemoteWebModules(remote);
|
||||
if (!cancelled) {
|
||||
setRemoteWebModules(remote);
|
||||
const recovered = new Set(remote.map((module) => module.id));
|
||||
setWebModuleLoadFailures(failedIds.filter((moduleId) => !recovered.has(moduleId)));
|
||||
}
|
||||
}
|
||||
|
||||
void loadWebModules().catch((error) => {
|
||||
@@ -451,8 +463,10 @@ export default function App() {
|
||||
setBackendReachable(true);
|
||||
const shellRefreshDue = now - lastShellRefreshAt >= 60_000;
|
||||
if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) {
|
||||
clearApiReadCache();
|
||||
const shellAuth = await fetchShellAuth(settings);
|
||||
if (cancelled) return;
|
||||
clearApiReadCache();
|
||||
lastShellRefreshAt = Date.now();
|
||||
setAuth((current) => current && sessionMatchesAuth(sessionInfo, current)
|
||||
? normalizeAuthInfo(mergeAuthPayload(current, shellAuth))
|
||||
@@ -555,6 +569,7 @@ export default function App() {
|
||||
<PlatformActiveObjectProvider>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} allToolItems={allToolItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
{webModuleLoadFailures.length > 0 && <WebModuleLoadFailureNotice moduleIds={webModuleLoadFailures} />}
|
||||
<ModuleLoadBoundary resetKey={`${location.pathname}:${temporalRevision}`} loading={webModulesLoading}>
|
||||
<Routes key={`${(auth.active_tenant ?? auth.tenant).id}:${temporalRevision}`}>
|
||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||
@@ -607,6 +622,14 @@ export default function App() {
|
||||
|
||||
}
|
||||
|
||||
function WebModuleLoadFailureNotice({ moduleIds }: { moduleIds: string[] }) {
|
||||
const { requestNavigation } = useUnsavedChanges();
|
||||
return <DismissibleAlert tone="warning" dismissible={false} floating>
|
||||
<p>{i18nMessage("i18n:govoplan-core.optional_module_load_failed", { value0: moduleIds.join(", ") })}</p>
|
||||
<Button onClick={() => requestNavigation(() => window.location.reload())}>i18n:govoplan-core.reload.cce71553</Button>
|
||||
</DismissibleAlert>;
|
||||
}
|
||||
|
||||
type AuthPayload = AuthUpdate;
|
||||
|
||||
function mergeAuthPayload(current: AuthInfo | null, next: AuthPayload): AuthPayload {
|
||||
|
||||
+85
-41
@@ -1,4 +1,4 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
import type { ApiSettings, AuthUpdate } from "../types";
|
||||
import { temporalRequestHeaders } from "../platform/temporal";
|
||||
|
||||
const STORAGE_KEY = "govoplan.apiSettings";
|
||||
@@ -25,12 +25,14 @@ type RecentSafeRequest = {
|
||||
type ConditionalSafeRequest = {
|
||||
value: unknown;
|
||||
etag: string;
|
||||
cacheControl: string;
|
||||
};
|
||||
|
||||
const inFlightSafeRequests = new Map<string, Promise<unknown>>();
|
||||
const recentSafeRequests = new Map<string, RecentSafeRequest>();
|
||||
const conditionalSafeRequests = new Map<string, ConditionalSafeRequest>();
|
||||
let safeRequestGeneration = 0;
|
||||
let lastSessionCookie: string | undefined;
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
@@ -166,7 +168,16 @@ export function loadApiSettings(): ApiSettings {
|
||||
};
|
||||
}
|
||||
|
||||
export function apiSettingsForAuthUpdate(settings: ApiSettings, next: AuthUpdate | null, accessToken?: string): ApiSettings {
|
||||
if (next === null || accessToken !== undefined || next.principal?.auth_method === "session") {
|
||||
const token = next === null ? "" : accessToken ?? settings.accessToken;
|
||||
return settings.apiKey || token !== settings.accessToken ? { ...settings, apiKey: "", accessToken: token } : settings;
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function saveApiSettings(settings: ApiSettings): void {
|
||||
clearApiReadCache();
|
||||
localStorage.setItem(`${STORAGE_KEY}.baseUrl`, normalizeApiBaseUrl(settings.apiBaseUrl));
|
||||
if (settings.apiKey) {
|
||||
sessionStorage.setItem(`${SESSION_STORAGE_KEY}.apiKey`, settings.apiKey);
|
||||
@@ -181,6 +192,7 @@ export function saveApiSettings(settings: ApiSettings): void {
|
||||
}
|
||||
|
||||
export function clearAccessToken(): void {
|
||||
clearApiReadCache();
|
||||
sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`);
|
||||
localStorage.removeItem(`${STORAGE_KEY}.accessToken`);
|
||||
}
|
||||
@@ -220,30 +232,27 @@ function isUnsafeMethod(method?: string): boolean {
|
||||
return !["GET", "HEAD", "OPTIONS", "TRACE"].includes(normalized);
|
||||
}
|
||||
|
||||
function canReuseSafeRequest(method: string, init?: RequestInit): boolean {
|
||||
function canReuseSafeRequest(method: string, headers: Headers, init?: RequestInit): boolean {
|
||||
return (method === "GET" || method === "HEAD") &&
|
||||
!init?.body &&
|
||||
!init?.signal &&
|
||||
init?.cache !== "no-store" &&
|
||||
init?.cache !== "reload";
|
||||
!requiresFreshRead(headers, init);
|
||||
}
|
||||
|
||||
function requestHeadersKey(headers: Headers): string {
|
||||
return [...headers.entries()].
|
||||
sort(([left], [right]) => left.localeCompare(right)).
|
||||
map(([key, value]) => `${key}:${value}`).
|
||||
join("\n");
|
||||
function requiresFreshRead(headers: Headers, init?: RequestInit): boolean {
|
||||
return init?.cache === "no-store" || init?.cache === "reload" || init?.cache === "no-cache" ||
|
||||
/\b(?:no-store|no-cache|max-age\s*=\s*"?0\b)/i.test(headers.get("Cache-Control") ?? "");
|
||||
}
|
||||
|
||||
function safeRequestKey(url: string, method: string, headers: Headers, init?: RequestInit): string {
|
||||
return [
|
||||
// Headers already iterates normalized names in lexicographical order.
|
||||
return JSON.stringify([
|
||||
method,
|
||||
url,
|
||||
init?.credentials ?? "include",
|
||||
init?.mode ?? "",
|
||||
init?.redirect ?? "",
|
||||
requestHeadersKey(headers)].
|
||||
join("\n\n");
|
||||
[...headers].filter(([key]) => key !== "cache-control")]);
|
||||
}
|
||||
|
||||
function pruneRecentSafeRequests(now = Date.now()): void {
|
||||
@@ -285,9 +294,9 @@ function conditionalSafeResponse(key: string): ConditionalSafeRequest | undefine
|
||||
return cached;
|
||||
}
|
||||
|
||||
function rememberConditionalSafeResponse(key: string, etag: string, value: unknown): void {
|
||||
function rememberConditionalSafeResponse(key: string, etag: string, value: unknown, cacheControl: string): void {
|
||||
conditionalSafeRequests.delete(key);
|
||||
conditionalSafeRequests.set(key, { etag, value });
|
||||
conditionalSafeRequests.set(key, { etag, value, cacheControl });
|
||||
while (conditionalSafeRequests.size > MAX_CONDITIONAL_SAFE_REQUESTS) {
|
||||
const oldestKey = conditionalSafeRequests.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
@@ -295,7 +304,8 @@ function rememberConditionalSafeResponse(key: string, etag: string, value: unkno
|
||||
}
|
||||
}
|
||||
|
||||
function clearSafeRequestCaches(): void {
|
||||
/** Invalidate before changing the shell's account, tenant, permissions, or session. */
|
||||
export function clearApiReadCache(): void {
|
||||
safeRequestGeneration += 1;
|
||||
inFlightSafeRequests.clear();
|
||||
recentSafeRequests.clear();
|
||||
@@ -318,6 +328,7 @@ function shouldNotifyAuthRequired(path: string): boolean {
|
||||
}
|
||||
|
||||
function notifyAuthRequired(path: string): void {
|
||||
clearApiReadCache();
|
||||
if (typeof window === "undefined" || !shouldNotifyAuthRequired(path)) return;
|
||||
const detail: AuthRequiredEventDetail = {
|
||||
path,
|
||||
@@ -347,43 +358,85 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
}
|
||||
|
||||
const csrf = csrfToken();
|
||||
// The HttpOnly session cookie cannot enter a cache key. Its paired CSRF
|
||||
// cookie rotates with the session, including sign-in from another tab.
|
||||
if (lastSessionCookie !== csrf) {
|
||||
clearApiReadCache();
|
||||
lastSessionCookie = csrf;
|
||||
}
|
||||
if (csrf && isUnsafeMethod(method) && !headers.has("X-CSRF-Token")) {
|
||||
headers.set("X-CSRF-Token", csrf);
|
||||
}
|
||||
|
||||
if (isUnsafeMethod(method)) {
|
||||
clearSafeRequestCaches();
|
||||
const unsafe = isUnsafeMethod(method);
|
||||
if (unsafe) {
|
||||
clearApiReadCache();
|
||||
}
|
||||
|
||||
const url = apiUrl(settings, path);
|
||||
const reusableSafeRequest = canReuseSafeRequest(method, init);
|
||||
const cacheKey = reusableSafeRequest ? safeRequestKey(url, method, headers, init) : null;
|
||||
const reusableSafeRequest = canReuseSafeRequest(method, headers, init);
|
||||
const cacheKey = safeRequestKey(url, method, headers, init);
|
||||
const requestGeneration = safeRequestGeneration;
|
||||
let request: Promise<T> | undefined;
|
||||
|
||||
if ((method === "GET" || method === "HEAD") && requiresFreshRead(headers, init)) {
|
||||
// Explicit fresh reads supersede both stored and still-running reads for
|
||||
// this resource, without invalidating unrelated workspace requests.
|
||||
inFlightSafeRequests.delete(cacheKey);
|
||||
recentSafeRequests.delete(cacheKey);
|
||||
conditionalSafeRequests.delete(cacheKey);
|
||||
}
|
||||
|
||||
function rememberResponse(response: Response, value: T, previous?: ConditionalSafeRequest): void {
|
||||
if (!reusableSafeRequest || requestGeneration !== safeRequestGeneration ||
|
||||
inFlightSafeRequests.get(cacheKey) !== request) return;
|
||||
|
||||
const cacheControl = response.headers.get("Cache-Control") ?? previous?.cacheControl ?? "";
|
||||
const noStore = /(?:^|,)\s*no-store\b/i.test(cacheControl) || response.headers.get("Vary")?.trim() === "*";
|
||||
const mustValidate = /(?:^|,)\s*(?:no-cache\b|max-age\s*=\s*"?0\b)/i.test(cacheControl);
|
||||
const etag = response.headers.get("etag") ?? previous?.etag;
|
||||
if (!noStore && etag) {
|
||||
rememberConditionalSafeResponse(cacheKey, etag, value, cacheControl);
|
||||
} else {
|
||||
conditionalSafeRequests.delete(cacheKey);
|
||||
}
|
||||
if (!noStore && !mustValidate) {
|
||||
rememberSafeResponse(cacheKey, value);
|
||||
} else {
|
||||
recentSafeRequests.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
async function runFetch(): Promise<T> {
|
||||
const fetchHeaders = new Headers(headers);
|
||||
const conditional = cacheKey ? conditionalSafeResponse(cacheKey) : undefined;
|
||||
const conditional = reusableSafeRequest ? conditionalSafeResponse(cacheKey) : undefined;
|
||||
if (conditional && !fetchHeaders.has("If-None-Match")) {
|
||||
fetchHeaders.set("If-None-Match", conditional.etag);
|
||||
}
|
||||
const fetchInit = { ...init, headers: fetchHeaders, credentials: init?.credentials ?? "include" };
|
||||
const response = await fetch(url, fetchInit);
|
||||
|
||||
if (response.status === 304 && cacheKey && conditional) {
|
||||
rememberSafeResponse(cacheKey, conditional.value);
|
||||
if (response.status === 304 && conditional && fetchHeaders.get("If-None-Match") === conditional.etag) {
|
||||
rememberResponse(response, conditional.value as T, conditional);
|
||||
return conditional.value as T;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 401 && shouldNotifyAuthRequired(path)) {
|
||||
notifyAuthRequired(path);
|
||||
// An obsolete request must not expire a newly established session.
|
||||
if (requestGeneration === safeRequestGeneration && csrfToken() === csrf) notifyAuthRequired(path);
|
||||
throw authExpiredError(response.statusText);
|
||||
}
|
||||
throw new ApiError(response.status, response.statusText, text);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
if (cacheKey) conditionalSafeRequests.delete(cacheKey);
|
||||
if (reusableSafeRequest && requestGeneration === safeRequestGeneration &&
|
||||
inFlightSafeRequests.get(cacheKey) === request) {
|
||||
recentSafeRequests.delete(cacheKey);
|
||||
conditionalSafeRequests.delete(cacheKey);
|
||||
}
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
@@ -395,20 +448,15 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
value = (await response.json()) as T;
|
||||
}
|
||||
|
||||
const etag = response.headers.get("etag");
|
||||
if (cacheKey && etag) {
|
||||
rememberConditionalSafeResponse(cacheKey, etag, value);
|
||||
} else if (cacheKey) {
|
||||
conditionalSafeRequests.delete(cacheKey);
|
||||
}
|
||||
rememberResponse(response, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!reusableSafeRequest || !cacheKey) {
|
||||
return runFetch();
|
||||
if (!reusableSafeRequest) {
|
||||
// Reads made while a write was pending may still describe its old state.
|
||||
return unsafe ? runFetch().finally(clearApiReadCache) : runFetch();
|
||||
}
|
||||
|
||||
const requestGeneration = safeRequestGeneration;
|
||||
const recent = recentSafeResponse(cacheKey);
|
||||
if (recent !== undefined) {
|
||||
return recent as T;
|
||||
@@ -419,13 +467,7 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
return existing as Promise<T>;
|
||||
}
|
||||
|
||||
const request = runFetch().
|
||||
then((value) => {
|
||||
if (requestGeneration === safeRequestGeneration) {
|
||||
rememberSafeResponse(cacheKey, value);
|
||||
}
|
||||
return value;
|
||||
}).
|
||||
request = runFetch().
|
||||
finally(() => {
|
||||
if (inFlightSafeRequests.get(cacheKey) === request) {
|
||||
inFlightSafeRequests.delete(cacheKey);
|
||||
@@ -437,6 +479,8 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
|
||||
|
||||
export async function apiDownload(settings: ApiSettings, path: string, filename: string): Promise<void> {
|
||||
const requestGeneration = safeRequestGeneration;
|
||||
const sessionCookie = csrfToken();
|
||||
const headers = authHeaders(settings);
|
||||
for (const [key, value] of Object.entries(temporalRequestHeaders())) {
|
||||
headers.set(key, value);
|
||||
@@ -445,7 +489,7 @@ export async function apiDownload(settings: ApiSettings, path: string, filename:
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 401 && shouldNotifyAuthRequired(path)) {
|
||||
notifyAuthRequired(path);
|
||||
if (requestGeneration === safeRequestGeneration && csrfToken() === sessionCookie) notifyAuthRequired(path);
|
||||
throw authExpiredError(response.statusText);
|
||||
}
|
||||
throw new ApiError(response.status, response.statusText, text);
|
||||
|
||||
@@ -79,6 +79,7 @@ export const mailProfilePolicyLimitKeys = [
|
||||
"allowed_profile_ids",
|
||||
"allow_user_profiles",
|
||||
"allow_group_profiles",
|
||||
"allow_campaign_profiles",
|
||||
"smtp_credentials.inherit",
|
||||
"imap_credentials.inherit",
|
||||
"whitelist.smtp_hosts",
|
||||
|
||||
@@ -12,11 +12,8 @@ import DismissibleAlert from "./DismissibleAlert";
|
||||
import FormField from "./FormField";
|
||||
import SegmentedControl from "./SegmentedControl";
|
||||
|
||||
export const APPEARANCE_OVERRIDE_TOKENS: readonly AppearanceOverrideToken[] = [
|
||||
"accent", "accent_foreground", "surface", "surface_foreground",
|
||||
"success", "success_foreground", "info", "info_foreground",
|
||||
"warning", "warning_foreground", "danger", "danger_foreground"
|
||||
];
|
||||
import { APPEARANCE_OVERRIDE_TOKENS, STATUS_TOKENS, cloneDefaultAppearanceOverrides, validateAppearanceOverrides } from "./appearanceOverrides";
|
||||
export { APPEARANCE_OVERRIDE_TOKENS, DEFAULT_APPEARANCE_OVERRIDES, applyAppearanceOverrides, cloneDefaultAppearanceOverrides, validateAppearanceOverrides } from "./appearanceOverrides";
|
||||
|
||||
const TOKEN_LABELS: Record<AppearanceOverrideToken, string> = {
|
||||
accent: "i18n:govoplan-core.override_accent",
|
||||
@@ -33,112 +30,6 @@ const TOKEN_LABELS: Record<AppearanceOverrideToken, string> = {
|
||||
danger_foreground: "i18n:govoplan-core.override_danger_foreground"
|
||||
};
|
||||
|
||||
const STATUS_TOKENS: readonly AppearanceOverrideToken[] = ["success", "info", "warning", "danger"];
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
const RUNTIME_PROPERTIES = new Set<string>();
|
||||
|
||||
const RUNTIME_TOKEN_PROPERTIES: Record<AppearanceOverrideToken, readonly string[]> = {
|
||||
accent: ["--accent", "--action-primary-bg"],
|
||||
accent_foreground: ["--on-accent", "--badge-accent-text", "--action-primary-text"],
|
||||
surface: ["--surface", "--panel-soft"],
|
||||
surface_foreground: ["--text", "--text-strong"],
|
||||
success: ["--success-bg", "--success-soft"],
|
||||
success_foreground: ["--success-text", "--success-text-strong"],
|
||||
info: ["--info-bg", "--info-soft"],
|
||||
info_foreground: ["--info-text", "--info-text-strong", "--info-text-deep"],
|
||||
warning: ["--warning-bg", "--warning-soft"],
|
||||
warning_foreground: ["--warning-text", "--warning-text-strong"],
|
||||
danger: ["--danger-bg", "--danger-soft"],
|
||||
danger_foreground: ["--danger-text", "--danger-text-strong", "--danger-text-deep"]
|
||||
};
|
||||
|
||||
for (const properties of Object.values(RUNTIME_TOKEN_PROPERTIES)) {
|
||||
for (const property of properties) RUNTIME_PROPERTIES.add(property);
|
||||
}
|
||||
|
||||
export const DEFAULT_APPEARANCE_OVERRIDES: AppearanceOverridesDocument = {
|
||||
schema_version: "1",
|
||||
light: {
|
||||
accent: "#245f91", accent_foreground: "#ffffff",
|
||||
surface: "#ffffff", surface_foreground: "#303135",
|
||||
success: "#d8eee8", success_foreground: "#315f55",
|
||||
info: "#dce9f3", info_foreground: "#294a61",
|
||||
warning: "#ffe1a3", warning_foreground: "#593700",
|
||||
danger: "#f8d1cc", danger_foreground: "#873c35"
|
||||
},
|
||||
dark: {
|
||||
accent: "#7ea6c5", accent_foreground: "#242424",
|
||||
surface: "#262724", surface_foreground: "#f1f1f1",
|
||||
success: "#24473f", success_foreground: "#d8eee8",
|
||||
info: "#243d4e", info_foreground: "#dce9f3",
|
||||
warning: "#5a431f", warning_foreground: "#ffe1a3",
|
||||
danger: "#4f2d2a", danger_foreground: "#f8d1cc"
|
||||
}
|
||||
};
|
||||
|
||||
export function cloneDefaultAppearanceOverrides(): AppearanceOverridesDocument {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_APPEARANCE_OVERRIDES)) as AppearanceOverridesDocument;
|
||||
}
|
||||
|
||||
export function validateAppearanceOverrides(value: unknown): AppearanceOverridesDocument {
|
||||
if (!isRecord(value) || value.schema_version !== "1" || !isRecord(value.light) || !isRecord(value.dark)) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
|
||||
}
|
||||
if (!hasExactKeys(value, ["schema_version", "light", "dark"])) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
|
||||
}
|
||||
const document = value as unknown as AppearanceOverridesDocument;
|
||||
for (const modeName of ["light", "dark"] as const) {
|
||||
const mode = document[modeName];
|
||||
if (!hasExactKeys(mode, APPEARANCE_OVERRIDE_TOKENS)) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_all_tokens_required");
|
||||
}
|
||||
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
|
||||
if (typeof mode[token] !== "string" || !HEX_COLOR.test(mode[token])) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_hex_required");
|
||||
}
|
||||
}
|
||||
for (const [background, foreground] of [
|
||||
["accent", "accent_foreground"], ["surface", "surface_foreground"],
|
||||
["success", "success_foreground"], ["info", "info_foreground"],
|
||||
["warning", "warning_foreground"], ["danger", "danger_foreground"]
|
||||
] as const) {
|
||||
if (contrastRatio(mode[background], mode[foreground]) < 4.5) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_contrast_error");
|
||||
}
|
||||
}
|
||||
for (let first = 0; first < STATUS_TOKENS.length; first += 1) {
|
||||
for (let second = first + 1; second < STATUS_TOKENS.length; second += 1) {
|
||||
if (rgbDistance(mode[STATUS_TOKENS[first]], mode[STATUS_TOKENS[second]]) < 12) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_status_error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
export function applyAppearanceOverrides(
|
||||
root: HTMLElement,
|
||||
document: AppearanceOverridesDocument | null,
|
||||
theme: "light" | "dark"
|
||||
) {
|
||||
for (const property of RUNTIME_PROPERTIES) root.style.removeProperty(property);
|
||||
if (!document) return;
|
||||
let validated: AppearanceOverridesDocument;
|
||||
try {
|
||||
validated = validateAppearanceOverrides(document);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const colors = validated[theme];
|
||||
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
|
||||
for (const property of RUNTIME_TOKEN_PROPERTIES[token]) {
|
||||
root.style.setProperty(property, colors[token]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function AppearanceOverridesEditor({
|
||||
value,
|
||||
onChange,
|
||||
@@ -257,31 +148,3 @@ function appearanceOverridesValidationMessage(value: AppearanceOverridesDocument
|
||||
return error instanceof Error ? error.message : "i18n:govoplan-core.appearance_override_invalid_schema";
|
||||
}
|
||||
}
|
||||
|
||||
function hasExactKeys(value: object, keys: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function relativeLuminance(color: string): number {
|
||||
const channels = [1, 3, 5].map((index) => Number.parseInt(color.slice(index, index + 2), 16) / 255);
|
||||
const linear = channels.map((channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
||||
}
|
||||
|
||||
function contrastRatio(first: string, second: string): number {
|
||||
const luminances = [relativeLuminance(first), relativeLuminance(second)].sort((left, right) => right - left);
|
||||
return (luminances[0] + 0.05) / (luminances[1] + 0.05);
|
||||
}
|
||||
|
||||
function rgbDistance(first: string, second: string): number {
|
||||
return Math.sqrt([1, 3, 5].reduce((total, index) => {
|
||||
const delta = Number.parseInt(first.slice(index, index + 2), 16) - Number.parseInt(second.slice(index, index + 2), 16);
|
||||
return total + delta * delta;
|
||||
}, 0));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export type CardProps = PlatformInterfaceIdentityProps & Omit<HTMLAttributes<HTM
|
||||
as?: "section" | "article";
|
||||
headerClassName?: string;
|
||||
bodyClassName?: string;
|
||||
/** Table-only bodies are edge-to-edge, including with loading wrappers. */
|
||||
bodyLayout?: "content" | "table";
|
||||
actionsClassName?: string;
|
||||
};
|
||||
|
||||
@@ -56,6 +58,7 @@ export default function Card({
|
||||
className = "",
|
||||
headerClassName = "",
|
||||
bodyClassName = "",
|
||||
bodyLayout = "content",
|
||||
actionsClassName = "",
|
||||
interfaceId,
|
||||
helpContextId,
|
||||
@@ -69,7 +72,7 @@ export default function Card({
|
||||
const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) }));
|
||||
const collapsed = collapseState.storageKey === storageKey ? collapseState.collapsed : readCollapseState(storageKey);
|
||||
const hasHeader = Boolean(title || actions || collapsible);
|
||||
const body = <div className={["card-body", bodyClassName].filter(Boolean).join(" ")}>{children}</div>;
|
||||
const body = <div className={["card-body", `card-body-${bodyLayout}`, bodyClassName].filter(Boolean).join(" ")} data-card-body-layout={bodyLayout}>{children}</div>;
|
||||
const shouldRenderBody = !collapsible || !collapsed;
|
||||
const collapseLabel = translateText(collapsed ? "i18n:govoplan-core.show_content.0528d8d2" : "i18n:govoplan-core.show_header_only.24afefca");
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormGrid } from "./ContentGrid";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
ApiSettings,
|
||||
@@ -104,12 +104,15 @@ const CREDENTIAL_DOCUMENTATION = {
|
||||
documentationType: "admin" as const
|
||||
};
|
||||
|
||||
const EMPTY_TARGET_OPTIONS: CredentialEnvelopeTargetOption[] = [];
|
||||
const EMPTY_SERVER_OPTIONS: CredentialEnvelopeServerOption[] = [];
|
||||
|
||||
export default function CredentialEnvelopeManager({
|
||||
settings,
|
||||
scopeType,
|
||||
scopeId,
|
||||
targetOptions = [],
|
||||
serverOptions = [],
|
||||
targetOptions = EMPTY_TARGET_OPTIONS,
|
||||
serverOptions = EMPTY_SERVER_OPTIONS,
|
||||
targetLabel = "Target",
|
||||
title = "Credential envelopes",
|
||||
canWrite
|
||||
@@ -120,6 +123,7 @@ export default function CredentialEnvelopeManager({
|
||||
const [credentials, setCredentials] = useState<CredentialEnvelopeSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [editing, setEditing] = useState<CredentialEnvelopeSummary | "new" | null>(null);
|
||||
@@ -175,6 +179,9 @@ export default function CredentialEnvelopeManager({
|
||||
]),
|
||||
[
|
||||
activeScopeId,
|
||||
// Reopening refreshes the catalogue, but typing in a draft must not
|
||||
// recreate it and repeat every module's server-metadata request.
|
||||
editing,
|
||||
referenceCapabilities,
|
||||
scopeType,
|
||||
serverReferenceOptions,
|
||||
@@ -260,19 +267,21 @@ export default function CredentialEnvelopeManager({
|
||||
setSavedDraftKey("");
|
||||
}
|
||||
|
||||
async function saveDraft(): Promise<boolean> {
|
||||
if (!editing || !scopeReady || !draft.name.trim() || !canWrite) return false;
|
||||
function saveDraft(): Promise<boolean> {
|
||||
if (saveInFlightRef.current) return saveInFlightRef.current;
|
||||
if (!editing || !scopeReady || !draft.name.trim() || !canWrite) return Promise.resolve(false);
|
||||
if (editing === "new" && !draft.secret.trim()) {
|
||||
setError("Enter a secret before creating the credential.");
|
||||
return false;
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const kindChanged = editing !== "new" && draft.credentialKind !== editing.credential_kind;
|
||||
if (kindChanged && !draft.secret.trim()) {
|
||||
setError("Enter a replacement secret when changing the credential type.");
|
||||
return false;
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const publicData = {
|
||||
...draft.retainedPublicData,
|
||||
@@ -313,6 +322,10 @@ export default function CredentialEnvelopeManager({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
const pending = operation.finally(() => { saveInFlightRef.current = null; });
|
||||
saveInFlightRef.current = pending;
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
@@ -427,7 +440,7 @@ export default function CredentialEnvelopeManager({
|
||||
</select>
|
||||
</FormField>
|
||||
)}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{error && !editing && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
||||
{managerBlocker && (
|
||||
<ActionBlockerHint
|
||||
@@ -522,6 +535,7 @@ export default function CredentialEnvelopeManager({
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<div className="adaptive-config-form">
|
||||
<section className="adaptive-config-section">
|
||||
<header>
|
||||
|
||||
@@ -16,6 +16,8 @@ type ExplorerTreeCommonProps<T> = {
|
||||
getNodeLabel: (node: T) => string;
|
||||
getNodeChildren: (node: T) => T[];
|
||||
activeId?: string;
|
||||
/** Select/open the labelled item without changing expansion. Group labels
|
||||
* select the group as well; never forward this callback to onToggle. */
|
||||
onOpen: (node: T, context: ExplorerTreeNodeContext) => void;
|
||||
disabled?: boolean;
|
||||
depth?: number;
|
||||
@@ -43,6 +45,8 @@ type ExplorerTreeCommonProps<T> = {
|
||||
type CollapsibleExplorerTreeProps<T> = {
|
||||
collapsible?: true;
|
||||
expandedIds: ReadonlySet<string>;
|
||||
/** The folder/disclosure button exclusively controls expansion. Keep the
|
||||
* current selection intact when expanding or collapsing its neighbours. */
|
||||
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -10,14 +10,15 @@ type FormFieldProps = PlatformInterfaceIdentityProps & {
|
||||
help?: ReactNode;
|
||||
documentation?: DocumentationHelpReference;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function FormField({ label, help, documentation, children, interfaceId, helpContextId, helpModuleId, helpTopicId }: FormFieldProps) {
|
||||
export default function FormField({ label, help, documentation, children, className = "", interfaceId, helpContextId, helpModuleId, helpTopicId }: FormFieldProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
||||
return (
|
||||
<label
|
||||
className="form-field"
|
||||
className={["form-field", className].filter(Boolean).join(" ")}
|
||||
data-help-scope="field"
|
||||
data-interface-id={interfaceId}
|
||||
data-help-context-id={helpContextId ?? documentation?.contextId}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export type ListFilterOption = { value: string; label: string; disabled?: boolean };
|
||||
export type ListFilterSelection = string[] | null;
|
||||
|
||||
/** null means unrestricted; an empty array deliberately matches nothing. */
|
||||
export default function ListSelectionFilter({
|
||||
options, value, onChange, label, renderOption, renderOptionActions,
|
||||
}: {
|
||||
options: ListFilterOption[];
|
||||
value: ListFilterSelection;
|
||||
onChange: (value: ListFilterSelection) => void;
|
||||
label?: string;
|
||||
renderOption?: (option: ListFilterOption) => ReactNode;
|
||||
renderOptionActions?: (option: ListFilterOption) => ReactNode;
|
||||
}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const selected = new Set(value ?? options.map((option) => option.value));
|
||||
|
||||
function toggle(optionValue: string) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(optionValue)) next.delete(optionValue);
|
||||
else next.add(optionValue);
|
||||
onChange(options.length > 0 && options.every((option) => next.has(option.value)) ? null : [...next]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="data-grid-list-filter">
|
||||
<div className="data-grid-list-filter-actions">
|
||||
<button type="button" onClick={() => onChange(null)}>{translateText("i18n:govoplan-core.select_all.913afff1")}</button>
|
||||
<button type="button" onClick={() => onChange([])}>{translateText("i18n:govoplan-core.deselect_all.85cce1e1")}</button>
|
||||
</div>
|
||||
<div className="data-grid-list-filter-options" role="group" aria-label={translateText(label ?? "i18n:govoplan-core.allowed_values.495fcf3a")}>
|
||||
{options.length === 0 ? <p className="muted small-note">{translateText("i18n:govoplan-core.no_options_available.a88ab045")}</p> : options.map((option) => (
|
||||
<div className="data-grid-list-filter-row" key={option.value}>
|
||||
<label>
|
||||
<input type="checkbox" checked={selected.has(option.value)} disabled={option.disabled} onChange={() => toggle(option.value)} />
|
||||
{renderOption ? renderOption(option) : <span className="data-grid-list-option-label">{translateText(option.label)}</span>}
|
||||
</label>
|
||||
{renderOptionActions?.(option)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,24 +6,32 @@ type LoadingFrameProps = {
|
||||
loading?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
indicator?: "default" | "none";
|
||||
/** Undefined omits the bar; null reports unknown progress without an invented percentage. */
|
||||
progress?: number | null;
|
||||
progressLabel?: string;
|
||||
};
|
||||
|
||||
export default function LoadingFrame({ children, loading = false, label = "i18n:govoplan-core.loading_data.089f19c5", className = "" }: LoadingFrameProps) {
|
||||
export default function LoadingFrame({ children, loading = false, label = "i18n:govoplan-core.loading_data.089f19c5", className = "", indicator = "default", progress, progressLabel }: LoadingFrameProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const translatedLabel = translateText(label);
|
||||
const classNames = ["loading-frame", loading ? "is-loading" : "", className].filter(Boolean).join(" ");
|
||||
const progressValue = typeof progress === "number" && Number.isFinite(progress) ? Math.max(0, Math.min(100, progress)) : undefined;
|
||||
const translatedProgressLabel = progressLabel ? translateText(progressLabel) : undefined;
|
||||
|
||||
return (
|
||||
<div className={classNames} aria-busy={loading || undefined}>
|
||||
{children}
|
||||
{loading &&
|
||||
<div className="loading-frame-overlay" role="status" aria-live="polite">
|
||||
<div className="loading-frame-panel">
|
||||
<LoadingIndicator label={translatedLabel} size="md" />
|
||||
<div className={`loading-frame-panel${progress !== undefined ? " has-progress" : ""}`}>
|
||||
{indicator !== "none" && <LoadingIndicator label={translatedLabel} size="md" />}
|
||||
<span>{translatedLabel}</span>
|
||||
{progress !== undefined && <progress max={100} value={progressValue} aria-label={translatedLabel} aria-valuetext={translatedProgressLabel} />}
|
||||
{translatedProgressLabel && <span className="loading-frame-progress-label">{translatedProgressLabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronDown, Filter, X } from "lucide-react";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import ListSelectionFilter, { type ListFilterOption, type ListFilterSelection } from "./ListSelectionFilter";
|
||||
import { nextDialogActivationOrder, registerDialog } from "./dialogStack";
|
||||
|
||||
export type MultiSelectFilterProps = {
|
||||
options: ListFilterOption[];
|
||||
value: ListFilterSelection;
|
||||
onChange: (value: ListFilterSelection) => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** The DataGrid checkbox filter as a standalone, non-clipping dropdown. */
|
||||
export default function MultiSelectFilter({ options, value, onChange, label, disabled = false, className = "" }: MultiSelectFilterProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0, width: 280, maxHeight: 400 });
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
const dialogStackId = useRef(Symbol("govoplan-list-filter"));
|
||||
const nestedDialog = useRef(false);
|
||||
const popupId = useId();
|
||||
const visible = open && !disabled;
|
||||
const selectedCount = value === null ? options.length : options.filter((option) => value.includes(option.value)).length;
|
||||
const summary = value === null ? translateText("i18n:govoplan-core.all") : value.length === 0
|
||||
? translateText("i18n:govoplan-core.none.6eef6648") : `${selectedCount}/${options.length}`;
|
||||
|
||||
function close(restoreFocus = false) {
|
||||
setOpen(false);
|
||||
if (restoreFocus) triggerRef.current?.focus();
|
||||
}
|
||||
|
||||
useEffect(() => { if (disabled) setOpen(false); }, [disabled]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!visible) return;
|
||||
function place() {
|
||||
const anchor = triggerRef.current?.getBoundingClientRect();
|
||||
if (!anchor) return;
|
||||
const margin = 8;
|
||||
const width = Math.min(Math.max(280, anchor.width), window.innerWidth - margin * 2);
|
||||
const spaceBelow = window.innerHeight - anchor.bottom - margin * 2;
|
||||
const spaceAbove = anchor.top - margin * 2;
|
||||
const above = spaceBelow < 300 && spaceAbove > spaceBelow;
|
||||
const maxHeight = Math.max(80, above ? spaceAbove : spaceBelow);
|
||||
const height = Math.min(popupRef.current?.scrollHeight ?? 360, maxHeight);
|
||||
setPosition({
|
||||
top: above ? Math.max(margin, anchor.top - height - margin) : anchor.bottom + margin,
|
||||
left: Math.max(margin, Math.min(anchor.left, window.innerWidth - width - margin)),
|
||||
width, maxHeight,
|
||||
});
|
||||
}
|
||||
place();
|
||||
window.addEventListener("resize", place);
|
||||
window.addEventListener("scroll", place, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", place);
|
||||
window.removeEventListener("scroll", place, true);
|
||||
};
|
||||
}, [visible, options.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const panel = popupRef.current;
|
||||
if (!panel || !triggerRef.current?.closest("[data-dialog-stack-state]")) return;
|
||||
// The popup must stay in document.body to escape clipping/transforming
|
||||
// containers. Register it with the existing modal stack so the parent's
|
||||
// focus trap does not treat its keyboard controls as unrelated content.
|
||||
nestedDialog.current = true;
|
||||
const unregister = registerDialog({
|
||||
id: dialogStackId.current,
|
||||
activationOrder: nextDialogActivationOrder(),
|
||||
panel,
|
||||
restoreFocus: triggerRef.current,
|
||||
canClose: () => true,
|
||||
onClose: () => setOpen(false)
|
||||
}, document.activeElement);
|
||||
return () => {
|
||||
nestedDialog.current = false;
|
||||
unregister();
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
popupRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
|
||||
function outside(event: PointerEvent | FocusEvent) {
|
||||
const target = event.target as Node | null;
|
||||
if (target && !popupRef.current?.contains(target) && !triggerRef.current?.contains(target)) {
|
||||
// A pointer dismissal belongs to this nested popup, not also to its
|
||||
// newly reactivated parent's backdrop mousedown handler.
|
||||
if (nestedDialog.current && event.type === "pointerdown") event.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("pointerdown", outside);
|
||||
document.addEventListener("focusin", outside);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", outside);
|
||||
document.removeEventListener("focusin", outside);
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<div className={`multi-select-filter ${className}`}>
|
||||
<button ref={triggerRef} type="button" className="btn btn-secondary multi-select-filter-trigger"
|
||||
aria-label={translateText(label)} aria-haspopup="dialog" aria-expanded={visible}
|
||||
aria-controls={visible ? popupId : undefined} disabled={disabled}
|
||||
onClick={() => setOpen((current) => !current)}>
|
||||
<Filter size={16} aria-hidden="true" /><span>{translateText(label)}: {summary}</span><ChevronDown size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{visible && createPortal(
|
||||
<div id={popupId} ref={popupRef} role="dialog" tabIndex={-1} aria-label={translateText(label)}
|
||||
className="data-grid-filter-popover multi-select-filter-popover" style={position}
|
||||
onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(true); } }}>
|
||||
<div className="data-grid-filter-popover-header">
|
||||
<strong>{translateText(label)}</strong>
|
||||
<button type="button" aria-label={translateText("i18n:govoplan-core.close_filter.3a281c3f")} onClick={() => close(true)}><X size={15} aria-hidden="true" /></button>
|
||||
</div>
|
||||
<ListSelectionFilter options={options} value={value} onChange={onChange} label={label} />
|
||||
</div>, document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +1,149 @@
|
||||
import { ArrowDown, ArrowUp, LockKeyhole } from "lucide-react";
|
||||
import type { NavigationPreferences, PlatformNavItem } from "../types";
|
||||
import { useId, useRef, useState, type DragEvent, type KeyboardEvent } from "react";
|
||||
import { ArrowDown, ArrowUp, GripVertical, LockKeyhole, Plus, Trash2 } from "lucide-react";
|
||||
import type { NavigationPreferences, PlatformNavItem, ProductAreaContribution } from "../types";
|
||||
import ActionToolbar from "./ActionToolbar";
|
||||
import Button from "./Button";
|
||||
import IconButton from "./IconButton";
|
||||
import ToggleSwitch from "./ToggleSwitch";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import { navigationEditorTranslations } from "../i18n/navigationEditorTranslations";
|
||||
import { inheritedNavigationLayout, materializeNavigationLayout, moveNavigationEntry, navigationEditorOrder, navigationId, type NavigationPreferenceScope } from "./navigationPreferenceLayout";
|
||||
|
||||
type Scope = "system" | "tenant" | "user";
|
||||
|
||||
export default function NavigationPreferenceEditor({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
scope,
|
||||
disabled = false
|
||||
}: {
|
||||
export default function NavigationPreferenceEditor({ items, productAreas = [], value, onChange, scope, disabled = false }: {
|
||||
items: PlatformNavItem[];
|
||||
productAreas?: ProductAreaContribution[];
|
||||
value: NavigationPreferences | null;
|
||||
onChange: (value: NavigationPreferences | null) => void;
|
||||
scope: Scope;
|
||||
scope: NavigationPreferenceScope;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const inheritedScope = scope === "system" ? "module" : scope === "tenant" ? "system" : "tenant";
|
||||
const inherited = preferenceFromLayer(items, inheritedScope);
|
||||
const editable = value ?? inherited;
|
||||
const { translateText, language, t } = usePlatformLanguage();
|
||||
function navigationText(name: string, values: Record<string, string | number> = {}) {
|
||||
const fallback = navigationEditorTranslations[language]?.[name] ?? navigationEditorTranslations.en[name] ?? name;
|
||||
const template = t(`i18n:govoplan-core.navigation_editor_${name}`, fallback);
|
||||
return template.replace(/\{(\w+)\}/g, (match, field: string) => String(values[field] ?? match));
|
||||
}
|
||||
const instructionsId = useId();
|
||||
const inherited = inheritedNavigationLayout(items, scope, productAreas);
|
||||
const editable = materializeNavigationLayout(value, inherited);
|
||||
const byId = new Map(items.map((item) => [navigationId(item), item]));
|
||||
const inheritedIds = [...items]
|
||||
.sort((left, right) => layerOrder(left, inheritedScope) - layerOrder(right, inheritedScope))
|
||||
.map(navigationId);
|
||||
const orderedIds = [
|
||||
...editable.order.filter((id) => byId.has(id)),
|
||||
...inheritedIds.filter((id) => !editable.order.includes(id))
|
||||
];
|
||||
const hidden = new Set(editable.hidden);
|
||||
const localLocks = new Set(editable.locked ?? []);
|
||||
const inheritedScope = scope === "system" ? "module" : scope === "tenant" ? "system" : "tenant";
|
||||
const ancestorLocked = (id: string) => Boolean(byId.get(id)?.navigationLayers?.[inheritedScope]?.locked);
|
||||
const locked = (id: string) => ancestorLocked(id) || Boolean(editable.locked?.includes(id));
|
||||
const separators = new Map((editable.separators ?? []).map((item) => [item.id, item]));
|
||||
const effective = { ...editable, hidden: editable.hidden.filter((id) => !locked(id)) };
|
||||
const orderedIds = navigationEditorOrder(items, effective);
|
||||
const available = items.filter((item) => effective.hidden.includes(navigationId(item)));
|
||||
const [selectedModule, setSelectedModule] = useState("");
|
||||
const [dragged, setDragged] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const pickup = useRef<{ id: string; original: NavigationPreferences | null } | null>(null);
|
||||
const addId = available.some((item) => navigationId(item) === selectedModule) ? selectedModule : navigationId(available[0] ?? { to: "", label: "" });
|
||||
|
||||
function labelFor(id: string) {
|
||||
return translateText(byId.get(id)?.label ?? separators.get(id)?.label ?? "") || translateText(navigationText("separator"));
|
||||
}
|
||||
|
||||
function update(patch: Partial<NavigationPreferences>) {
|
||||
onChange({ ...editable, ...patch, contract_version: "1" });
|
||||
if (disabled) return;
|
||||
const next = { ...effective, order: orderedIds, ...patch, contract_version: "1" as const };
|
||||
// Optional modules can be temporarily absent. Preserve their stored place
|
||||
// instead of destroying it when an unrelated visible entry is edited.
|
||||
const unavailable = new Set(editable.order.filter((id) => !byId.has(id) && !separators.has(id)));
|
||||
const order = [...next.order];
|
||||
for (const id of editable.order) {
|
||||
if (!unavailable.has(id) || order.includes(id)) continue;
|
||||
const following = editable.order.slice(editable.order.indexOf(id) + 1).find((entry) => order.includes(entry));
|
||||
order.splice(following ? order.indexOf(following) : order.length, 0, id);
|
||||
}
|
||||
onChange({ ...next, order });
|
||||
}
|
||||
|
||||
function move(id: string, offset: -1 | 1) {
|
||||
const index = orderedIds.indexOf(id);
|
||||
const target = index + offset;
|
||||
if (index < 0 || target < 0 || target >= orderedIds.length) return;
|
||||
const next = [...orderedIds];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
const target = orderedIds[orderedIds.indexOf(id) + offset];
|
||||
if (!target || disabled) return;
|
||||
const next = moveNavigationEntry(orderedIds, id, target, offset === 1);
|
||||
update({ order: next });
|
||||
setAnnouncement(translateText(navigationText("moved", { label: labelFor(id), position: next.indexOf(id) + 1, total: next.length })));
|
||||
}
|
||||
|
||||
function setVisible(id: string, visible: boolean) {
|
||||
const next = new Set(hidden);
|
||||
if (visible) next.delete(id);
|
||||
else next.add(id);
|
||||
update({ order: orderedIds, hidden: [...next] });
|
||||
function remove(id: string) {
|
||||
if (locked(id)) return;
|
||||
update({ order: orderedIds.filter((item) => item !== id),
|
||||
hidden: byId.has(id) ? [...new Set([...effective.hidden, id])] : effective.hidden,
|
||||
separators: (effective.separators ?? []).filter((item) => item.id !== id) });
|
||||
}
|
||||
|
||||
function setLocked(id: string, locked: boolean) {
|
||||
const next = new Set(localLocks);
|
||||
if (locked) next.add(id);
|
||||
else next.delete(id);
|
||||
const nextHidden = new Set(hidden);
|
||||
if (locked) nextHidden.delete(id);
|
||||
update({ order: orderedIds, hidden: [...nextHidden], locked: [...next] });
|
||||
function drop(event: DragEvent, target: string) {
|
||||
event.preventDefault();
|
||||
if (disabled || !dragged) return;
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const next = moveNavigationEntry(orderedIds, dragged, target, event.clientY > bounds.top + bounds.height / 2);
|
||||
if (next.some((id, index) => id !== orderedIds[index])) update({ order: next });
|
||||
setAnnouncement(translateText(navigationText("moved", { label: labelFor(dragged), position: next.indexOf(dragged) + 1, total: next.length })));
|
||||
setDragged(null); setDropTarget(null);
|
||||
}
|
||||
|
||||
function keyboardDrag(event: KeyboardEvent, id: string) {
|
||||
if (disabled) return;
|
||||
if (event.key === " " || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (pickup.current) { pickup.current = null; setDragged(null); setAnnouncement(translateText(navigationText("dropped"))); }
|
||||
else { pickup.current = { id, original: value }; setDragged(id); setAnnouncement(translateText(navigationText("picked_up"))); }
|
||||
} else if (pickup.current?.id === id && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
|
||||
event.preventDefault(); move(id, event.key === "ArrowUp" ? -1 : 1);
|
||||
} else if (pickup.current && event.key === "Escape") {
|
||||
event.preventDefault(); onChange(pickup.current.original); pickup.current = null; setDragged(null); setAnnouncement(translateText(navigationText("cancelled")));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="navigation-preference-editor" data-navigation-preference-scope={scope}>
|
||||
<ActionToolbar className="navigation-preference-toolbar" justify="between">
|
||||
<p className="muted small-note">
|
||||
Higher personal settings take precedence over tenant and system order. Locked entries remain visible.
|
||||
</p>
|
||||
<Button onClick={() => onChange(null)} disabled={disabled || value === null}>
|
||||
Use inherited order
|
||||
</Button>
|
||||
<p className="muted small-note" id={instructionsId}>{navigationText("help")}</p>
|
||||
<Button onClick={() => onChange(null)} disabled={disabled || value === null}>{navigationText("inherit")}</Button>
|
||||
</ActionToolbar>
|
||||
<ol className="navigation-preference-list">
|
||||
<ActionToolbar className="navigation-preference-add">
|
||||
<select aria-label={translateText(navigationText("available"))} value={addId} disabled={disabled || available.length === 0} onChange={(event) => setSelectedModule(event.target.value)}>
|
||||
{available.length === 0 && <option value="">{translateText(navigationText("all_added"))}</option>}
|
||||
{available.map((item) => <option key={navigationId(item)} value={navigationId(item)}>{translateText(item.label)}</option>)}
|
||||
</select>
|
||||
<Button disabled={disabled || !addId} onClick={() => update({ order: [...orderedIds, addId], hidden: effective.hidden.filter((id) => id !== addId) })}><Plus size={16} aria-hidden="true" />{navigationText("add_module")}</Button>
|
||||
<Button disabled={disabled || (effective.separators?.length ?? 0) >= 128} onClick={() => {
|
||||
const separator = { id: `separator:${crypto.randomUUID()}`, label: "" };
|
||||
update({ order: [...orderedIds, separator.id], separators: [...(effective.separators ?? []), separator] });
|
||||
}}><Plus size={16} aria-hidden="true" />{navigationText("add_separator")}</Button>
|
||||
</ActionToolbar>
|
||||
<ol className="navigation-preference-list" aria-label={translateText(navigationText("layout"))}>
|
||||
{orderedIds.map((id, index) => {
|
||||
const item = byId.get(id);
|
||||
if (!item) return null;
|
||||
const inheritedState = item.navigationLayers?.[inheritedScope];
|
||||
const ancestorLocked = Boolean(inheritedState?.locked);
|
||||
const locked = ancestorLocked || localLocks.has(id);
|
||||
const label = translateText(item.label);
|
||||
const separator = separators.get(id);
|
||||
const label = labelFor(id);
|
||||
return (
|
||||
<li key={id} data-navigation-id={id} data-navigation-locked={locked ? "true" : "false"}>
|
||||
<li key={id} data-navigation-id={id} data-navigation-kind={separator ? "separator" : "module"} data-navigation-locked={locked(id)} data-dragging={dragged === id} data-drop-target={dropTarget === id}
|
||||
onDragOver={(event) => { if (!disabled && dragged) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; setDropTarget(id); } }} onDrop={(event) => drop(event, id)}>
|
||||
<div className="navigation-preference-order-actions">
|
||||
<IconButton label={`Move ${label} up`} icon={<ArrowUp size={16} />} onClick={() => move(id, -1)} disabled={disabled || index === 0} />
|
||||
<IconButton label={`Move ${label} down`} icon={<ArrowDown size={16} />} onClick={() => move(id, 1)} disabled={disabled || index === orderedIds.length - 1} />
|
||||
<IconButton label={navigationText("reorder", { label })} icon={<GripVertical size={16} />} className="navigation-preference-drag" disabled={disabled} draggable={!disabled}
|
||||
aria-describedby={instructionsId} aria-pressed={dragged === id} onKeyDown={(event) => keyboardDrag(event, id)}
|
||||
onDragStart={(event) => { setDragged(id); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", id); }} onDragEnd={() => { setDragged(null); setDropTarget(null); }} />
|
||||
<IconButton label={navigationText("up", { label })} icon={<ArrowUp size={16} />} onClick={() => move(id, -1)} disabled={disabled || index === 0} />
|
||||
<IconButton label={navigationText("down", { label })} icon={<ArrowDown size={16} />} onClick={() => move(id, 1)} disabled={disabled || index === orderedIds.length - 1} />
|
||||
</div>
|
||||
<div className="navigation-preference-label">
|
||||
<strong>{label}</strong>
|
||||
<span>{id}</span>
|
||||
{separator ? <label><span>{navigationText("separator_label")}</span><input value={translateText(separator.label)} maxLength={120} disabled={disabled} placeholder={translateText(navigationText("separator"))} onChange={(event) => update({ separators: (effective.separators ?? []).map((entry) => entry.id === id ? { ...entry, label: event.target.value } : entry) })} /></label> : <><strong>{label}</strong><span>{id}</span></>}
|
||||
</div>
|
||||
<div className="navigation-preference-item-actions">
|
||||
{item && (scope === "system" || scope === "tenant") && <ToggleSwitch label={<><LockKeyhole size={14} aria-hidden="true" />{navigationText("locked")}</>} checked={locked(id)} disabled={disabled || ancestorLocked(id)} onChange={(next) => update({ locked: next ? [...new Set([...(effective.locked ?? []), id])] : (effective.locked ?? []).filter((entry) => entry !== id), hidden: effective.hidden.filter((entry) => entry !== id) })} />}
|
||||
{item && locked(id) && <span className="muted small-note" title={translateText(navigationText("locked_help"))}><LockKeyhole size={14} aria-label={translateText(navigationText("locked"))} /></span>}
|
||||
<IconButton label={navigationText("remove", { label })} icon={<Trash2 size={16} />} disabled={disabled || locked(id)} disabledReason={locked(id) ? navigationText("locked_help") : undefined} onClick={() => remove(id)} />
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
label="Visible"
|
||||
checked={locked || !hidden.has(id)}
|
||||
disabled={disabled || locked}
|
||||
help={locked ? `Locked by ${inheritedState?.lock_source ?? scope}` : undefined}
|
||||
onChange={(visible) => setVisible(id, visible)}
|
||||
/>
|
||||
{scope !== "user" && (
|
||||
<ToggleSwitch
|
||||
label={<><LockKeyhole size={14} aria-hidden="true" /> Locked</>}
|
||||
checked={locked}
|
||||
disabled={disabled || ancestorLocked}
|
||||
help={ancestorLocked ? `Locked by ${inheritedState?.lock_source}` : "Lower scopes cannot hide this entry."}
|
||||
onChange={(next) => setLocked(id, next)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{orderedIds.length === 0 && <p className="muted">{navigationText("empty")}</p>}
|
||||
<p className="visually-hidden" role="status" aria-live="polite">{announcement}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function navigationId(item: PlatformNavItem): string {
|
||||
return item.navigationId ?? item.surfaceId ?? item.to;
|
||||
}
|
||||
|
||||
function preferenceFromLayer(
|
||||
items: PlatformNavItem[],
|
||||
layer: "module" | "system" | "tenant"
|
||||
): NavigationPreferences {
|
||||
const ordered = [...items].sort((left, right) => layerOrder(left, layer) - layerOrder(right, layer));
|
||||
return {
|
||||
contract_version: "1",
|
||||
order: ordered.map(navigationId),
|
||||
hidden: ordered.filter((item) => item.navigationLayers?.[layer]?.visible === false).map(navigationId),
|
||||
locked: []
|
||||
};
|
||||
}
|
||||
|
||||
function layerOrder(item: PlatformNavItem, layer: "module" | "system" | "tenant"): number {
|
||||
return item.navigationLayers?.[layer]?.order ?? item.order ?? 100;
|
||||
}
|
||||
|
||||
@@ -291,10 +291,9 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
|
||||
: undefined}
|
||||
data-page-dirty={variant === "editor" ? (state === "clean" ? "false" : "true") : undefined}
|
||||
>
|
||||
<ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading">
|
||||
{refreshable ? <ActionSlot name="reload"><ReloadAction action={reloadAction!} /></ActionSlot> : null}
|
||||
{contextActions ? <ActionSlot name="context">{contextActions}</ActionSlot> : null}
|
||||
</ToolbarGroup>
|
||||
{contextActions ? <ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading">
|
||||
<ActionSlot name="context">{contextActions}</ActionSlot>
|
||||
</ToolbarGroup> : null}
|
||||
<ToolbarGroup className="page-action-bar-trailing" align="end" data-page-action-group="trailing">
|
||||
{variant === "editor" ? (
|
||||
<span
|
||||
@@ -317,6 +316,7 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
|
||||
</span>
|
||||
) : null}
|
||||
{helpAction ? <ActionSlot name="help">{helpAction}</ActionSlot> : null}
|
||||
{refreshable ? <ActionSlot name="reload"><ReloadAction action={reloadAction!} /></ActionSlot> : null}
|
||||
{trailingActions}
|
||||
</ToolbarGroup>
|
||||
</ActionToolbar>
|
||||
|
||||
@@ -44,6 +44,7 @@ import FormField from "./FormField";
|
||||
import IconButton from "./IconButton";
|
||||
import SegmentedControl from "./SegmentedControl";
|
||||
import { normalizeWysiwygImageUrl, normalizeWysiwygLinkUrl } from "./wysiwygEditorUrls";
|
||||
import { isWysiwygDocumentUpdate } from "./wysiwygEditorUpdates";
|
||||
|
||||
export type WysiwygEditorMode = "visual" | "source";
|
||||
|
||||
@@ -282,9 +283,11 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
|
||||
}
|
||||
},
|
||||
onFocus: () => onFocusRef.current?.(),
|
||||
onUpdate: ({ editor: currentEditor }) => {
|
||||
onUpdate: ({ editor: currentEditor, transaction, appendedTransactions }) => {
|
||||
if (!isWysiwygDocumentUpdate(transaction, appendedTransactions)) return;
|
||||
const nextValue = editorHtmlValue(currentEditor);
|
||||
appliedValueRef.current = nextValue;
|
||||
if (nextValue === valueRef.current) return;
|
||||
onChangeRef.current(nextValue);
|
||||
}
|
||||
});
|
||||
@@ -313,7 +316,9 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
|
||||
} as CSSProperties;
|
||||
|
||||
useEffect(() => {
|
||||
editor.setEditable(!disabled);
|
||||
// Tiptap otherwise emits an update even when the document did not change.
|
||||
// Mounting or locking an editor must not normalize and dirty stored HTML.
|
||||
editor.setEditable(!disabled, false);
|
||||
}, [disabled, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -381,9 +386,8 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
|
||||
window.requestAnimationFrame(() => editor.commands.focus("end"));
|
||||
return;
|
||||
}
|
||||
const nextValue = editorHtmlValue(editor);
|
||||
appliedValueRef.current = nextValue;
|
||||
if (nextValue !== valueRef.current) onChangeRef.current(nextValue);
|
||||
// Real visual edits already publish through onUpdate. Merely inspecting
|
||||
// source must preserve the supplied HTML, including legacy formatting.
|
||||
setMode("source");
|
||||
window.requestAnimationFrame(() => sourceRef.current?.focus());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/** Synchronous theme application and validation; no settings UI dependencies. */
|
||||
import type { AppearanceOverrideToken, AppearanceOverridesDocument } from "../types";
|
||||
|
||||
export const APPEARANCE_OVERRIDE_TOKENS: readonly AppearanceOverrideToken[] = [
|
||||
"accent", "accent_foreground", "surface", "surface_foreground",
|
||||
"success", "success_foreground", "info", "info_foreground",
|
||||
"warning", "warning_foreground", "danger", "danger_foreground"
|
||||
];
|
||||
|
||||
export const STATUS_TOKENS: readonly AppearanceOverrideToken[] = ["success", "info", "warning", "danger"];
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
const RUNTIME_PROPERTIES = new Set<string>();
|
||||
|
||||
const RUNTIME_TOKEN_PROPERTIES: Record<AppearanceOverrideToken, readonly string[]> = {
|
||||
accent: ["--accent", "--action-primary-bg"],
|
||||
accent_foreground: ["--on-accent", "--badge-accent-text", "--action-primary-text"],
|
||||
surface: ["--surface", "--panel-soft"],
|
||||
surface_foreground: ["--text", "--text-strong"],
|
||||
success: ["--success-bg", "--success-soft"],
|
||||
success_foreground: ["--success-text", "--success-text-strong"],
|
||||
info: ["--info-bg", "--info-soft"],
|
||||
info_foreground: ["--info-text", "--info-text-strong", "--info-text-deep"],
|
||||
warning: ["--warning-bg", "--warning-soft"],
|
||||
warning_foreground: ["--warning-text", "--warning-text-strong"],
|
||||
danger: ["--danger-bg", "--danger-soft"],
|
||||
danger_foreground: ["--danger-text", "--danger-text-strong", "--danger-text-deep"]
|
||||
};
|
||||
|
||||
for (const properties of Object.values(RUNTIME_TOKEN_PROPERTIES)) {
|
||||
for (const property of properties) RUNTIME_PROPERTIES.add(property);
|
||||
}
|
||||
|
||||
export const DEFAULT_APPEARANCE_OVERRIDES: AppearanceOverridesDocument = {
|
||||
schema_version: "1",
|
||||
light: {
|
||||
accent: "#245f91", accent_foreground: "#ffffff",
|
||||
surface: "#ffffff", surface_foreground: "#303135",
|
||||
success: "#d8eee8", success_foreground: "#315f55",
|
||||
info: "#dce9f3", info_foreground: "#294a61",
|
||||
warning: "#ffe1a3", warning_foreground: "#593700",
|
||||
danger: "#f8d1cc", danger_foreground: "#873c35"
|
||||
},
|
||||
dark: {
|
||||
accent: "#7ea6c5", accent_foreground: "#242424",
|
||||
surface: "#262724", surface_foreground: "#f1f1f1",
|
||||
success: "#24473f", success_foreground: "#d8eee8",
|
||||
info: "#243d4e", info_foreground: "#dce9f3",
|
||||
warning: "#5a431f", warning_foreground: "#ffe1a3",
|
||||
danger: "#4f2d2a", danger_foreground: "#f8d1cc"
|
||||
}
|
||||
};
|
||||
|
||||
export function cloneDefaultAppearanceOverrides(): AppearanceOverridesDocument {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_APPEARANCE_OVERRIDES)) as AppearanceOverridesDocument;
|
||||
}
|
||||
|
||||
export function validateAppearanceOverrides(value: unknown): AppearanceOverridesDocument {
|
||||
if (!isRecord(value) || value.schema_version !== "1" || !isRecord(value.light) || !isRecord(value.dark)) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
|
||||
}
|
||||
if (!hasExactKeys(value, ["schema_version", "light", "dark"])) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
|
||||
}
|
||||
const document = value as unknown as AppearanceOverridesDocument;
|
||||
for (const modeName of ["light", "dark"] as const) {
|
||||
const mode = document[modeName];
|
||||
if (!hasExactKeys(mode, APPEARANCE_OVERRIDE_TOKENS)) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_all_tokens_required");
|
||||
}
|
||||
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
|
||||
if (typeof mode[token] !== "string" || !HEX_COLOR.test(mode[token])) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_hex_required");
|
||||
}
|
||||
}
|
||||
for (const [background, foreground] of [
|
||||
["accent", "accent_foreground"], ["surface", "surface_foreground"],
|
||||
["success", "success_foreground"], ["info", "info_foreground"],
|
||||
["warning", "warning_foreground"], ["danger", "danger_foreground"]
|
||||
] as const) {
|
||||
if (contrastRatio(mode[background], mode[foreground]) < 4.5) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_contrast_error");
|
||||
}
|
||||
}
|
||||
for (let first = 0; first < STATUS_TOKENS.length; first += 1) {
|
||||
for (let second = first + 1; second < STATUS_TOKENS.length; second += 1) {
|
||||
if (rgbDistance(mode[STATUS_TOKENS[first]], mode[STATUS_TOKENS[second]]) < 12) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_status_error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
export function applyAppearanceOverrides(
|
||||
root: HTMLElement,
|
||||
document: AppearanceOverridesDocument | null,
|
||||
theme: "light" | "dark"
|
||||
) {
|
||||
for (const property of RUNTIME_PROPERTIES) root.style.removeProperty(property);
|
||||
if (!document) return;
|
||||
let validated: AppearanceOverridesDocument;
|
||||
try {
|
||||
validated = validateAppearanceOverrides(document);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const colors = validated[theme];
|
||||
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
|
||||
for (const property of RUNTIME_TOKEN_PROPERTIES[token]) {
|
||||
root.style.setProperty(property, colors[token]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasExactKeys(value: object, keys: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function relativeLuminance(color: string): number {
|
||||
const channels = [1, 3, 5].map((index) => Number.parseInt(color.slice(index, index + 2), 16) / 255);
|
||||
const linear = channels.map((channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
||||
}
|
||||
|
||||
function contrastRatio(first: string, second: string): number {
|
||||
const luminances = [relativeLuminance(first), relativeLuminance(second)].sort((left, right) => right - left);
|
||||
return (luminances[0] + 0.05) / (luminances[1] + 0.05);
|
||||
}
|
||||
|
||||
function rgbDistance(first: string, second: string): number {
|
||||
return Math.sqrt([1, 3, 5].reduce((total, index) => {
|
||||
const delta = Number.parseInt(first.slice(index, index + 2), 16) - Number.parseInt(second.slice(index, index + 2), 16);
|
||||
return total + delta * delta;
|
||||
}, 0));
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { NavigationPreferences, PlatformNavItem, ProductAreaContribution } from "../types";
|
||||
import { groupNavigationItems } from "../platform/productAreas";
|
||||
|
||||
export type NavigationPreferenceScope = "system" | "tenant" | "user" | "view";
|
||||
|
||||
export function navigationId(item: PlatformNavItem): string {
|
||||
return item.navigationId ?? item.surfaceId ?? item.to;
|
||||
}
|
||||
|
||||
export function inheritedNavigationLayout(items: PlatformNavItem[], scope: NavigationPreferenceScope, productAreas: ProductAreaContribution[]): NavigationPreferences {
|
||||
const layer = scope === "system" ? "module" : scope === "tenant" ? "system" : "tenant";
|
||||
const ordered = items.map((item) => ({
|
||||
...item,
|
||||
order: item.navigationLayers?.[layer]?.order ?? item.order ?? 100,
|
||||
navigationCustomLayout: item.navigationLayers?.[layer]?.custom_layout ?? false,
|
||||
navigationSection: item.navigationLayers?.[layer]?.section,
|
||||
navigationLayoutSource: item.navigationLayers?.[layer]?.layout_source ?? "module",
|
||||
navigationOrderSource: layer
|
||||
})).sort((left, right) => left.order - right.order);
|
||||
const groups = groupNavigationItems(ordered, productAreas);
|
||||
const order: string[] = [];
|
||||
const separators: NonNullable<NavigationPreferences["separators"]> = [];
|
||||
groups.forEach((group, index) => {
|
||||
if (group.label !== undefined || index > 0) {
|
||||
const id = group.id.startsWith("separator:") ? group.id : `separator:${group.id}`;
|
||||
separators.push({ id, label: group.label ?? "" });
|
||||
order.push(id);
|
||||
}
|
||||
order.push(...group.items.map(navigationId));
|
||||
});
|
||||
return { contract_version: "1", order, separators,
|
||||
hidden: ordered.filter((item) => item.navigationLayers?.[layer]?.visible === false).map(navigationId), locked: [] };
|
||||
}
|
||||
|
||||
export function navigationEditorOrder(items: PlatformNavItem[], value: NavigationPreferences): string[] {
|
||||
const available = new Set([...items.map(navigationId), ...(value.separators ?? []).map((item) => item.id)]);
|
||||
const hidden = new Set(value.hidden);
|
||||
return [...new Set([...value.order, ...items.map(navigationId)])].filter((id) => available.has(id) && !hidden.has(id));
|
||||
}
|
||||
|
||||
/** Upgrade legacy order-only drafts without losing their order or group markers. */
|
||||
export function materializeNavigationLayout(value: NavigationPreferences | null, inherited: NavigationPreferences): NavigationPreferences {
|
||||
if (!value) return inherited;
|
||||
if (value.separators != null) return value;
|
||||
const separators = new Map((inherited.separators ?? []).map((item) => [item.id, item]));
|
||||
const sectionByItem = new Map<string, string>();
|
||||
let section: string | null = null;
|
||||
for (const id of inherited.order) {
|
||||
if (separators.has(id)) section = id;
|
||||
else if (section) sectionByItem.set(id, section);
|
||||
}
|
||||
const order: string[] = [];
|
||||
const placed = new Set<string>();
|
||||
for (const id of new Set([...value.order, ...inherited.order.filter((id) => !separators.has(id))])) {
|
||||
const group = sectionByItem.get(id);
|
||||
if (group && !placed.has(group)) { order.push(group); placed.add(group); }
|
||||
order.push(id);
|
||||
}
|
||||
return { ...value, order, separators: inherited.separators };
|
||||
}
|
||||
|
||||
export function moveNavigationEntry(order: string[], id: string, target: string, after = false): string[] {
|
||||
if (id === target || !order.includes(id) || !order.includes(target)) return order;
|
||||
const next = order.filter((item) => item !== id);
|
||||
next.splice(next.indexOf(target) + (after ? 1 : 0), 0, id);
|
||||
return next;
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react";
|
||||
import StatusBadge from "../StatusBadge";
|
||||
import ListSelectionFilter, { type ListFilterOption } from "../ListSelectionFilter";
|
||||
import TableActionGroup from "./TableActionGroup";
|
||||
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
|
||||
import {
|
||||
DATA_GRID_MAX_TRACK_WIDTH,
|
||||
dataGridActionTrackMinimum,
|
||||
dataGridColumnPixelWidth as columnPixelWidth,
|
||||
dataGridColumnTrackWithMinimum as columnTrackWithMinimum,
|
||||
dataGridLayoutSignature,
|
||||
@@ -23,11 +25,7 @@ export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "da
|
||||
export type DataGridInitialFit = "content" | "container";
|
||||
export type DataGridResizeBehavior = "free" | "cover" | "constrained";
|
||||
|
||||
export type DataGridListOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export type DataGridListOption = ListFilterOption;
|
||||
|
||||
export type DataGridListConfig<T> = {
|
||||
options: DataGridListOption[];
|
||||
@@ -97,7 +95,8 @@ export type DataGridColumn<T> = {
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
filterType?: DataGridFilterType;
|
||||
columnType?: "default" | "from-list";
|
||||
/** Canonical TableActionGroup content is recognized automatically. Mark custom action controls explicitly. */
|
||||
columnType?: "default" | "from-list" | "actions";
|
||||
list?: DataGridListConfig<T>;
|
||||
sticky?: "start" | "end";
|
||||
align?: "left" | "center" | "right";
|
||||
@@ -200,6 +199,8 @@ type ColumnResizeState = {
|
||||
uncompensatedShrinkRoom: number;
|
||||
behavior: DataGridResizeBehavior;
|
||||
containerWidth: number;
|
||||
pointerId: number;
|
||||
previousState: DataGridState;
|
||||
};
|
||||
|
||||
const STORAGE_PREFIX = "govoplan.datagrid.";
|
||||
@@ -261,6 +262,11 @@ export default function DataGrid<T>({
|
||||
const serverPaginationRef = useRef<DataGridServerPagination | null>(serverQueryMode ? pagination : null);
|
||||
const lastQueryRef = useRef<DataGridQueryState | null>(null);
|
||||
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
|
||||
const [actionWidths, setActionWidths] = useState<Record<string, number>>({});
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const sizingColumns = useMemo(() => columns.map((column) => actionWidths[column.id] !== undefined
|
||||
? { ...column, minWidth: dataGridActionTrackMinimum(column, actionWidths[column.id], containerWidth) }
|
||||
: column), [columns, actionWidths, containerWidth]);
|
||||
|
||||
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
|
||||
|
||||
@@ -322,7 +328,7 @@ export default function DataGrid<T>({
|
||||
for (const column of columns) {
|
||||
const element = headerCellRefs.current[column.id];
|
||||
if (!element) continue;
|
||||
const width = Math.round(element.getBoundingClientRect().width);
|
||||
const width = Math.round(element.getBoundingClientRect().width * 100) / 100;
|
||||
if (width > 0) next[column.id] = width;
|
||||
}
|
||||
setMeasuredWidths((current) => shallowEqualNumberRecords(current, next) ? current : next);
|
||||
@@ -340,7 +346,7 @@ export default function DataGrid<T>({
|
||||
if (element) observer.observe(element);
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
}, [columns, state.widths]);
|
||||
}, [columns]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = scrollRegionRef.current;
|
||||
@@ -353,16 +359,17 @@ export default function DataGrid<T>({
|
||||
animationFrame = window.requestAnimationFrame(() => {
|
||||
const nextContainerWidth = Math.round(scrollElement.clientWidth);
|
||||
if (nextContainerWidth <= 0) return;
|
||||
setContainerWidth(nextContainerWidth);
|
||||
|
||||
setState((current) => {
|
||||
const signatureMatches = current.layoutSignature === layoutSignature;
|
||||
const userWidths = signatureMatches ? current.userWidths ?? {} : {};
|
||||
const mustFit = effectiveResizeBehavior !== "free" || resolvedInitialFit === "container";
|
||||
if (!mustFit) {
|
||||
if (signatureMatches && current.widths === undefined) return current;
|
||||
if (signatureMatches && shallowEqualNumberRecords(current.widths ?? {}, userWidths)) return current;
|
||||
return {
|
||||
...current,
|
||||
widths: undefined,
|
||||
widths: userWidths,
|
||||
userWidths,
|
||||
layoutSignature,
|
||||
fillColumnId: undefined
|
||||
@@ -370,7 +377,7 @@ export default function DataGrid<T>({
|
||||
}
|
||||
|
||||
const layout = fitDataGridColumns(
|
||||
columns,
|
||||
sizingColumns,
|
||||
nextContainerWidth,
|
||||
measuredWidths,
|
||||
userWidths,
|
||||
@@ -407,16 +414,17 @@ export default function DataGrid<T>({
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [columns, layoutSignature, effectiveResizeBehavior, resolvedInitialFit, measuredWidths, resizeState]);
|
||||
}, [sizingColumns, layoutSignature, effectiveResizeBehavior, resolvedInitialFit, measuredWidths, resizeState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizeState) return;
|
||||
const activeResize = resizeState;
|
||||
|
||||
function onMove(event: MouseEvent) {
|
||||
function onMove(event: PointerEvent) {
|
||||
if (event.pointerId !== activeResize.pointerId) return;
|
||||
const rawDelta = event.clientX - activeResize.startX;
|
||||
const resized = resizeDataGridColumn(
|
||||
columns,
|
||||
sizingColumns,
|
||||
activeResize.baseWidths,
|
||||
activeResize.columnId,
|
||||
rawDelta,
|
||||
@@ -438,9 +446,10 @@ export default function DataGrid<T>({
|
||||
}));
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
function onUp(event?: PointerEvent) {
|
||||
if (event && event.pointerId !== activeResize.pointerId) return;
|
||||
setState((current) => sanitizePersistedColumnState(
|
||||
columns,
|
||||
sizingColumns,
|
||||
current,
|
||||
effectiveResizeBehavior,
|
||||
layoutSignature
|
||||
@@ -448,13 +457,29 @@ export default function DataGrid<T>({
|
||||
setResizeState(null);
|
||||
}
|
||||
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
function cancel(event: PointerEvent | KeyboardEvent) {
|
||||
if ("pointerId" in event && event.pointerId !== activeResize.pointerId) return;
|
||||
if ("key" in event && event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
if ("key" in event) event.stopPropagation();
|
||||
setState(activeResize.previousState);
|
||||
setResizeState(null);
|
||||
}
|
||||
function onBlur() { onUp(); }
|
||||
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", cancel);
|
||||
window.addEventListener("keydown", cancel, true);
|
||||
window.addEventListener("blur", onBlur);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", cancel);
|
||||
window.removeEventListener("keydown", cancel, true);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
};
|
||||
}, [resizeState, columns, effectiveResizeBehavior, layoutSignature]);
|
||||
}, [resizeState, sizingColumns, effectiveResizeBehavior, layoutSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openFilterColumnId) return undefined;
|
||||
@@ -549,18 +574,71 @@ export default function DataGrid<T>({
|
||||
const paginationPageSize = Math.max(1, pagination?.pageSize ?? Math.max(1, visibleRows.length));
|
||||
const paginationPageCount = Math.max(1, Math.ceil(paginationTotal / paginationPageSize));
|
||||
const paginationPage = pagination ? Math.min(paginationPageCount, Math.max(1, pagination.page)) : 1;
|
||||
const renderedRows = pagination && paginationMode === "client" ?
|
||||
visibleRows.slice((paginationPage - 1) * paginationPageSize, paginationPage * paginationPageSize) :
|
||||
visibleRows;
|
||||
const renderedRows = useMemo(() => pagination && paginationMode === "client" ?
|
||||
visibleRows.slice((paginationPage - 1) * paginationPageSize, paginationPage * paginationPageSize) :
|
||||
visibleRows, [Boolean(pagination), paginationMode, paginationPage, paginationPageSize, visibleRows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pagination && pagination.page !== paginationPage) pagination.onPageChange(paginationPage);
|
||||
}, [pagination, paginationPage]);
|
||||
|
||||
const actualTracks = columns.map((column) => widthForColumn(column, state.widths?.[column.id]));
|
||||
useLayoutEffect(() => {
|
||||
const region = scrollRegionRef.current;
|
||||
if (!region) return;
|
||||
let frame = 0;
|
||||
function measure() {
|
||||
const next: Record<string, number> = {};
|
||||
for (const cell of Array.from(region!.querySelectorAll<HTMLElement>(".data-grid-body-cell[data-column-id]"))) {
|
||||
const columnId = cell.dataset.columnId!;
|
||||
const explicitActions = columns.some((column) => column.id === columnId && column.columnType === "actions");
|
||||
const groups = cell.querySelectorAll<HTMLElement>(".table-action-group");
|
||||
if (!groups.length && !explicitActions) continue;
|
||||
const cellStyle = window.getComputedStyle(cell);
|
||||
const cellInsets = cssPixels(cellStyle.paddingLeft) + cssPixels(cellStyle.paddingRight)
|
||||
+ cssPixels(cellStyle.borderLeftWidth) + cssPixels(cellStyle.borderRightWidth);
|
||||
// Measure action slots, never arbitrary row text (long titles must not
|
||||
// inflate an entire grid). Include disabled wrappers and reserved slots.
|
||||
const contentWidth = explicitActions
|
||||
? measureActionGroup(cell)
|
||||
: Math.max(...Array.from(groups, measureActionGroup)) + cellInsets;
|
||||
next[columnId] = Math.max(next[columnId] ?? 0, Math.ceil(contentWidth));
|
||||
}
|
||||
setActionWidths((current) => shallowEqualNumberRecords(current, next) ? current : next);
|
||||
}
|
||||
function schedule() {
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = window.requestAnimationFrame(measure);
|
||||
}
|
||||
measure();
|
||||
const groups = region.querySelectorAll<HTMLElement>(".table-action-group, .data-grid-action-cell > *");
|
||||
const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(schedule) : null;
|
||||
const mutationObserver = typeof MutationObserver !== "undefined" ? new MutationObserver(schedule) : null;
|
||||
for (const group of Array.from(groups)) {
|
||||
resizeObserver?.observe(group);
|
||||
mutationObserver?.observe(group, { childList: true, subtree: true, attributes: true, characterData: true });
|
||||
}
|
||||
window.addEventListener("resize", schedule);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
window.removeEventListener("resize", schedule);
|
||||
};
|
||||
}, [columns, renderedRows, emptyAction]);
|
||||
|
||||
const actualTracks = sizingColumns.map((column) => widthForColumn(column, state.widths?.[column.id]));
|
||||
const templateColumns = actualTracks.join(" ");
|
||||
const pixelLayoutWidth = sizingColumns.every((column) => state.widths?.[column.id] !== undefined)
|
||||
? sizingColumns.reduce((total, column) => total + Math.max(effectiveColumnMinWidth(column), state.widths![column.id]), 0)
|
||||
: undefined;
|
||||
const hasFlexibleColumns = columns.some((column) => !state.widths?.[column.id] && isFlexibleColumn(column));
|
||||
const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]);
|
||||
const stickyOffsets = useMemo(() => computeStickyOffsets(sizingColumns, state.widths, measuredWidths), [sizingColumns, state.widths, measuredWidths]);
|
||||
const stickyWidth = sizingColumns.reduce((total, column) => column.sticky
|
||||
? total + Math.max(effectiveColumnMinWidth(column), state.widths?.[column.id] ?? measuredWidths[column.id] ?? 0)
|
||||
: total, 0);
|
||||
// Very wide explicit/persisted sticky tracks must not obscure all data. They
|
||||
// remain reachable through the same keyboard-accessible horizontal scroller.
|
||||
const releaseStickyColumns = containerWidth > 0 && stickyWidth > containerWidth - Math.min(120, containerWidth / 2);
|
||||
const gridClassName = [
|
||||
"data-grid",
|
||||
`data-grid-fit-${resolvedInitialFit}`,
|
||||
@@ -613,14 +691,49 @@ export default function DataGrid<T>({
|
||||
});
|
||||
}
|
||||
|
||||
function resizeBase(columnId: string) {
|
||||
const baseWidths = measuredColumnWidths(sizingColumns, headerCellRefs.current, state.widths, measuredWidths);
|
||||
const index = columns.findIndex((column) => column.id === columnId);
|
||||
const lastResizable = !columns.slice(index + 1).some(isResizeCompensationColumn);
|
||||
const region = scrollRegionRef.current;
|
||||
const overflow = region ? Math.max(0, region.scrollWidth - region.clientWidth) : 0;
|
||||
return {
|
||||
baseWidths,
|
||||
uncompensatedShrinkRoom: region && !lastResizable ? Math.max(0, overflow - region.scrollLeft) : overflow,
|
||||
containerWidth: Math.max(1, region?.clientWidth ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
function resizeByKeyboard(columnId: string, delta: number) {
|
||||
const base = resizeBase(columnId);
|
||||
const resized = resizeDataGridColumn(sizingColumns, base.baseWidths, columnId, delta, effectiveResizeBehavior, base.uncompensatedShrinkRoom);
|
||||
setState((current) => ({
|
||||
...current,
|
||||
widths: resized.widths,
|
||||
userWidths: effectiveResizeBehavior === "free" ? { ...current.userWidths, [columnId]: resized.widths[columnId] } : resized.widths,
|
||||
userLayoutContainerWidth: base.containerWidth,
|
||||
layoutSignature
|
||||
}));
|
||||
}
|
||||
|
||||
function resetColumnWidth(columnId: string) {
|
||||
setResizeState(null);
|
||||
setState((current) => {
|
||||
const userWidths = { ...current.userWidths };
|
||||
delete userWidths[columnId];
|
||||
const fitted = fitDataGridColumns(sizingColumns, containerWidth, measuredWidths, userWidths, effectiveResizeBehavior, current.userLayoutContainerWidth);
|
||||
return { ...current, userWidths, widths: fitted.widths };
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`data-grid-shell data-grid-${resolvedInitialFit} data-grid-shell-resize-${effectiveResizeBehavior} ${className}`.trim()}
|
||||
className={`data-grid-shell data-grid-${resolvedInitialFit} data-grid-shell-resize-${effectiveResizeBehavior} ${releaseStickyColumns ? "data-grid-release-sticky" : ""} ${className}`.trim()}
|
||||
data-resize-behavior={effectiveResizeBehavior}
|
||||
data-requested-resize-behavior={resizeBehavior}>
|
||||
|
||||
<div className="data-grid-scroll-region" ref={scrollRegionRef}>
|
||||
<div className={gridClassName} role="table" aria-label={id} style={{ gridTemplateColumns: templateColumns }}>
|
||||
<div className="data-grid-scroll-region" ref={scrollRegionRef} tabIndex={0} role="region" aria-label={id}>
|
||||
<div className={gridClassName} role="table" aria-label={id} style={{ gridTemplateColumns: templateColumns, width: pixelLayoutWidth }}>
|
||||
{columns.map((column, columnIndex) => {
|
||||
const sorted = state.sort?.columnId === column.id ? state.sort.direction : undefined;
|
||||
const hasFilter = Boolean((state.filters?.[column.id] ?? "").trim());
|
||||
@@ -628,6 +741,7 @@ export default function DataGrid<T>({
|
||||
<div
|
||||
key={`header-${column.id}`}
|
||||
role="columnheader"
|
||||
data-column-id={column.id}
|
||||
ref={(element) => {headerCellRefs.current[column.id] = element;}}
|
||||
className={`data-grid-cell data-grid-header-cell ${column.headerClassName ?? ""} ${column.sortable ? "is-sortable" : ""} ${sorted ? "is-sorted" : ""} ${stickyClass(column)}`.trim()}
|
||||
style={stickyStyle(column, stickyOffsets[columnIndex])}>
|
||||
@@ -659,35 +773,44 @@ export default function DataGrid<T>({
|
||||
<button
|
||||
type="button"
|
||||
className="data-grid-resize-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={effectiveColumnMinWidth(sizingColumns[columnIndex])}
|
||||
aria-valuemax={Math.max(state.widths?.[column.id] ?? 0, effectiveColumnMaxWidth(sizingColumns[columnIndex]))}
|
||||
aria-valuenow={Math.round(state.widths?.[column.id] ?? measuredWidths[column.id] ?? effectiveColumnMinWidth(sizingColumns[columnIndex]))}
|
||||
title={translateText("i18n:govoplan-core.data_grid_resize_help")}
|
||||
aria-description={translateText("i18n:govoplan-core.data_grid_resize_help")}
|
||||
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", { value0: translateText("i18n:govoplan-core.resize.f52dc753"), value1: translateHeaderLabel(column.header, translateText) })}
|
||||
onMouseDown={(event) => {
|
||||
onDoubleClick={() => resetColumnWidth(column.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
resetColumnWidth(column.id);
|
||||
} else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
resizeByKeyboard(column.id, (event.key === "ArrowLeft" ? -1 : 1) * (event.shiftKey ? 40 : 10));
|
||||
}
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0 || !event.isPrimary) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, state.widths, measuredWidths);
|
||||
const activeColumnIndex = columns.findIndex((candidate) => candidate.id === column.id);
|
||||
const isLastResizableColumn = !columns.
|
||||
slice(activeColumnIndex + 1).
|
||||
some(isResizeCompensationColumn);
|
||||
const scrollElement = scrollRegionRef.current;
|
||||
const totalHorizontalOverflow = scrollElement ?
|
||||
Math.max(0, scrollElement.scrollWidth - scrollElement.clientWidth) :
|
||||
0;
|
||||
const shrinkRoomWithoutScroll = scrollElement && !isLastResizableColumn ?
|
||||
Math.max(0, totalHorizontalOverflow - scrollElement.scrollLeft) :
|
||||
totalHorizontalOverflow;
|
||||
event.currentTarget.focus();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
const base = resizeBase(column.id);
|
||||
|
||||
setState((current) => ({
|
||||
...current,
|
||||
widths: roundWidthRecord(baseWidths),
|
||||
widths: roundWidthRecord(base.baseWidths),
|
||||
fillColumnId: undefined
|
||||
}));
|
||||
setResizeState({
|
||||
columnId: column.id,
|
||||
startX: event.clientX,
|
||||
baseWidths,
|
||||
uncompensatedShrinkRoom: shrinkRoomWithoutScroll,
|
||||
...base,
|
||||
behavior: effectiveResizeBehavior,
|
||||
containerWidth: Math.max(1, scrollElement?.clientWidth ?? 0)
|
||||
pointerId: event.pointerId,
|
||||
previousState: state
|
||||
});
|
||||
}}>
|
||||
|
||||
@@ -712,7 +835,8 @@ export default function DataGrid<T>({
|
||||
{translatedEmptyText}
|
||||
</div>
|
||||
<div
|
||||
className={`data-grid-cell data-grid-body-cell data-grid-empty-action-cell data-grid-row-even is-last-row ${stickyClass(actionColumn)}`.trim()}
|
||||
className={`data-grid-cell data-grid-body-cell data-grid-action-cell data-grid-empty-action-cell data-grid-row-even is-last-row ${stickyClass(actionColumn)}`.trim()}
|
||||
data-column-id={actionColumn.id}
|
||||
role="cell"
|
||||
style={{ ...stickyStyle(actionColumn, stickyOffsets[actionColumnIndex]), gridColumn: `${actionColumnIndex + 1} / ${actionColumnIndex + 2}` }}>
|
||||
|
||||
@@ -738,7 +862,8 @@ export default function DataGrid<T>({
|
||||
<div
|
||||
key={`${rowKey}-${column.id}`}
|
||||
role="cell"
|
||||
className={`data-grid-cell data-grid-body-cell ${parityClass} ${lastRowClass} ${column.align ? `align-${column.align}` : ""} ${column.className ?? ""} ${rowClass ?? ""} ${stickyClass(column)}`.trim()}
|
||||
data-column-id={column.id}
|
||||
className={`data-grid-cell data-grid-body-cell ${actionWidths[column.id] !== undefined || column.columnType === "actions" ? "data-grid-action-cell" : ""} ${parityClass} ${lastRowClass} ${column.align ? `align-${column.align}` : ""} ${column.className ?? ""} ${rowClass ?? ""} ${stickyClass(column)}`.trim()}
|
||||
style={stickyStyle(column, stickyOffsets[columnIndex])}>
|
||||
|
||||
{renderCell(column, row, originalIndex, translateText)}
|
||||
@@ -782,6 +907,28 @@ export default function DataGrid<T>({
|
||||
|
||||
}
|
||||
|
||||
function cssPixels(value: string): number {
|
||||
return Number.parseFloat(value) || 0;
|
||||
}
|
||||
|
||||
function measureActionGroup(element: HTMLElement | null): number {
|
||||
if (!element || !element.getClientRects().length) return 0;
|
||||
const style = window.getComputedStyle(element);
|
||||
if (element.matches("button, a, input, select, .table-action-placeholder")) {
|
||||
return Math.max(element.getBoundingClientRect().width, cssPixels(style.minWidth), element.scrollWidth);
|
||||
}
|
||||
const children = Array.from(element.children).filter((child): child is HTMLElement => {
|
||||
if (!(child instanceof HTMLElement) || !child.getClientRects().length || child.getAttribute("role") === "tooltip") return false;
|
||||
const position = window.getComputedStyle(child).position;
|
||||
return position !== "absolute" && position !== "fixed";
|
||||
});
|
||||
const widths = children.map(measureActionGroup);
|
||||
const width = style.flexDirection === "column" ? Math.max(0, ...widths)
|
||||
: widths.reduce((total, childWidth) => total + childWidth, 0) + Math.max(0, widths.length - 1) * cssPixels(style.columnGap);
|
||||
return width + cssPixels(style.paddingLeft) + cssPixels(style.paddingRight)
|
||||
+ cssPixels(style.borderLeftWidth) + cssPixels(style.borderRightWidth);
|
||||
}
|
||||
|
||||
export type DataGridPaginationBarProps = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
@@ -1031,13 +1178,6 @@ function ListFilterEditor({
|
||||
const selected = parseListFilter(value, options.map((option) => option.value));
|
||||
const selectedSet = new Set(selected);
|
||||
|
||||
function toggleOption(optionValue: string) {
|
||||
const next = new Set(selectedSet);
|
||||
if (next.has(optionValue)) next.delete(optionValue);else
|
||||
next.add(optionValue);
|
||||
onChange(formatListSelection([...next], options));
|
||||
}
|
||||
|
||||
function addOption() {
|
||||
const normalized = newValue.trim();
|
||||
if (!normalized || !onOptionsChange || options.some((option) => option.value === normalized)) return;
|
||||
@@ -1056,32 +1196,17 @@ function ListFilterEditor({
|
||||
|
||||
return (
|
||||
<div className="data-grid-list-filter">
|
||||
<div className="data-grid-list-filter-actions">
|
||||
<button type="button" onClick={() => onChange("")}>{translateText("i18n:govoplan-core.select_all.913afff1")}</button>
|
||||
<button type="button" onClick={() => onChange(formatListFilter([]))}>{translateText("i18n:govoplan-core.deselect_all.85cce1e1")}</button>
|
||||
</div>
|
||||
<div className="data-grid-list-filter-options" role="group" aria-label={translateText("i18n:govoplan-core.allowed_values.495fcf3a")}>
|
||||
{options.length === 0 ?
|
||||
<p className="muted small-note">{translateText("i18n:govoplan-core.no_values_are_configured_for_this_column.16e935e8")}</p> :
|
||||
options.map((option) =>
|
||||
<div className="data-grid-list-filter-row" key={option.value}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSet.has(option.value)}
|
||||
disabled={option.disabled}
|
||||
onChange={() => toggleOption(option.value)} />
|
||||
|
||||
{display === "pill" ? <StatusBadge status={option.value} label={option.label} /> : <span>{translateText(option.label)}</span>}
|
||||
</label>
|
||||
{editable &&
|
||||
<ListSelectionFilter
|
||||
options={options}
|
||||
value={value ? selected : null}
|
||||
onChange={(next) => onChange(next === null ? "" : formatListSelection(next, options))}
|
||||
renderOption={display === "pill" ? (option) => <StatusBadge status={option.value} label={option.label} /> : undefined}
|
||||
renderOptionActions={editable ? (option) => (
|
||||
<button type="button" className="data-grid-list-option-remove" aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", { value0: translateText("i18n:govoplan-core.remove.e963907d"), value1: translateText(option.label) })} onClick={() => removeOption(option.value)}>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
) : undefined}
|
||||
/>
|
||||
{editable &&
|
||||
<div className="data-grid-list-option-add">
|
||||
<input
|
||||
|
||||
@@ -8,6 +8,7 @@ export type DataGridSizingColumn = {
|
||||
fill?: boolean;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
columnType?: "default" | "from-list" | "actions";
|
||||
sticky?: "start" | "end";
|
||||
};
|
||||
|
||||
@@ -50,9 +51,27 @@ export function dataGridLayoutSignature(
|
||||
column.maxWidth ?? "",
|
||||
column.resizable ? "r" : "f",
|
||||
column.fill ? "fill" : "",
|
||||
column.sortable ? "sort" : "",
|
||||
column.filterable ? "filter" : "",
|
||||
column.columnType ?? "default",
|
||||
column.sticky ?? ""
|
||||
].join(":")).join("|");
|
||||
return `v2::${columnSignature}::${initialFit}::${resizeBehavior}`;
|
||||
return `v3::${columnSignature}::${initialFit}::${resizeBehavior}`;
|
||||
}
|
||||
|
||||
/** Reserve real action slots, but leave room to read data in narrow viewports.
|
||||
* Oversized groups wrap within this minimum; declared hard minima still apply.
|
||||
* This measured minimum is deliberately not part of the persisted signature.
|
||||
*/
|
||||
export function dataGridActionTrackMinimum(
|
||||
column: DataGridSizingColumn,
|
||||
intrinsicWidth: number,
|
||||
containerWidth: number
|
||||
): number {
|
||||
const minimum = effectiveDataGridColumnMinWidth(column);
|
||||
const contentWidth = Number.isFinite(intrinsicWidth) ? Math.max(0, intrinsicWidth) : 0;
|
||||
const availableWidth = containerWidth > 0 ? Math.max(minimum, Math.floor(containerWidth / 2)) : contentWidth;
|
||||
return Math.max(minimum, Math.min(contentWidth, availableWidth));
|
||||
}
|
||||
|
||||
export function dataGridWidthsForLayout(
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
type DocumentTransaction = { docChanged: boolean };
|
||||
|
||||
/** Editable-state and focus updates are not user document edits. */
|
||||
export function isWysiwygDocumentUpdate(
|
||||
transaction: DocumentTransaction,
|
||||
appendedTransactions: readonly DocumentTransaction[] = []
|
||||
): boolean {
|
||||
return transaction.docChanged || appendedTransactions.some((item) => item.docChanged);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import DescriptionList from "../../components/DescriptionList";
|
||||
import ContentGrid, { FormGrid } from "../../components/ContentGrid";
|
||||
import ContentGrid, { FormGrid, GridItem } from "../../components/ContentGrid";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type { AppearanceOverridesDocument, ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, NavigationPreferences, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPalette, UserUiPreferences, UserUiTheme } from "../../types";
|
||||
@@ -553,15 +553,16 @@ export default function SettingsPage({
|
||||
<span>i18n:govoplan-core.template_placeholder_chips_and_preview_overlays.11634d55</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Navigation order">
|
||||
<GridItem span="full"><Card title="Navigation order">
|
||||
<NavigationPreferenceEditor
|
||||
items={navigationItems}
|
||||
productAreas={platformModules.flatMap((module) => module.productAreas ?? [])}
|
||||
value={navigation}
|
||||
onChange={setNavigation}
|
||||
scope="user"
|
||||
disabled={uiBusy}
|
||||
/>
|
||||
</Card>
|
||||
</Card></GridItem>
|
||||
</ContentGrid>
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { PlatformTranslations } from "../types";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
"en": {
|
||||
"i18n:govoplan-core.optional_module_load_failed": "An enabled module could not load after retrying: {value0}. Its screens and integrations may be unavailable; the module has not been uninstalled. Save any other drafts before reloading this page.",
|
||||
"i18n:govoplan-core.data_grid_resize_help": "Drag to resize. Left/Right: 10 px; Shift: 40 px. Enter or double-click: reset this column. Escape: cancel dragging.",
|
||||
"i18n:govoplan-core.inherit_governed_palette": "Inherit governed default",
|
||||
"i18n:govoplan-core.effective_source": "Effective source",
|
||||
"i18n:govoplan-core.appearance_source_user": "Personal preference",
|
||||
@@ -638,7 +640,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.use_hh_mm.e995be3f": "Use HH:MM.",
|
||||
"i18n:govoplan-core.use_value.bac38fc3": "Use {value0}",
|
||||
"i18n:govoplan-core.use_yyyy_mm_dd.406d2e4d": "Use YYYY-MM-DD.",
|
||||
"i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70": "Used only when there is no browser session token. Browser login remains the preferred interactive mode.",
|
||||
"i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70": "Applying an API key switches identity. Sign-in and sign-out clear it.",
|
||||
"i18n:govoplan-core.user_docs.1e38e8d3": "User docs",
|
||||
"i18n:govoplan-core.user.9f8a2389": "User",
|
||||
"i18n:govoplan-core.users": "Users",
|
||||
@@ -739,6 +741,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.temporal_selection_invalid": "The selected data state is invalid."
|
||||
},
|
||||
"de": {
|
||||
"i18n:govoplan-core.optional_module_load_failed": "Ein aktiviertes Modul konnte auch nach einem Wiederholungsversuch nicht geladen werden: {value0}. Seine Ansichten und Integrationen sind möglicherweise nicht verfügbar; das Modul wurde nicht deinstalliert. Andere Entwürfe vor dem Neuladen dieser Seite speichern.",
|
||||
"i18n:govoplan-core.data_grid_resize_help": "Zum Ändern der Breite ziehen. Links/Rechts: 10 px; Umschalt: 40 px. Eingabe oder Doppelklick: Spalte zurücksetzen. Escape: Ziehen abbrechen.",
|
||||
"i18n:govoplan-core.inherit_governed_palette": "Verwalteten Standard übernehmen",
|
||||
"i18n:govoplan-core.effective_source": "Wirksame Quelle",
|
||||
"i18n:govoplan-core.appearance_source_user": "Persönliche Einstellung",
|
||||
@@ -977,7 +981,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.default.808d7dca": "Default",
|
||||
"i18n:govoplan-core.density.f9160c22": "Density",
|
||||
"i18n:govoplan-core.description.55f8ebc8": "Description",
|
||||
"i18n:govoplan-core.deselect_all.85cce1e1": "Deselect all",
|
||||
"i18n:govoplan-core.deselect_all.85cce1e1": "Alle abwählen",
|
||||
"i18n:govoplan-core.detected_saved_sent_folder.18642a29": "Detected/saved sent folder",
|
||||
"i18n:govoplan-core.detected_sent_folder.cbf8ec8d": "Detected Sent folder:",
|
||||
"i18n:govoplan-core.disable_storage.07c44d8b": "Disable storage",
|
||||
@@ -1022,7 +1026,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.files.6ce6c512": "Dateien",
|
||||
"i18n:govoplan-core.filter_yields_an_empty_result.8bfe7c90": "Filter yields an empty result.",
|
||||
"i18n:govoplan-core.filter.d7decf1a": "Filter",
|
||||
"i18n:govoplan-core.first_page.49d74b49": "First page",
|
||||
"i18n:govoplan-core.first_page.49d74b49": "Erste Seite",
|
||||
"i18n:govoplan-core.folder_below_the_campaign_attachment_base_path_w.04f81c33": "Folder below the campaign attachment base path where this rule starts looking for files.",
|
||||
"i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9": "Folder for sent-message copies. Leave as auto unless this campaign needs a different target.",
|
||||
"i18n:govoplan-core.folder_used_when_this_imap_account_is_used_for_s.08503f5e": "Folder used when this IMAP account is used for sent-message copies. Leave as auto to use the server default.",
|
||||
@@ -1087,7 +1091,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.language_native_german": "Deutsch",
|
||||
"i18n:govoplan-core.language.89b86ab0": "Sprache",
|
||||
"i18n:govoplan-core.light_theme.7878f1fa": "Hell",
|
||||
"i18n:govoplan-core.last_page.b01f16ae": "Last page",
|
||||
"i18n:govoplan-core.last_page.b01f16ae": "Letzte Seite",
|
||||
"i18n:govoplan-core.leave_empty_to_use_the_same_origin_in_vite_dev_a.9a1c25d7": "Leave empty to use the same origin. In Vite dev, /api is proxied to the FastAPI backend.",
|
||||
"i18n:govoplan-core.less_or_equal.2860e695": "Less or equal",
|
||||
"i18n:govoplan-core.less_than.1d3d412a": "Less than",
|
||||
@@ -1152,7 +1156,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.never.80c3052d": "Never",
|
||||
"i18n:govoplan-core.next_actions.7b09055a": "Next actions",
|
||||
"i18n:govoplan-core.next_month.8abf7cf1": "Next month",
|
||||
"i18n:govoplan-core.next_page.4bfc194b": "Next page",
|
||||
"i18n:govoplan-core.next_page.4bfc194b": "Nächste Seite",
|
||||
"i18n:govoplan-core.next_passes_will_add_functionality_here.c13caade": "Die nächsten Durchläufe ergänzen hier die Funktionalität.",
|
||||
"i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e": "Es wurden keine Zugriffsnachweise zurückgegeben.",
|
||||
"i18n:govoplan-core.no_accessible_campaigns_found.0b74419a": "No accessible campaigns found.",
|
||||
@@ -1220,7 +1224,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.prepared_preference_for_users_who_prefer_fewer_a.b288e8ab": "Prepared preference for users who prefer fewer animations.",
|
||||
"i18n:govoplan-core.prepared_ui_preference_for_denser_tables_the_cur.45698d83": "Prepared UI preference for denser tables. The current table layout remains unchanged until this is wired globally.",
|
||||
"i18n:govoplan-core.previous_month.46a29921": "Previous month",
|
||||
"i18n:govoplan-core.previous_page.81f54719": "Previous page",
|
||||
"i18n:govoplan-core.previous_page.81f54719": "Vorherige Seite",
|
||||
"i18n:govoplan-core.profile_saved_the_account_menu_has_been_updated.aee56076": "Profile saved. The account menu has been updated.",
|
||||
"i18n:govoplan-core.quiet_ui_mode.1b0bd558": "Quiet UI mode",
|
||||
"i18n:govoplan-core.rate_limit_for_outgoing_messages_lower_values_ar.9e929f6c": "Rate limit for outgoing messages. Lower values are safer for mail providers and throttled accounts.",
|
||||
@@ -1254,7 +1258,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.reusable_template_record_this_campaign_should_re.5896529b": "Reusable template record this campaign should refer to once the template backend is available.",
|
||||
"i18n:govoplan-core.review_send.1627617d": "Prüfen & Senden",
|
||||
"i18n:govoplan-core.role.b5b4a5a2": "Rolle",
|
||||
"i18n:govoplan-core.rows_per_page.af2f9c1b": "Rows per page",
|
||||
"i18n:govoplan-core.rows_per_page.af2f9c1b": "Zeilen pro Seite",
|
||||
"i18n:govoplan-core.sa.50cf95ce": "Sa",
|
||||
"i18n:govoplan-core.same_origin_proxied.c39e6e2b": "Same-origin / proxied",
|
||||
"i18n:govoplan-core.save_and_leave.0507824a": "Save and leave",
|
||||
@@ -1266,7 +1270,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.scenario.569aae5b": "Scenario",
|
||||
"i18n:govoplan-core.search_nested_folders_below_the_configured_base_.122d1916": "Search nested folders below the configured base directory.",
|
||||
"i18n:govoplan-core.security.f25ce1b8": "Sicherheit",
|
||||
"i18n:govoplan-core.select_all.913afff1": "Select all",
|
||||
"i18n:govoplan-core.select_all.913afff1": "Alle auswählen",
|
||||
"i18n:govoplan-core.select_an_item_to_inspect_its_content.1f67f131": "Select an item to inspect its content.",
|
||||
"i18n:govoplan-core.select_value.a9ef046e": "Select {value0}",
|
||||
"i18n:govoplan-core.send_without_attachments.ead6d030": "Send without attachments",
|
||||
@@ -1320,7 +1324,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.system_retention_defaults_and_the_fields_lower_l.90a9e923": "System retention defaults and the fields lower levels may override.",
|
||||
"i18n:govoplan-core.system_roles.a9461aa6": "System roles",
|
||||
"i18n:govoplan-core.system.bc0792d8": "System",
|
||||
"i18n:govoplan-core.table_pagination.3665bd76": "Table pagination",
|
||||
"i18n:govoplan-core.table_pagination.3665bd76": "Tabellenseiten",
|
||||
"i18n:govoplan-core.target.61ad50a9": "Target",
|
||||
"i18n:govoplan-core.template_body_content_shown_for_review_placehold.57454b52": "Template body content shown for review. Placeholders are checked against campaign fields.",
|
||||
"i18n:govoplan-core.template_placeholder_chips_and_preview_overlays.11634d55": "Template placeholder chips and preview overlays",
|
||||
@@ -1375,7 +1379,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.use_hh_mm.e995be3f": "Use HH:MM.",
|
||||
"i18n:govoplan-core.use_value.bac38fc3": "Use {value0}",
|
||||
"i18n:govoplan-core.use_yyyy_mm_dd.406d2e4d": "Use YYYY-MM-DD.",
|
||||
"i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70": "Used only when there is no browser session token. Browser login remains the preferred interactive mode.",
|
||||
"i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70": "API-Schlüssel wechseln die Identität. An- und Abmelden entfernt den Schlüssel.",
|
||||
"i18n:govoplan-core.user_docs.1e38e8d3": "User docs",
|
||||
"i18n:govoplan-core.user.9f8a2389": "User",
|
||||
"i18n:govoplan-core.users": "Benutzer",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Loaded with the navigation editor, not with the startup shell. */
|
||||
export const navigationEditorTranslations: Record<string, Record<string, string>> = {
|
||||
"en": {
|
||||
"help": "Drag the handles to reorder modules and separators. Keyboard: Space to pick up, arrows to move, Enter to drop, Escape to cancel. Removing a module only hides it here; locked entries stay visible.",
|
||||
"inherit": "Use inherited layout",
|
||||
"available": "Available modules",
|
||||
"all_added": "All available modules are included",
|
||||
"add_module": "Add module",
|
||||
"add_separator": "Add separator",
|
||||
"layout": "Navigation layout",
|
||||
"separator": "Separator",
|
||||
"separator_label": "Group label (optional)",
|
||||
"locked": "Locked",
|
||||
"locked_help": "An administrator has locked this entry; lower scopes cannot remove it.",
|
||||
"empty": "Add modules to create this navigation layout.",
|
||||
"reorder": "Reorder {label}",
|
||||
"up": "Move {label} up",
|
||||
"down": "Move {label} down",
|
||||
"remove": "Remove {label}",
|
||||
"moved": "{label} moved to position {position} of {total}.",
|
||||
"picked_up": "Picked up. Use arrow keys to move, Enter to drop, Escape to cancel.",
|
||||
"dropped": "Item placed.",
|
||||
"cancelled": "Reordering cancelled."
|
||||
},
|
||||
"de": {
|
||||
"help": "Ziehen Sie die Griffe, um Module und Trennlinien anzuordnen. Tastatur: Leertaste zum Aufnehmen, Pfeile zum Verschieben, Eingabe zum Ablegen, Escape zum Abbrechen. Entfernen blendet Module nur hier aus; gesperrte Einträge bleiben sichtbar.",
|
||||
"inherit": "Geerbte Anordnung verwenden",
|
||||
"available": "Verfügbare Module",
|
||||
"all_added": "Alle verfügbaren Module sind enthalten",
|
||||
"add_module": "Modul hinzufügen",
|
||||
"add_separator": "Trennlinie hinzufügen",
|
||||
"layout": "Navigationsanordnung",
|
||||
"separator": "Trennlinie",
|
||||
"separator_label": "Gruppenbezeichnung (optional)",
|
||||
"locked": "Gesperrt",
|
||||
"locked_help": "Dieser Eintrag ist administrativ gesperrt und kann auf untergeordneten Ebenen nicht entfernt werden.",
|
||||
"empty": "Fügen Sie Module zu dieser Navigationsanordnung hinzu.",
|
||||
"reorder": "{label} anordnen",
|
||||
"up": "{label} nach oben",
|
||||
"down": "{label} nach unten",
|
||||
"remove": "{label} entfernen",
|
||||
"moved": "{label} wurde auf Position {position} von {total} verschoben.",
|
||||
"picked_up": "Aufgenommen. Mit Pfeiltasten verschieben, Eingabe zum Ablegen, Escape zum Abbrechen.",
|
||||
"dropped": "Eintrag abgelegt.",
|
||||
"cancelled": "Anordnung abgebrochen."
|
||||
}
|
||||
};
|
||||
+5
-2
@@ -66,14 +66,14 @@ export { default as AdminSelectionList } from "./components/admin/AdminSelection
|
||||
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components/admin/adminUtils";
|
||||
export { default as Button } from "./components/Button";
|
||||
export { AppearancePalettePreview, AppearancePaletteSelect, APPEARANCE_PALETTE_OPTIONS, appearancePaletteLabel } from "./components/AppearancePaletteControl";
|
||||
export { default as AppearanceOverridesEditor } from "./components/AppearanceOverridesEditor";
|
||||
export {
|
||||
default as AppearanceOverridesEditor,
|
||||
APPEARANCE_OVERRIDE_TOKENS,
|
||||
DEFAULT_APPEARANCE_OVERRIDES,
|
||||
applyAppearanceOverrides,
|
||||
cloneDefaultAppearanceOverrides,
|
||||
validateAppearanceOverrides
|
||||
} from "./components/AppearanceOverridesEditor";
|
||||
} from "./components/appearanceOverrides";
|
||||
export type { ButtonProps } from "./components/Button";
|
||||
export { default as Card } from "./components/Card";
|
||||
export type { CardProps } from "./components/Card";
|
||||
@@ -212,6 +212,9 @@ export {
|
||||
platformModuleReferenceProvider
|
||||
} from "./platform/referenceProviders";
|
||||
export { DashboardWidgetList, useDashboardWidgetData } from "./components/DashboardWidgetContent";
|
||||
export { default as MultiSelectFilter } from "./components/MultiSelectFilter";
|
||||
export type { MultiSelectFilterProps } from "./components/MultiSelectFilter";
|
||||
export type { ListFilterOption, ListFilterSelection } from "./components/ListSelectionFilter";
|
||||
export type { DashboardWidgetDataState, DashboardWidgetListItem } from "./components/DashboardWidgetContent";
|
||||
export { default as SegmentedControl } from "./components/SegmentedControl";
|
||||
export type { SegmentedControlOption, SegmentedControlProps, SegmentedControlSize, SegmentedControlWidth } from "./components/SegmentedControl";
|
||||
|
||||
@@ -78,8 +78,9 @@ export default function IconRail({
|
||||
<>
|
||||
<div className="icon-rail-scroll">
|
||||
<nav className="icon-nav">
|
||||
{navigationGroups.map((group) => (
|
||||
{navigationGroups.map((group, index) => (
|
||||
<div className="icon-nav-group" key={group.id}>
|
||||
{!railExpanded && index > 0 && <div className="icon-nav-group-separator" role="separator" aria-label={group.label ? translateText(group.label) : undefined} />}
|
||||
{group.label && (
|
||||
<div className="icon-nav-group-label">
|
||||
{translateText(group.label)}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Retry only the import boundary, once. Validation and capabilities stay separate. */
|
||||
export async function importModuleWithRetry<T>(
|
||||
load: () => Promise<T>,
|
||||
pause: () => Promise<void> = () => new Promise((resolve) => setTimeout(resolve, 250))
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await load();
|
||||
} catch {
|
||||
await pause();
|
||||
return await load();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
uiCapability as uiCapabilityForModules } from
|
||||
"./moduleLogic";
|
||||
import { hasAnyScope, hasScope } from "../utils/permissions";
|
||||
import { importModuleWithRetry } from "./moduleLoading";
|
||||
import {
|
||||
isViewSurfaceVisible,
|
||||
navigationViewSurfaceId,
|
||||
@@ -34,6 +35,7 @@ export function shellNavItemsForModules(modules: PlatformWebModule[]): PlatformN
|
||||
}
|
||||
|
||||
const localModuleCache = new Map<string, Promise<PlatformWebModule | null>>();
|
||||
const failedLocalModules = new Set<string>();
|
||||
const loadedLocalModules = new Map<string, PlatformWebModule>();
|
||||
const remoteModuleCache = new Map<string, Promise<PlatformWebModule | null>>();
|
||||
|
||||
@@ -117,6 +119,9 @@ function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavIt
|
||||
navigationOrderSource: item.navigation_order_source,
|
||||
navigationVisibilitySource: item.navigation_visibility_source,
|
||||
navigationLockSource: item.navigation_lock_source,
|
||||
navigationSection: item.navigation_section,
|
||||
navigationCustomLayout: item.navigation_custom_layout,
|
||||
navigationLayoutSource: item.navigation_layout_source,
|
||||
navigationLayers: item.navigation_layers
|
||||
};
|
||||
}
|
||||
@@ -294,13 +299,17 @@ export async function loadRemotePublicWebModules(
|
||||
}
|
||||
|
||||
export async function loadInstalledWebModules(
|
||||
platformModules: PlatformModuleInfo[] | null | undefined
|
||||
platformModules: PlatformModuleInfo[] | null | undefined,
|
||||
onLoadFailure?: (moduleId: string) => void
|
||||
): Promise<PlatformWebModule[]> {
|
||||
if (!platformModules?.length) return [];
|
||||
|
||||
const enabledModules = platformModules.filter((module) => module.enabled);
|
||||
const resolved = await Promise.all(enabledModules.map(async (info) => {
|
||||
const local = await loadInstalledWebModule(info);
|
||||
if (!local && info.frontend?.package_name && failedLocalModules.has(info.frontend.package_name)) {
|
||||
onLoadFailure?.(info.id);
|
||||
}
|
||||
return local ? applyServerMetadata(local, info) : null;
|
||||
}));
|
||||
return resolved.filter((module): module is PlatformWebModule => module !== null);
|
||||
@@ -353,17 +362,19 @@ async function loadInstalledWebModulePackage(
|
||||
): Promise<PlatformWebModule | null> {
|
||||
let promise = localModuleCache.get(loader.packageName);
|
||||
if (!promise) {
|
||||
promise = loader.load().
|
||||
promise = importModuleWithRetry(loader.load).
|
||||
then((imported) => {
|
||||
const module = imported.default;
|
||||
if (!isPlatformWebModule(module)) {
|
||||
throw new Error(`${loader.packageName} does not export a PlatformWebModule`);
|
||||
}
|
||||
loadedLocalModules.set(loader.packageName, module);
|
||||
failedLocalModules.delete(loader.packageName);
|
||||
return module;
|
||||
}).
|
||||
catch((error) => {
|
||||
localModuleCache.delete(loader.packageName);
|
||||
failedLocalModules.add(loader.packageName);
|
||||
console.warn("GovOPlaN installed WebUI module was not loaded:", loader.packageName, error);
|
||||
return null;
|
||||
});
|
||||
@@ -371,6 +382,7 @@ async function loadInstalledWebModulePackage(
|
||||
}
|
||||
const module = await promise;
|
||||
if (module && expectedModuleId && module.id !== expectedModuleId) {
|
||||
failedLocalModules.add(loader.packageName);
|
||||
console.warn(
|
||||
"GovOPlaN installed WebUI package id mismatch:",
|
||||
loader.packageName,
|
||||
|
||||
@@ -17,6 +17,30 @@ export function groupNavigationItems(
|
||||
contributions: ProductAreaContribution[],
|
||||
presentation?: ViewPresentation
|
||||
): NavigationGroup[] {
|
||||
const viewLayout = presentation?.navigation;
|
||||
const personalLayout = items.some((item) => item.navigationLayoutSource === "user"
|
||||
|| item.navigationOrderSource === "user" || item.navigationVisibilitySource === "user");
|
||||
if (viewLayout && !personalLayout) {
|
||||
const byId = new Map(items.flatMap((item) => navigationItemAliases(item).map((id) => [id, item] as const)));
|
||||
const separators = new Map((viewLayout.separators ?? []).map((item) => [item.id, item]));
|
||||
const orderedIds = [...new Set([...viewLayout.order, ...byId.keys()])];
|
||||
const projected: PlatformNavItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
let section: { id: string; label: string } | null = null;
|
||||
for (const id of orderedIds) {
|
||||
if (separators.has(id)) { section = separators.get(id)!; continue; }
|
||||
const item = byId.get(id);
|
||||
if (!item || seen.has(item.to) || (navigationItemAliases(item).some((alias) => viewLayout.hidden.includes(alias)) && !item.navigationLocked)) continue;
|
||||
seen.add(item.to);
|
||||
projected.push(viewLayout.separators == null ? item : { ...item, navigationSection: section });
|
||||
}
|
||||
return viewLayout.separators == null
|
||||
? groupNavigationItems(projected, contributions, { ...presentation, navigation: null })
|
||||
: explicitNavigationGroups(projected);
|
||||
}
|
||||
if (items.some((item) => item.navigationCustomLayout)) {
|
||||
return explicitNavigationGroups(items);
|
||||
}
|
||||
if (presentation?.navigationMode === "flat" || contributions.length === 0) {
|
||||
return [{ id: "all-tools", items }];
|
||||
}
|
||||
@@ -89,3 +113,23 @@ export function groupNavigationItems(
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function explicitNavigationGroups(items: PlatformNavItem[]): NavigationGroup[] {
|
||||
const groups: NavigationGroup[] = [];
|
||||
for (const item of items) {
|
||||
const section = item.navigationSection;
|
||||
const id = section?.id ?? "navigation-ungrouped";
|
||||
let group = groups[groups.length - 1];
|
||||
if (!group || group.id !== id) {
|
||||
group = { id, label: section?.label, areaLabel: section?.label, items: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.items.push(item);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function navigationItemAliases(item: PlatformNavItem): string[] {
|
||||
return [...new Set([item.navigationId, item.surfaceId, item.to, ...(item.navigationAliases ?? [])]
|
||||
.filter((id): id is string => Boolean(id)))];
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ export function projectProductNavigation(
|
||||
const authorizedItems = items.filter((item) => navigationItemAuthorized(item, auth));
|
||||
const allToolItems = catalogueItems.filter((item) => navigationItemAuthorized(item, auth));
|
||||
const ownerItemByPath = new Map(authorizedItems.map((item) => [item.to, item]));
|
||||
const positionByPath = new Map(authorizedItems.map((item, index) => [item.to, index]));
|
||||
const consumedOwnerPaths = new Set<string>();
|
||||
const replacementByPath = new Map<string, PlatformNavItem>();
|
||||
|
||||
@@ -152,14 +153,29 @@ export function projectProductNavigation(
|
||||
const target = navigable[0];
|
||||
if (!target) continue;
|
||||
|
||||
// Contributor priority still determines the operational target of the
|
||||
// product surface. Its rail placement instead follows the earliest owner
|
||||
// in the effective personal/tenant/system navigation layout.
|
||||
const placement = navigable.reduce((earliest, candidate) =>
|
||||
(positionByPath.get(candidate.item.to) ?? Infinity) < (positionByPath.get(earliest.item.to) ?? Infinity)
|
||||
? candidate : earliest, target);
|
||||
const lockedOwners = navigable.map(({ item }) => item).filter((item) => item.navigationLocked);
|
||||
const lockedOwner = lockedOwners.find((item) => item.navigationLockSource === "system") ?? lockedOwners[0];
|
||||
|
||||
navigable.forEach(({ contribution }) => {
|
||||
consumedOwnerPaths.add(contribution.routePath);
|
||||
});
|
||||
replacementByPath.set(target.contribution.routePath, {
|
||||
...target.item,
|
||||
replacementByPath.set(placement.contribution.routePath, {
|
||||
...placement.item,
|
||||
to: surface.entryPath,
|
||||
label: surface.label,
|
||||
navigationId: surface.id,
|
||||
navigationAliases: [...new Set([
|
||||
...surface.aliases,
|
||||
...navigable.flatMap(({ item }) => [item.navigationId, item.surfaceId, item.to, ...(item.navigationAliases ?? [])])
|
||||
].filter((alias): alias is string => Boolean(alias)))],
|
||||
navigationLocked: lockedOwners.length > 0,
|
||||
navigationLockSource: lockedOwner?.navigationLockSource ?? placement.item.navigationLockSource,
|
||||
activePaths: [
|
||||
...surface.aliases,
|
||||
...navigable.map(({ contribution }) => contribution.routePath)
|
||||
|
||||
@@ -1018,6 +1018,18 @@
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.navigation-preference-editor { min-width: 0; container-type: inline-size; }
|
||||
.navigation-preference-add { margin-bottom: var(--space-3); flex-wrap: wrap; }
|
||||
.navigation-preference-add select { flex: 1 1 12rem; min-width: 0; width: auto; max-width: 100%; }
|
||||
.navigation-preference-item-actions { display: flex; align-items: center; gap: var(--space-2); justify-content: flex-end; }
|
||||
.navigation-preference-drag { cursor: grab; }
|
||||
.navigation-preference-drag:active { cursor: grabbing; }
|
||||
.navigation-preference-list > li[data-dragging="true"] { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.navigation-preference-list > li[data-drop-target="true"] { border-color: var(--accent); background: var(--surface-subtle); }
|
||||
.navigation-preference-list > li[data-navigation-kind="separator"] { border-style: dashed; }
|
||||
.navigation-preference-label label, .navigation-preference-label input { width: 100%; min-width: 0; }
|
||||
.navigation-preference-label strong { overflow-wrap: anywhere; }
|
||||
|
||||
.navigation-preference-toolbar p {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1032,7 +1044,8 @@
|
||||
|
||||
.navigation-preference-list > li {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(12rem, 1fr) auto auto;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
@@ -1041,6 +1054,11 @@
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
@container (max-width: 520px) {
|
||||
.navigation-preference-list > li { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.navigation-preference-label { grid-row: 1; grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
.navigation-preference-order-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
@@ -1054,7 +1072,7 @@
|
||||
|
||||
.navigation-preference-label span {
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
text-overflow: ellipsis;
|
||||
@@ -2336,6 +2354,29 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading-frame-panel.has-progress {
|
||||
display: grid;
|
||||
width: min(32rem, 100%);
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.loading-frame-panel progress {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 0.7rem;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.loading-frame-progress-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.module-load-progress {
|
||||
display: flex;
|
||||
min-height: 120px;
|
||||
|
||||
@@ -75,6 +75,9 @@
|
||||
|
||||
.dialog-panel {
|
||||
width: min(560px, 100%);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
max-height: min(760px, calc(100vh - 3rem));
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
@@ -97,6 +100,7 @@
|
||||
|
||||
.dialog-header {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
min-height: 58px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -108,12 +112,15 @@
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
flex: 0 0 2rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
@@ -142,11 +149,23 @@
|
||||
|
||||
.dialog-body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Form controls and their labels must shrink inside a dialog's padded body.
|
||||
Wide tables/editors retain their own local scroll surfaces; do not conceal
|
||||
overflowing controls by clipping horizontal overflow on the whole dialog. */
|
||||
.dialog-body :where(.form-field, .form-grid-layout, .dialog-form-layout, .dialog-section-layout) {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.dialog-body .form-field { grid-template-columns: minmax(0, 1fr); }
|
||||
.dialog-body :where(input, select, textarea) { min-width: 0; max-width: 100%; }
|
||||
.dialog-body :where(.form-label, .dialog-description, .dialog-notices) { overflow-wrap: anywhere; }
|
||||
|
||||
.dialog-body-padding-none { padding: 0; }
|
||||
.dialog-body-padding-compact { padding: 12px; }
|
||||
.dialog-body-padding-default { padding: 20px; }
|
||||
@@ -157,6 +176,7 @@
|
||||
|
||||
.dialog-footer {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
@@ -169,11 +189,11 @@
|
||||
.dialog-actions-start { justify-content: flex-start; }
|
||||
.dialog-actions-between { justify-content: space-between; }
|
||||
.dialog-actions-end { justify-content: flex-end; }
|
||||
.dialog-form-layout { min-width: 0; display: grid; }
|
||||
.dialog-form-layout { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr); }
|
||||
.dialog-form-spacing-compact { gap: 10px; }
|
||||
.dialog-form-spacing-default { gap: 16px; }
|
||||
.dialog-form-spacing-loose { gap: 24px; }
|
||||
.dialog-section-layout { min-width: 0; display: grid; gap: 12px; }
|
||||
.dialog-section-layout { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
|
||||
.dialog-section-separated { padding-top: 16px; border-top: var(--border-line); }
|
||||
.dialog-section-inset { padding: 14px; border: var(--border-line); border-radius: var(--radius); background: var(--panel-soft); }
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
.content-grid-item-two { grid-column: span 2; }
|
||||
.content-grid-item-full { grid-column: 1 / -1; }
|
||||
.form-grid-layout > .wide { grid-column: 1 / -1; }
|
||||
/* Form rows align their controls, not a switch with its neighbour's label.
|
||||
This is intrinsic: wrapped labels and one-column layouts need no spacer. */
|
||||
.form-grid-layout > :where(.form-field, .toggle-switch-row),
|
||||
.form-grid-layout > .content-grid-item:has(> :is(.form-field, .toggle-switch-row):only-child) {
|
||||
align-self: end;
|
||||
}
|
||||
.form-section-layout { min-width: 0; display: grid; gap: 14px; }
|
||||
.form-section-separated { padding-top: 18px; border-top: var(--border-line); }
|
||||
.form-section-panel { padding: 18px; border: var(--border-line); border-radius: var(--radius); background: var(--panel); }
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
.icon-rail-scroll { width: 100%; min-width: 0; min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-color: var(--rail-text-muted) transparent; scrollbar-width: thin; }
|
||||
.icon-nav { width: 100%; display: flex; flex-direction: column; min-width: 0; }
|
||||
.icon-nav-group { min-width: 0; }
|
||||
.icon-nav-group-label { display: none; min-height: 26px; align-items: end; padding: 6px 14px 4px 16px; color: var(--rail-text-muted); font-size: 11px; font-weight: 700; letter-spacing: 0; }
|
||||
.icon-nav-group-label { display: none; min-height: 23px; align-items: end; padding: 5px 14px 5px 16px; color: var(--rail-text-muted); font-size: 11px; font-weight: 700; letter-spacing: 0; white-space: nowrap;}
|
||||
.icon-rail.expanded .icon-nav-group-label { display: flex; }
|
||||
.icon-rail.expanded .icon-nav-group + .icon-nav-group .icon-nav-group-label { border-top: 1px solid var(--rail-bg-active); }
|
||||
.icon-nav-group-separator { border-top: 1px solid var(--rail-text-muted); opacity: .45; margin: 11px 12px; }
|
||||
.icon-nav-item { width: 100%; height: 52px; display: grid; grid-template-columns: 55px minmax(0, 1fr); align-items: center; color: var(--rail-text-muted); border-left: 3px solid transparent; text-decoration: none; box-sizing: border-box; }
|
||||
.icon-nav-item svg,
|
||||
.icon-nav-item > :first-child:not(.icon-nav-label) { justify-self: center; }
|
||||
@@ -264,6 +264,23 @@
|
||||
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
|
||||
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
|
||||
.card-body { padding: 22px 24px; }
|
||||
|
||||
/* Table surfaces have an explicit inset contract, independent of child count. */
|
||||
.card-body.card-body-table { min-width: 0; padding: 0; }
|
||||
.card-body-table > :is(.data-grid-shell, .admin-table-surface, .connection-tree),
|
||||
.card-body-table > .loading-frame > :is(.data-grid-shell, .admin-table-surface, .connection-tree) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.card-body-table .admin-table-surface > .data-grid-shell {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.metric-group-layout {
|
||||
--metric-group-column-minimum: 140px;
|
||||
min-width: 0;
|
||||
|
||||
@@ -144,7 +144,14 @@
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
/* Auto-height tables have no vertical scrollbar. A permanent gutter made
|
||||
every row end 15px before its card; reserve space only for real scrollbars. */
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
.data-grid-scroll-region:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.admin-table-surface {
|
||||
@@ -161,7 +168,7 @@
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.card-body > .admin-table-surface:only-child {
|
||||
.card-body:not(.card-body-table) > .admin-table-surface:only-child {
|
||||
margin: -22px -24px;
|
||||
width: calc(100% + 48px);
|
||||
max-width: inherit;
|
||||
@@ -173,21 +180,21 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.card-body > .data-grid-shell:only-child {
|
||||
.card-body:not(.card-body-table) > .data-grid-shell:only-child {
|
||||
margin: -22px -24px;
|
||||
width: calc(100% + 48px);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.card-body > .connection-tree:only-child {
|
||||
.card-body:not(.card-body-table) > .connection-tree:only-child {
|
||||
margin: -22px -24px;
|
||||
width: calc(100% + 48px);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.card-body > .loading-frame:only-child > .connection-tree:only-child {
|
||||
.card-body:not(.card-body-table) > .loading-frame:only-child > .connection-tree:only-child {
|
||||
margin: -22px -24px;
|
||||
width: calc(100% + 48px);
|
||||
border: 0;
|
||||
@@ -362,6 +369,8 @@
|
||||
}
|
||||
|
||||
.data-grid-resize-handle {
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
cursor: col-resize;
|
||||
width: 16px;
|
||||
align-self: stretch;
|
||||
@@ -499,6 +508,19 @@
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Keep oversized explicitly fixed/persisted sticky tracks from covering the
|
||||
entire viewport. The labelled, focusable scroll region retains access. */
|
||||
.data-grid-release-sticky .data-grid-body-cell.is-sticky-start,
|
||||
.data-grid-release-sticky .data-grid-body-cell.is-sticky-end {
|
||||
position: relative;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
}
|
||||
.data-grid-release-sticky .data-grid-header-cell {
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
}
|
||||
|
||||
.data-grid-cell.is-sticky-start {
|
||||
border-right: var(--border-line-dark);
|
||||
box-shadow: var(--shadow-sticky-start);
|
||||
@@ -546,6 +568,12 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.multi-select-filter { min-width: 0; }
|
||||
.multi-select-filter-trigger { max-width: 100%; }
|
||||
.multi-select-filter-trigger > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.multi-select-filter-popover { overflow-y: auto; }
|
||||
.data-grid-list-option-label { min-width: 0; overflow-wrap: anywhere; white-space: normal; }
|
||||
|
||||
.data-grid-list-filter-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -656,12 +684,30 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Action tracks measure the unwrapped slots; wrapping is only needed when a
|
||||
constrained viewport cannot fit their full width without hiding row data. */
|
||||
.data-grid-action-cell .table-action-group,
|
||||
.data-grid-action-cell > * {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.data-grid-action-cell .table-action-group > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.data-grid-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.table-action-button.btn {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
max-width: 36px;
|
||||
flex: 0 0 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
--black: #000000;
|
||||
--transparent: transparent;
|
||||
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
|
||||
--rail-bg: #25282a;
|
||||
--rail-bg-active: #1c1e20;
|
||||
--rail-text: #c7c6c0;
|
||||
|
||||
@@ -298,11 +298,16 @@ export type PlatformNavItem = {
|
||||
order?: number;
|
||||
surfaceId?: string;
|
||||
navigationId?: string;
|
||||
/** Stable owner IDs/paths retained when optional modules compose one entry. */
|
||||
navigationAliases?: string[];
|
||||
navigationVisible?: boolean;
|
||||
navigationLocked?: boolean;
|
||||
navigationOrderSource?: string;
|
||||
navigationVisibilitySource?: string;
|
||||
navigationLockSource?: string | null;
|
||||
navigationSection?: NavigationSeparator | null;
|
||||
navigationCustomLayout?: boolean;
|
||||
navigationLayoutSource?: string;
|
||||
navigationLayers?: Partial<Record<"module" | "system" | "tenant", NavigationLayerState>>;
|
||||
/** Additional stable or owner paths that should mark a composed product entry active. */
|
||||
activePaths?: string[];
|
||||
@@ -313,13 +318,19 @@ export type NavigationLayerState = {
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
lock_source?: string | null;
|
||||
section?: NavigationSeparator | null;
|
||||
custom_layout?: boolean;
|
||||
layout_source?: string;
|
||||
};
|
||||
|
||||
export type NavigationSeparator = { id: string; label: string };
|
||||
|
||||
export type NavigationPreferences = {
|
||||
contract_version: "1";
|
||||
order: string[];
|
||||
hidden: string[];
|
||||
locked?: string[];
|
||||
separators?: NavigationSeparator[] | null;
|
||||
};
|
||||
|
||||
export type ProductAreaContribution = {
|
||||
@@ -599,6 +610,7 @@ export type EffectiveViewProjection = {
|
||||
};
|
||||
|
||||
export type ViewPresentation = {
|
||||
navigation?: NavigationPreferences | null;
|
||||
navigationMode?: "grouped" | "flat";
|
||||
productAreaOrder?: string[];
|
||||
productAreaLabels?: Record<string, string>;
|
||||
@@ -1287,6 +1299,9 @@ export type PlatformFrontendModuleInfo = {
|
||||
navigation_order_source?: string;
|
||||
navigation_visibility_source?: string;
|
||||
navigation_lock_source?: string | null;
|
||||
navigation_section?: NavigationSeparator | null;
|
||||
navigation_custom_layout?: boolean;
|
||||
navigation_layout_source?: string;
|
||||
navigation_layers?: Partial<Record<"module" | "system" | "tenant", NavigationLayerState>>;
|
||||
}>;
|
||||
settings_routes: PlatformFrontendRouteInfo[];
|
||||
@@ -1481,6 +1496,9 @@ export type PlatformModuleInfo = {
|
||||
navigation_order_source?: string;
|
||||
navigation_visibility_source?: string;
|
||||
navigation_lock_source?: string | null;
|
||||
navigation_section?: NavigationSeparator | null;
|
||||
navigation_custom_layout?: boolean;
|
||||
navigation_layout_source?: string;
|
||||
navigation_layers?: Partial<Record<"module" | "system" | "tenant", NavigationLayerState>>;
|
||||
}>;
|
||||
frontend?: PlatformFrontendModuleInfo | null;
|
||||
|
||||
@@ -89,7 +89,7 @@ export function collectCampaignAddressSuggestions(draft: Record<string, unknown>
|
||||
return dedupeAddresses(suggestions);
|
||||
}
|
||||
|
||||
export function dedupeAddresses(addresses: MailboxAddress[]): MailboxAddress[] {
|
||||
export function dedupeAddresses(addresses: MailboxAddress[], options: { preserveOrder?: boolean } = {}): MailboxAddress[] {
|
||||
const seen = new Map<string, MailboxAddress>();
|
||||
addresses.map(normalizeEmailAddress).forEach((address) => {
|
||||
if (!address.email) return;
|
||||
@@ -98,7 +98,10 @@ export function dedupeAddresses(addresses: MailboxAddress[]): MailboxAddress[] {
|
||||
seen.set(address.email, address);
|
||||
}
|
||||
});
|
||||
return [...seen.values()].sort((left, right) => (left.name || left.email).localeCompare(right.name || right.email));
|
||||
const unique = [...seen.values()];
|
||||
// Suggestion catalogues retain their alphabetical default. User-authored
|
||||
// recipient order is semantic and must survive deduplication and saving.
|
||||
return options.preserveOrder ? unique : unique.sort((left, right) => (left.name || left.email).localeCompare(right.name || right.email));
|
||||
}
|
||||
|
||||
export function addressDisplayName(address: MailboxAddress): string {
|
||||
|
||||
Reference in New Issue
Block a user