feat: consolidate shared UI and harden browser authority for release

This commit is contained in:
2026-09-08 01:35:05 +02:00
parent ac40774785
commit b75ca34295
143 changed files with 8664 additions and 752 deletions
+30 -7
View File
@@ -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
View File
@@ -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);
+1
View File
@@ -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));
}
+4 -1
View File
@@ -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>
+4
View File
@@ -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;
};
+3 -2
View File
@@ -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>
);
}
+12 -4
View File
@@ -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>);
}
}
+129
View File
@@ -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;
}
+4 -4
View File
@@ -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>
+9 -5
View File
@@ -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());
}
+142
View File
@@ -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;
}
+205 -80
View File
@@ -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
+20 -1
View File
@@ -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);
}
+4 -3
View File
@@ -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>
}
+14 -10
View File
@@ -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
View File
@@ -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";
+2 -1
View File
@@ -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)}
+12
View File
@@ -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();
}
}
+14 -2
View File
@@ -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,
+44
View File
@@ -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)))];
}
+18 -2
View File
@@ -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)
+43 -2
View File
@@ -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;
+22 -2
View File
@@ -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); }
+6
View File
@@ -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 -2
View File
@@ -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;
+51 -5
View File
@@ -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;
}
+4
View File
@@ -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;
+18
View File
@@ -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;
+5 -2
View File
@@ -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 {