feat: add temporal context and contextual help

This commit is contained in:
2026-08-05 00:03:31 +02:00
parent 982ef636b8
commit add7a99f6d
43 changed files with 1878 additions and 167 deletions
+21 -2
View File
@@ -11,6 +11,8 @@ import { PermissionBoundary } from "./components/AccessBoundary";
import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules";
import { PlatformModulesProvider } from "./platform/ModuleContext";
import { PlatformViewProvider } from "./platform/ViewContext";
import { PlatformTemporalProvider } from "./platform/TemporalContext";
import { PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT } from "./platform/temporal";
import {
PLATFORM_VIEW_CHANGED_EVENT,
PLATFORM_WORKFLOW_VIEW_CHANGED_EVENT,
@@ -53,6 +55,7 @@ export default function App() {
const [reloginMessage, setReloginMessage] = useState("");
const [baseViewProjection, setBaseViewProjection] = useState<EffectiveViewProjection | null>(null);
const [workflowViewProjection, setWorkflowViewProjection] = useState<EffectiveViewProjection | null>(null);
const [temporalRevision, setTemporalRevision] = useState(0);
const viewProjection = workflowViewProjection ?? baseViewProjection;
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
@@ -71,6 +74,20 @@ export default function App() {
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
useEffect(() => {
function reloadTemporalData() {
setTemporalRevision((current) => current + 1);
}
window.addEventListener(
PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT,
reloadTemporalData
);
return () => window.removeEventListener(
PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT,
reloadTemporalData
);
}, []);
useEffect(() => {
if (!auth || !viewsRuntime) {
setBaseViewProjection(null);
@@ -518,12 +535,13 @@ export default function App() {
onLanguageChange={persistLanguagePreference}
moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}>
<PlatformTemporalProvider scopeKey={`${auth.user?.id ?? "account"}:${(auth.active_tenant ?? auth.tenant).id}`}>
<DocumentationHelpProvider localDocsAvailable={localDocsAvailable}>
<PlatformViewProvider modules={webModules} projection={viewProjection}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<ModuleLoadBoundary resetKey={location.pathname} loading={webModulesLoading}>
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
<ModuleLoadBoundary resetKey={`${location.pathname}:${temporalRevision}`} loading={webModulesLoading}>
<Routes key={`${(auth.active_tenant ?? auth.tenant).id}:${temporalRevision}`}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
{publicRoutes.map((route) =>
@@ -567,6 +585,7 @@ export default function App() {
</UnsavedChangesProvider>
</PlatformViewProvider>
</DocumentationHelpProvider>
</PlatformTemporalProvider>
</PlatformModulesProvider>
</PlatformLanguageProvider>);
+9 -1
View File
@@ -1,4 +1,5 @@
import type { ApiSettings } from "../types";
import { temporalRequestHeaders } from "../platform/temporal";
const STORAGE_KEY = "govoplan.apiSettings";
const LEGACY_STORAGE_KEYS: string[] = [];
@@ -341,6 +342,9 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
for (const [key, value] of authHeaders(settings)) {
headers.set(key, value);
}
for (const [key, value] of Object.entries(temporalRequestHeaders())) {
if (!headers.has(key)) headers.set(key, value);
}
const csrf = csrfToken();
if (csrf && isUnsafeMethod(method) && !headers.has("X-CSRF-Token")) {
@@ -433,7 +437,11 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
export async function apiDownload(settings: ApiSettings, path: string, filename: string): Promise<void> {
const response = await fetch(apiUrl(settings, path), { headers: authHeaders(settings), credentials: "include" });
const headers = authHeaders(settings);
for (const [key, value] of Object.entries(temporalRequestHeaders())) {
headers.set(key, value);
}
const response = await fetch(apiUrl(settings, path), { headers, credentials: "include" });
if (!response.ok) {
const text = await response.text();
if (response.status === 401 && shouldNotifyAuthRequired(path)) {
+15 -2
View File
@@ -7,7 +7,20 @@ export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & PlatformInte
disabledReason?: ReactNode;
};
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpTopicId, ...props }: ButtonProps) {
const button = <button data-interface-id={interfaceId} data-help-topic-id={helpTopicId} className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />;
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpContextId, helpTopicId, children, ...props }: ButtonProps) {
const button = (
<button
data-help-scope="action"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={typeof children === "string" ? children : undefined}
className={`btn btn-${variant} ${className}`}
disabled={disabled || Boolean(disabledReason)}
{...props}
>
{children}
</button>
);
return <DisabledActionTooltip reason={disabledReason}>{button}</DisabledActionTooltip>;
}
+12 -4
View File
@@ -1,8 +1,9 @@
import { useEffect, useState, type ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types";
type CardProps = {
type CardProps = PlatformInterfaceIdentityProps & {
title?: ReactNode;
children: ReactNode;
actions?: ReactNode;
@@ -38,7 +39,7 @@ function writeCollapseState(storageKey: string | null, collapsed: boolean): void
// localStorage may be unavailable in private or restricted contexts.
}}
export default function Card({ title, children, actions, collapsible = false, collapseKey, persistCollapse = true }: CardProps) {
export default function Card({ title, children, actions, collapsible = false, collapseKey, persistCollapse = true, interfaceId, helpContextId, helpTopicId }: CardProps) {
const { translateText } = usePlatformLanguage();
const storageKey = resolveCollapseStorageKey(collapsible, persistCollapse, collapseKey, title);
const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) }));
@@ -59,7 +60,14 @@ export default function Card({ title, children, actions, collapsible = false, co
}
return (
<section className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}>
<section
className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}
data-help-scope="interface"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={typeof title === "string" ? title : undefined}
>
{hasHeader &&
<header className="card-header">
{title && (typeof title === "string" ? <h2>{translateText(title)}</h2> : <div className="card-title-node">{title}</div>)}
@@ -85,4 +93,4 @@ export default function Card({ title, children, actions, collapsible = false, co
{shouldRenderBody && (collapsible ? <div className="card-collapse-region">{body}</div> : body)}
</section>);
}
}
+28 -6
View File
@@ -50,7 +50,7 @@ function combineDateTime(date: string, time: string): string {
return `${date || dateString(new Date())}T${time || "00:00"}`;
}
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", interfaceId, helpTopicId, ...props }: BaseProps) {
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", interfaceId, helpContextId, helpTopicId, ...props }: BaseProps) {
const selectedDate = parseDate(value);
const [open, setOpen] = useState(false);
const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date());
@@ -94,7 +94,15 @@ export function DateField({ value, onChange, min, max, disabled, className = "",
}
return (
<div ref={rootRef} className={`date-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<div
ref={rootRef}
className={`date-field ${className}`.trim()}
data-help-scope="field"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={props["aria-label"] ?? placeholder}
>
<input
{...props}
ref={inputRef}
@@ -146,7 +154,7 @@ export function DateField({ value, onChange, min, max, disabled, className = "",
}
export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", interfaceId, helpTopicId, ...props }: BaseProps) {
export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", interfaceId, helpContextId, helpTopicId, ...props }: BaseProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const input = inputRef.current;
@@ -159,7 +167,14 @@ export function TimeField({ value, onChange, min, max, className = "", placehold
}, [value, min, max]);
return (
<div className={`time-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<div
className={`time-field ${className}`.trim()}
data-help-scope="field"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={props["aria-label"] ?? placeholder}
>
<input
{...props}
ref={inputRef}
@@ -175,7 +190,7 @@ export function TimeField({ value, onChange, min, max, className = "", placehold
}
export function DateTimeField({ value, onChange, min, max, disabled, className = "", interfaceId, helpTopicId, ...props }: BaseProps) {
export function DateTimeField({ value, onChange, min, max, disabled, className = "", interfaceId, helpContextId, helpTopicId, ...props }: BaseProps) {
const parts = datePartsFromDateTime(value);
const minParts = datePartsFromDateTime(min || "");
const maxParts = datePartsFromDateTime(max || "");
@@ -189,7 +204,14 @@ export function DateTimeField({ value, onChange, min, max, disabled, className =
}
return (
<div className={`date-time-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<div
className={`date-time-field ${className}`.trim()}
data-help-scope="field"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={props["aria-label"]}
>
<DateField
{...props}
value={parts.date}
+11 -2
View File
@@ -8,8 +8,9 @@ import {
registerDialog,
type DialogStackId
} from "./dialogStack";
import type { PlatformInterfaceIdentityProps } from "../types";
export type DialogProps = {
export type DialogProps = PlatformInterfaceIdentityProps & {
open: boolean;
title: ReactNode;
children: ReactNode;
@@ -56,7 +57,10 @@ export default function Dialog({
footerClassName = "",
portal = false,
panelStyle,
backdropStyle
backdropStyle,
interfaceId,
helpContextId,
helpTopicId
}: DialogProps) {
const titleId = useId();
const canClose = Boolean(onClose) && !closeDisabled;
@@ -134,6 +138,11 @@ export default function Dialog({
role={role}
aria-modal="true"
data-dialog-stack-state="topmost"
data-help-scope="dialog"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={typeof title === "string" ? title : undefined}
aria-labelledby={titleId}
aria-describedby={ariaDescribedBy}
>
+10 -2
View File
@@ -12,11 +12,19 @@ type FormFieldProps = PlatformInterfaceIdentityProps & {
children: ReactNode;
};
export default function FormField({ label, help, documentation, children, interfaceId, helpTopicId }: FormFieldProps) {
export default function FormField({ label, help, documentation, children, interfaceId, helpContextId, helpTopicId }: FormFieldProps) {
const { translateText } = usePlatformLanguage();
const renderedLabel = typeof label === "string" ? translateText(label) : label;
return (
<label className="form-field" data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<label
className="form-field"
data-help-scope="field"
data-interface-id={interfaceId}
data-help-context-id={helpContextId ?? documentation?.contextId}
data-help-topic-id={helpTopicId ?? documentation?.topicId}
data-help-documentation-type={documentation?.documentationType}
data-help-key={typeof label === "string" ? label : undefined}
>
<FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)} documentation={documentation}>{renderedLabel}</FieldLabel>
{children}
</label>
@@ -98,6 +98,7 @@ export default function SearchableSelect({
debounceMs = 200,
className = "",
interfaceId,
helpContextId,
helpTopicId
}: SearchableSelectProps) {
const { translateText } = usePlatformLanguage();
@@ -297,8 +298,11 @@ export default function SearchableSelect({
<div
ref={rootRef}
className={rootClassName}
data-help-scope="field"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={ariaLabel}
onBlur={closeOnFocusLeave}
>
<div className="searchable-select-control">
+9 -2
View File
@@ -14,7 +14,7 @@ type ToggleSwitchProps = PlatformInterfaceIdentityProps & {
help?: ReactNode;
};
export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help, interfaceId, helpTopicId }: ToggleSwitchProps) {
export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help, interfaceId, helpContextId, helpTopicId }: ToggleSwitchProps) {
const { translateText } = usePlatformLanguage();
const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined;
const renderedLabel = typeof label === "string" ? translateText(label) : label;
@@ -22,7 +22,14 @@ export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checke
const renderedActiveLabel = typeof activeLabel === "string" ? translateText(activeLabel) : activeLabel;
const inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined;
return (
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<label
className={`toggle-switch-row ${disabled ? "disabled" : ""}`}
data-help-scope="field"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={typeof label === "string" ? label : undefined}
>
<input
className="toggle-switch-input"
type="checkbox"
+16 -4
View File
@@ -3,8 +3,9 @@ import DismissibleAlert from "../DismissibleAlert";
import LoadingFrame from "../LoadingFrame";
import PageTitle from "../PageTitle";
import { usePlatformLanguage } from "../../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../../types";
type Props = {
type Props = PlatformInterfaceIdentityProps & {
title: string;
description: string;
loading?: boolean;
@@ -25,11 +26,22 @@ export default function AdminPageLayout({
success = "",
actions,
children,
className = ""
className = "",
interfaceId,
helpContextId,
helpTopicId
}: Props) {
const { translateText } = usePlatformLanguage();
return (
<div className={`admin-section-page ${className}`.trim()}>
<div
className={`admin-section-page ${className}`.trim()}
data-help-scope="page"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-documentation-type="admin"
data-help-key={title}
>
<div className="page-heading split workspace-heading admin-page-heading">
<div>
<PageTitle loading={loading}>{title}</PageTitle>
@@ -44,4 +56,4 @@ export default function AdminPageLayout({
</LoadingFrame>
</div>);
}
}
@@ -46,6 +46,7 @@ export default function EmailAddressInput({
compact = false,
showAddButton,
interfaceId,
helpContextId,
helpTopicId
}: EmailAddressInputProps) {
const { translateText } = usePlatformLanguage();
@@ -202,7 +203,14 @@ export default function EmailAddressInput({
) : null;
return (
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<div
className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}
data-help-scope="field"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-topic-id={helpTopicId}
data-help-key={emailPlaceholder}
>
<div className={`email-address-editor ${error ? "has-error" : ""}`}>
<div className="email-chip-list" aria-live="polite">
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{translateText(emptyText)}</span>}
@@ -3,6 +3,8 @@ export const HOSTED_DOCUMENTATION_URL = "https://govoplan.add-ideas.de/";
export type DocumentationHelpReference = {
topicId?: string;
contextId?: string;
fallbackContextId?: string;
moduleId?: string;
documentationType?: "user" | "admin";
anchorId?: string;
};
@@ -20,6 +22,10 @@ export function documentationHelpHref(
});
if (topicId) params.set("topic", topicId);
else if (contextId) params.set("context", contextId);
const fallbackContextId = reference.fallbackContextId?.trim();
if (fallbackContextId && fallbackContextId !== contextId) params.set("fallback_context", fallbackContextId);
const moduleId = reference.moduleId?.trim();
if (moduleId) params.set("module", moduleId);
const anchorId = reference.anchorId?.trim();
return `${baseUrl}?${params.toString()}${anchorId ? `#${encodeURIComponent(anchorId)}` : ""}`;
}
+42 -2
View File
@@ -637,7 +637,27 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.enter_a_valid_http_https_mail_or_phone_link.45447137": "Enter a valid HTTP, HTTPS, mail or phone link.",
"i18n:govoplan-core.enter_a_valid_http_https_cid_or_raster_data_image.2cec4b3c": "Enter a valid HTTP, HTTPS, CID or raster data image.",
"i18n:govoplan-core.this_html_uses_markup_outside_the_visual_editor.5adb2a3c": "This HTML uses markup outside the visual editor. Use HTML source mode to preserve it.",
"i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive"
"i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive",
"i18n:govoplan-core.data_state": "Data state",
"i18n:govoplan-core.validity": "Validity",
"i18n:govoplan-core.current": "Current",
"i18n:govoplan-core.at_time": "At time",
"i18n:govoplan-core.all": "All",
"i18n:govoplan-core.current_data": "Current data",
"i18n:govoplan-core.historical_data_state": "Historical data state",
"i18n:govoplan-core.historical_recorded_state": "Historical recorded state",
"i18n:govoplan-core.all_validity_periods": "All validity periods",
"i18n:govoplan-core.valid_at": "Valid at",
"i18n:govoplan-core.valid_at_help": "Select when the data was valid in the represented domain.",
"i18n:govoplan-core.recorded_state": "Recorded state",
"i18n:govoplan-core.recorded_state_help": "Optionally limit results to what the system had recorded by a point in time.",
"i18n:govoplan-core.latest_recorded_state": "Latest recorded state",
"i18n:govoplan-core.recorded_by_time": "State recorded by a point in time",
"i18n:govoplan-core.recorded_by": "Recorded by",
"i18n:govoplan-core.temporal_data_explanation": "Validity controls when a fact applies. Recorded state controls what the system knew. Access permissions are always evaluated now.",
"i18n:govoplan-core.apply_data_state": "Apply data state",
"i18n:govoplan-core.select_valid_date_time": "Select a valid date and time.",
"i18n:govoplan-core.temporal_selection_invalid": "The selected data state is invalid."
},
"de": {
"i18n:govoplan-core.generate_password.bd5bede8": "Passwort generieren",
@@ -1275,6 +1295,26 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.enter_a_valid_http_https_mail_or_phone_link.45447137": "Geben Sie einen gültigen HTTP-, HTTPS-, E-Mail- oder Telefon-Link ein.",
"i18n:govoplan-core.enter_a_valid_http_https_cid_or_raster_data_image.2cec4b3c": "Geben Sie eine gültige HTTP-, HTTPS-, CID- oder Rasterdaten-Bildadresse ein.",
"i18n:govoplan-core.this_html_uses_markup_outside_the_visual_editor.5adb2a3c": "Dieses HTML verwendet Markup außerhalb des visuellen Editors. Verwenden Sie den HTML-Quelltextmodus, um es zu erhalten.",
"i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive"
"i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive",
"i18n:govoplan-core.data_state": "Datenstand",
"i18n:govoplan-core.validity": "Gültigkeit",
"i18n:govoplan-core.current": "Aktuell",
"i18n:govoplan-core.at_time": "Zeitpunkt",
"i18n:govoplan-core.all": "Alle",
"i18n:govoplan-core.current_data": "Aktuell gültige Daten",
"i18n:govoplan-core.historical_data_state": "Historischer Datenstand",
"i18n:govoplan-core.historical_recorded_state": "Historischer Erfassungsstand",
"i18n:govoplan-core.all_validity_periods": "Alle Gültigkeitszeiträume",
"i18n:govoplan-core.valid_at": "Gültig am",
"i18n:govoplan-core.valid_at_help": "Wählen Sie, wann die Daten im dargestellten Sachverhalt gültig waren.",
"i18n:govoplan-core.recorded_state": "Erfassungsstand",
"i18n:govoplan-core.recorded_state_help": "Begrenzt die Ergebnisse optional auf den Stand, den das System bis zu einem Zeitpunkt erfasst hatte.",
"i18n:govoplan-core.latest_recorded_state": "Neuester Erfassungsstand",
"i18n:govoplan-core.recorded_by_time": "Bis zu einem Zeitpunkt erfasster Stand",
"i18n:govoplan-core.recorded_by": "Erfasst bis",
"i18n:govoplan-core.temporal_data_explanation": "Die Gültigkeit bestimmt, wann ein Sachverhalt gilt. Der Erfassungsstand bestimmt, was das System wusste. Berechtigungen werden immer aktuell geprüft.",
"i18n:govoplan-core.apply_data_state": "Datenstand anwenden",
"i18n:govoplan-core.select_valid_date_time": "Wählen Sie ein gültiges Datum und eine Uhrzeit.",
"i18n:govoplan-core.temporal_selection_invalid": "Der ausgewählte Datenstand ist ungültig."
}
};
+2
View File
@@ -32,6 +32,8 @@ export * from "./platform/ModuleContext";
export * from "./platform/moduleEvents";
export * from "./platform/ViewContext";
export * from "./platform/views";
export * from "./platform/temporal";
export * from "./platform/TemporalContext";
export * from "./platform/wizards";
export * from "./utils/permissions";
+31 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router";
import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react";
import packageInfo from "../../package.json";
@@ -7,7 +7,7 @@ import Dialog from "../components/Dialog";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
import { usePlatformModules } from "../platform/ModuleContext";
import type { PlatformWebModule } from "../types";
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
import { helpContextForPathname, helpContextForTarget, helpQueryForContext, type HelpContext } from "../utils/helpContext";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { AuthInfo } from "../types";
import { hasAnyScope } from "../utils/permissions";
@@ -21,8 +21,12 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
const wrapRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const navigate = useGuardedNavigate();
const helpContext = helpContextForPathname(location.pathname, location.search);
const modules = usePlatformModules();
const routeHelpContext = useMemo(
() => helpContextForPathname(location.pathname, location.search, modules),
[location.pathname, location.search, modules]
);
const [activeHelpContext, setActiveHelpContext] = useState<HelpContext>(routeHelpContext);
const { translateText } = usePlatformLanguage();
const adminDocsAvailable = hasAnyScope(auth, ["docs:documentation:admin", "system:settings:read", "admin:settings:read"]);
const configuredDocsAvailable = hasAnyScope(auth, ["docs:documentation:read"]) || adminDocsAvailable;
@@ -35,6 +39,7 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
setActiveHelpContext(helpContextForTarget(routeHelpContext, event.target, modules));
setOpen(false);
setContextOpen(true);
}
@@ -49,22 +54,28 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
window.removeEventListener("keydown", openContextHelp, true);
window.removeEventListener("mousedown", onPointerDown);
};
}, []);
}, [modules, routeHelpContext]);
useEffect(() => {
if (!contextOpen) setActiveHelpContext(routeHelpContext);
}, [contextOpen, routeHelpContext]);
function openHelp() {
setActiveHelpContext(routeHelpContext);
setOpen(false);
setContextOpen(true);
}
function openDocs(type: "user" | "admin") {
function openDocs(type: "user" | "admin", context: HelpContext = routeHelpContext) {
setOpen(false);
setContextOpen(false);
if (!docsAvailable) {
window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer");
window.open(externalDocsUrl(type, context), "_blank", "noopener,noreferrer");
return;
}
const params = new URLSearchParams({ type });
params.set("context", helpContext.id);
const contextParams = new URLSearchParams(helpQueryForContext(context));
contextParams.forEach((value, key) => params.set(key, value));
navigate(`/docs?${params.toString()}`);
}
@@ -72,6 +83,9 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
<div className="context-menu-wrap" ref={wrapRef}>
<button
className="titlebar-link"
data-help-context-id="core.contextual-help"
data-help-module-id="core"
data-help-scope="action"
onClick={() => setOpen(!open)}
onKeyDown={(event) => {
if (event.key === "F1") {
@@ -90,11 +104,11 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
<HelpCircle size={16} /> {translateText("i18n:govoplan-core.help.c47ae153")} <small>i18n:govoplan-core.f1.88bfad9c</small>
</button>
<hr />
<button className="dropdown-item" onClick={() => openDocs("user")} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}>
<button className="dropdown-item" onClick={() => openDocs("user", routeHelpContext)} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}>
<BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")}
</button>
{adminDocsAvailable &&
<button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
<button className="dropdown-item" onClick={() => openDocs("admin", routeHelpContext)} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
</button>
}
@@ -103,14 +117,16 @@ export default function HelpMenu({ auth }: { auth: AuthInfo | null }) {
<button className="dropdown-item" onClick={() => {setAboutOpen(true);setOpen(false);}}><Info size={16} /> {translateText("i18n:govoplan-core.about.6b21fb79")}</button>
</div>
}
{contextOpen && <ContextHelpModal context={helpContext} onOpenDocs={() => openDocs("user")} onClose={() => setContextOpen(false)} />}
{contextOpen && <ContextHelpModal context={activeHelpContext} onOpenDocs={() => openDocs(activeHelpContext.documentationType ?? "user", activeHelpContext)} onClose={() => setContextOpen(false)} />}
{aboutOpen && <AboutModal modules={modules} onClose={() => setAboutOpen(false)} />}
</div>);
}
function externalDocsUrl(type: "user" | "admin", context: HelpContext): string {
const params = new URLSearchParams({ type, context: context.id });
const params = new URLSearchParams({ type });
const contextParams = new URLSearchParams(helpQueryForContext(context));
contextParams.forEach((value, key) => params.set(key, value));
return `${EXTERNAL_DOCS_BASE_URL}/?${params.toString()}`;
}
@@ -125,13 +141,12 @@ function ContextHelpModal({ context, onOpenDocs, onClose }: {context: HelpContex
<div className="help-panel-section" data-help-context={context.id}>
<h3>{translateText(context.title)}</h3>
{context.parentId &&
<p className="muted"><strong>{translateText("i18n:govoplan-core.page.fb06270f")}:</strong> {translateText(context.parentTitle ?? context.parentId)}</p>
}
<p className="mono-small">{translateText("i18n:govoplan-core.help_context.61aed3b9")} {context.id}</p>
<p className="muted">{translateText("i18n:govoplan-core.this_area_is_prepared_for_context_sensitive_help.57665877")} <span className="kbd">{helpQueryForContext(context)}</span> {translateText("i18n:govoplan-core.to_open_the_right_page_or_section.5ecf4fd2")}</p>
</div>
<div className="help-panel-section">
<h3>{translateText("i18n:govoplan-core.next_actions.7b09055a")}</h3>
<p className="muted">{translateText("i18n:govoplan-core.the_first_guided_help_content_can_cover_campaign.14a6bd8a")}</p>
<Button onClick={onOpenDocs}><BookOpen size={16} /> {translateText("i18n:govoplan-core.open_user_documentation.084af515")}</Button>
<Button onClick={onOpenDocs}><BookOpen size={16} /> {translateText(context.documentationType === "admin" ? "i18n:govoplan-core.open_admin_documentation.6adbdae3" : "i18n:govoplan-core.open_user_documentation.084af515")}</Button>
</div>
</Dialog>);
+9 -1
View File
@@ -25,7 +25,15 @@ export default function LanguageMenu() {
return (
<div className="context-menu-wrap language-menu-wrap" ref={menuRef}>
<button className="titlebar-link language-menu-button" onClick={() => setOpen(!open)} aria-haspopup="menu" aria-expanded={open}>
<button
className="titlebar-link language-menu-button"
data-help-context-id="core.titlebar.language"
data-help-module-id="core"
data-help-scope="action"
onClick={() => setOpen(!open)}
aria-haspopup="menu"
aria-expanded={open}
>
<span className="language-menu-code">{language.toUpperCase()}</span>
<span className="tenant-caret"></span>
</button>
+220
View File
@@ -0,0 +1,220 @@
import { Calendar, CalendarOff } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import Button from "../components/Button";
import DismissibleAlert from "../components/DismissibleAlert";
import FormField from "../components/FormField";
import SegmentedControl from "../components/SegmentedControl";
import { useUnsavedChanges } from "../components/UnsavedChangesGuard";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import { useTemporalDataContext } from "../platform/TemporalContext";
import {
normalizeTemporalDataSelection,
type TemporalDataSelection,
type TemporalValidityMode
} from "../platform/temporal";
export default function TemporalDataMenu() {
const { selection, applySelection, isDefault } = useTemporalDataContext();
const { translateText } = usePlatformLanguage();
const { requestNavigation } = useUnsavedChanges();
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<TemporalDataSelection>(selection);
const [error, setError] = useState("");
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onPointerDown(event: MouseEvent) {
const target = event.target as Node;
if (menuRef.current && !menuRef.current.contains(target)) setOpen(false);
}
window.addEventListener("mousedown", onPointerDown);
return () => window.removeEventListener("mousedown", onPointerDown);
}, []);
useEffect(() => {
if (!open) setDraft(selection);
}, [open, selection]);
function toggleOpen() {
if (!open) {
setDraft(selection);
setError("");
}
setOpen(!open);
}
function selectValidityMode(validityMode: TemporalValidityMode) {
setDraft((current) => ({
...current,
validityMode,
validAt: validityMode === "at"
? current.validAt ?? new Date().toISOString()
: null
}));
}
function selectRecordedMode(mode: "latest" | "at") {
setDraft((current) => ({
...current,
recordedAt: mode === "at"
? current.recordedAt ?? new Date().toISOString()
: null
}));
}
function apply() {
try {
const normalized = normalizeTemporalDataSelection(draft);
requestNavigation(() => {
applySelection(normalized);
setOpen(false);
setError("");
});
} catch (caught) {
setError(
caught instanceof Error
? caught.message
: "i18n:govoplan-core.temporal_selection_invalid"
);
}
}
const iconTitle = selection.validityMode === "all"
? "i18n:govoplan-core.all_validity_periods"
: selection.validityMode === "at"
? "i18n:govoplan-core.historical_data_state"
: selection.recordedAt
? "i18n:govoplan-core.historical_recorded_state"
: "i18n:govoplan-core.current_data";
const Icon = selection.validityMode === "all" ? CalendarOff : Calendar;
const validDateMissing = draft.validityMode === "at" && !draft.validAt;
return (
<div className="context-menu-wrap temporal-data-menu-wrap" ref={menuRef}>
<button
type="button"
className={`titlebar-icon-link${isDefault ? "" : " is-context-active"}`}
data-help-context-id="core.temporal-data-context"
data-help-module-id="core"
data-help-scope="action"
onClick={toggleOpen}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={translateText(iconTitle)}
title={translateText(iconTitle)}
>
<Icon size={18} aria-hidden="true" />
</button>
{open && (
<div
className="dropdown-menu temporal-data-menu"
role="dialog"
aria-label={translateText("i18n:govoplan-core.data_state")}
>
<div className="temporal-data-menu-heading">
<strong>i18n:govoplan-core.data_state</strong>
</div>
<SegmentedControl<TemporalValidityMode>
className="temporal-validity-control"
value={draft.validityMode}
width="fill"
size="equal"
ariaLabel={translateText("i18n:govoplan-core.validity")}
onChange={selectValidityMode}
options={[
{ id: "current", label: "i18n:govoplan-core.current" },
{ id: "at", label: "i18n:govoplan-core.at_time" },
{ id: "all", label: "i18n:govoplan-core.all" }
]}
/>
{draft.validityMode === "at" && (
<FormField
label="i18n:govoplan-core.valid_at"
help="i18n:govoplan-core.valid_at_help"
>
<input
type="datetime-local"
value={toLocalInput(draft.validAt)}
onChange={(event) => setDraft((current) => ({
...current,
validAt: fromLocalInput(event.target.value)
}))}
/>
</FormField>
)}
<div className="temporal-recorded-section">
<FormField
label="i18n:govoplan-core.recorded_state"
help="i18n:govoplan-core.recorded_state_help"
>
<select
value={draft.recordedAt ? "at" : "latest"}
onChange={(event) => selectRecordedMode(
event.target.value === "at" ? "at" : "latest"
)}
>
<option value="latest">i18n:govoplan-core.latest_recorded_state</option>
<option value="at">i18n:govoplan-core.recorded_by_time</option>
</select>
</FormField>
{draft.recordedAt && (
<FormField label="i18n:govoplan-core.recorded_by">
<input
type="datetime-local"
value={toLocalInput(draft.recordedAt)}
onChange={(event) => setDraft((current) => ({
...current,
recordedAt: fromLocalInput(event.target.value)
}))}
/>
</FormField>
)}
</div>
<p className="temporal-data-explanation">
i18n:govoplan-core.temporal_data_explanation
</p>
{error && (
<DismissibleAlert tone="danger" compact resetKey={error}>
{error}
</DismissibleAlert>
)}
<div className="temporal-data-menu-actions">
<Button
type="button"
variant="primary"
onClick={apply}
disabled={validDateMissing}
disabledReason={validDateMissing
? "i18n:govoplan-core.select_valid_date_time"
: undefined}
>
i18n:govoplan-core.apply_data_state
</Button>
</div>
</div>
)}
</div>
);
}
function toLocalInput(value: string | null): string {
if (!value) return "";
const instant = new Date(value);
if (!Number.isFinite(instant.getTime())) return "";
const local = new Date(instant.getTime() - instant.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function fromLocalInput(value: string): string | null {
if (!value) return null;
const instant = new Date(value);
return Number.isFinite(instant.getTime()) ? instant.toISOString() : null;
}
+31 -39
View File
@@ -1,8 +1,9 @@
import { useRef, useState, useEffect } from "react";
import { Bell, Check, LogOut, Settings, TriangleAlert, UserCircle, WifiOff } from "lucide-react";
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
import type { ActingContextRuntimeUiCapability, ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types";
import HelpMenu from "./HelpMenu";
import LanguageMenu from "./LanguageMenu";
import TemporalDataMenu from "./TemporalDataMenu";
import LoginModal from "../features/auth/LoginModal";
import DismissibleAlert from "../components/DismissibleAlert";
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
@@ -48,8 +49,6 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
hasAnyScope(auth, searchRuntime.anyOf)
)
);
const showGlobalSearch = Boolean(auth && GlobalSearch && canUseGlobalSearch);
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
const displayUserName = auth?.user?.display_name || auth?.user?.email || translateText("i18n:govoplan-core.sign_in.ada2e9e9");
@@ -62,7 +61,7 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
);
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
const showContextSelectors = Boolean(
auth && ((activeTenant && showTenantControl) || ViewSelector || ActingContextSelector)
auth && ((activeTenant && showTenantControl) || ActingContextSelector)
);
const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications"));
const notificationSummary = useSharedNotificationSummary(settings, {
@@ -152,34 +151,27 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
}
return (
<header className={`titlebar${showGlobalSearch ? " has-global-search" : ""}${titlebarState ? ` is-${titlebarState}` : ""}`}>
{titlebarState &&
<div className="titlebar-status-pattern" aria-hidden="true">
{Array.from({ length: 10 }, (_, index) => <span key={index}>{titlebarStateLabel}</span>)}
<header className="titlebar">
{titlebarState === "offline" &&
<div
className="backend-offline-topbar-alert"
role="status"
aria-live="polite"
title={titlebarStateLabel}>
{titlebarStateLabel}
</div>
}
{titlebarState === "maintenance" &&
<button
type="button"
className="maintenance-topbar-link"
title={maintenanceMode?.message || translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
onClick={openMaintenanceSettings}>
{titlebarStateLabel}
</button>
}
<div className="titlebar-leading">
{titlebarState === "offline" &&
<div
className="backend-offline-topbar-alert"
role="status"
aria-live="polite"
aria-label={titlebarStateLabel}
title={titlebarStateLabel}>
<WifiOff size={18} aria-hidden="true" />
</div>
}
{titlebarState === "maintenance" &&
<button
type="button"
className="maintenance-topbar-link"
aria-label={translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
title={maintenanceMode?.message || translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
onClick={openMaintenanceSettings}>
<TriangleAlert size={18} aria-hidden="true" />
</button>
}
{auth && showContextSelectors &&
<div className="titlebar-context-selectors">
{activeTenant && showTenantControl &&
@@ -187,7 +179,7 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
<span className="tenant-label">{translateText("i18n:govoplan-core.tenant_label_prefix")}</span>
{canSwitchTenant ?
<>
<button className="tenant-name-button" onClick={() => setTenantOpen(!tenantOpen)}>
<button className="tenant-name-button" data-help-context-id="tenancy.selector" data-help-module-id="tenancy" onClick={() => setTenantOpen(!tenantOpen)}>
<strong>{activeTenant.name}</strong>
<span className="tenant-caret"></span>
</button>
@@ -216,9 +208,6 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
}
</div>
}
{ViewSelector &&
<ViewSelector settings={settings} auth={auth} projection={projection} />
}
{ActingContextSelector &&
<ActingContextSelector settings={settings} auth={auth} onAuthChange={onAuthChange} />
}
@@ -226,18 +215,19 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
}
</div>
{auth && GlobalSearch && canUseGlobalSearch &&
<div className="titlebar-global-search">
<GlobalSearch settings={settings} auth={auth} />
</div>
}
<div className="titlebar-actions">
{auth && GlobalSearch && canUseGlobalSearch &&
<GlobalSearch settings={settings} auth={auth} />
}
<LanguageMenu />
{auth && ViewSelector &&
<ViewSelector settings={settings} auth={auth} projection={projection} />
}
{auth && <TemporalDataMenu />}
<HelpMenu auth={auth} />
{auth && notificationsAvailable &&
<button className="titlebar-icon-link titlebar-notification-button" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}>
<button className="titlebar-icon-link titlebar-notification-button" data-help-context-id="notifications.route.notifications" data-help-module-id="notifications" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}>
<Bell size={18} />
{unreadNotificationCount > 0 &&
<span className="titlebar-notification-badge" aria-label={`${unreadNotificationCount} unread`}>
@@ -250,6 +240,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
<div className="context-menu-wrap" ref={accountRef}>
<button
className="account-pill"
data-help-context-id="access.account-menu"
data-help-module-id="access"
aria-label={displayUserName}
aria-haspopup="menu"
aria-expanded={accountOpen}
+112
View File
@@ -0,0 +1,112 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode
} from "react";
import {
DEFAULT_TEMPORAL_DATA_SELECTION,
dispatchTemporalContextChanged,
normalizeTemporalDataSelection,
setActiveTemporalDataSelection,
temporalDataSelectionIsDefault,
temporalDataSelectionKey,
type TemporalDataSelection
} from "./temporal";
const TEMPORAL_STORAGE_PREFIX = "govoplan.temporal-data";
type TemporalContextValue = {
selection: TemporalDataSelection;
applySelection: (selection: TemporalDataSelection) => void;
isDefault: boolean;
selectionKey: string;
};
const TemporalContext = createContext<TemporalContextValue>({
selection: DEFAULT_TEMPORAL_DATA_SELECTION,
applySelection: () => undefined,
isDefault: true,
selectionKey: temporalDataSelectionKey(DEFAULT_TEMPORAL_DATA_SELECTION)
});
export function PlatformTemporalProvider({
scopeKey,
children
}: {
scopeKey: string;
children: ReactNode;
}) {
const [selection, setSelection] = useState<TemporalDataSelection>(() =>
loadSelection(scopeKey)
);
useEffect(() => {
const stored = loadSelection(scopeKey);
setActiveTemporalDataSelection(stored);
setSelection(stored);
dispatchTemporalContextChanged(stored, scopeKey);
return () => {
setActiveTemporalDataSelection(DEFAULT_TEMPORAL_DATA_SELECTION);
};
}, [scopeKey]);
const applySelection = useCallback((next: TemporalDataSelection) => {
const normalized = normalizeTemporalDataSelection(next);
setActiveTemporalDataSelection(normalized);
storeSelection(scopeKey, normalized);
setSelection(normalized);
dispatchTemporalContextChanged(normalized, scopeKey);
}, [scopeKey]);
const value = useMemo<TemporalContextValue>(() => ({
selection,
applySelection,
isDefault: temporalDataSelectionIsDefault(selection),
selectionKey: temporalDataSelectionKey(selection)
}), [applySelection, selection]);
return (
<TemporalContext.Provider value={value}>
{children}
</TemporalContext.Provider>
);
}
export function useTemporalDataContext(): TemporalContextValue {
return useContext(TemporalContext);
}
function storageKey(scopeKey: string): string {
return `${TEMPORAL_STORAGE_PREFIX}.${scopeKey}`;
}
function loadSelection(scopeKey: string): TemporalDataSelection {
if (typeof sessionStorage === "undefined") {
return DEFAULT_TEMPORAL_DATA_SELECTION;
}
const stored = sessionStorage.getItem(storageKey(scopeKey));
if (!stored) return DEFAULT_TEMPORAL_DATA_SELECTION;
try {
return normalizeTemporalDataSelection(JSON.parse(stored));
} catch {
sessionStorage.removeItem(storageKey(scopeKey));
return DEFAULT_TEMPORAL_DATA_SELECTION;
}
}
function storeSelection(
scopeKey: string,
selection: TemporalDataSelection
): void {
if (typeof sessionStorage === "undefined") return;
if (temporalDataSelectionIsDefault(selection)) {
sessionStorage.removeItem(storageKey(scopeKey));
return;
}
sessionStorage.setItem(storageKey(scopeKey), JSON.stringify(selection));
}
+2
View File
@@ -194,6 +194,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
routes: routesWithServerMetadata(module, info),
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
viewSurfaces: mergeViewSurfaces(module, info),
helpContexts: info.help_contexts ?? module.helpContexts,
uiCapabilities: {
...(module.uiCapabilities ?? {}),
...runtimeUiCapabilitiesForModule(module, info)
@@ -218,6 +219,7 @@ function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
dependencies: [],
optional_dependencies: [],
enabled: true,
help_contexts: info.help_contexts,
runtime_ui_capabilities: [],
nav: [],
frontend: {
+98
View File
@@ -0,0 +1,98 @@
export type TemporalValidityMode = "current" | "at" | "all";
export type TemporalDataSelection = {
validityMode: TemporalValidityMode;
validAt: string | null;
recordedAt: string | null;
};
export type TemporalContextChangedEventDetail = {
selection: TemporalDataSelection;
scopeKey: string;
};
export const PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT =
"govoplan:temporal-context-changed";
export const DEFAULT_TEMPORAL_DATA_SELECTION: TemporalDataSelection = {
validityMode: "current",
validAt: null,
recordedAt: null
};
let activeSelection = DEFAULT_TEMPORAL_DATA_SELECTION;
export function normalizeTemporalDataSelection(
value: Partial<TemporalDataSelection> | null | undefined
): TemporalDataSelection {
const validityMode = ["current", "at", "all"].includes(
String(value?.validityMode)
)
? value?.validityMode as TemporalValidityMode
: "current";
const validAt = validityMode === "at"
? normalizeTimestamp(value?.validAt)
: null;
if (validityMode === "at" && !validAt) {
throw new Error("i18n:govoplan-core.select_valid_date_time");
}
return {
validityMode,
validAt,
recordedAt: normalizeTimestamp(value?.recordedAt)
};
}
export function temporalDataSelectionIsDefault(
value: TemporalDataSelection
): boolean {
return value.validityMode === "current" && !value.recordedAt;
}
export function temporalDataSelectionKey(value: TemporalDataSelection): string {
return [value.validityMode, value.validAt ?? "", value.recordedAt ?? ""].join(":");
}
export function setActiveTemporalDataSelection(
value: TemporalDataSelection
): void {
activeSelection = normalizeTemporalDataSelection(value);
}
export function activeTemporalDataSelection(): TemporalDataSelection {
return activeSelection;
}
export function temporalRequestHeaders(): Record<string, string> {
const selection = activeTemporalDataSelection();
if (temporalDataSelectionIsDefault(selection)) return {};
const headers: Record<string, string> = {
"X-Govoplan-Validity-Mode": selection.validityMode
};
if (selection.validAt) headers["X-Govoplan-Valid-At"] = selection.validAt;
if (selection.recordedAt) {
headers["X-Govoplan-Recorded-At"] = selection.recordedAt;
}
return headers;
}
export function dispatchTemporalContextChanged(
selection: TemporalDataSelection,
scopeKey: string
): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent<TemporalContextChangedEventDetail>(
PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT,
{ detail: { selection, scopeKey } }
)
);
}
function normalizeTimestamp(value: string | null | undefined): string | null {
const clean = String(value || "").trim();
if (!clean) return null;
const instant = new Date(clean);
if (!Number.isFinite(instant.getTime())) return null;
return instant.toISOString();
}
+1 -1
View File
@@ -124,7 +124,7 @@
.titlebar-link,
.account-pill {
padding: 8px 10px;
margin: -8px -10px;
margin: 0;
border-radius: 7px;
transition: background-color .12s ease, color .12s ease, box-shadow .12s ease;
cursor: pointer;
+17 -27
View File
@@ -28,17 +28,8 @@
.icon-rail.compact { width: 58px; }
.app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
.titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 0 18px; gap: 18px; z-index: 100; box-shadow: var(--shadow-chrome); }
.titlebar.is-maintenance { background: var(--warning-bg); border-bottom-color: var(--warning-border-soft); }
.titlebar.is-offline { background: var(--danger-bg); border-bottom-color: var(--danger-border-deep); }
.titlebar > :not(.titlebar-status-pattern) { position: relative; z-index: 1; }
.titlebar-status-pattern { position: absolute; inset: 0; display: flex; align-items: center; gap: 34px; overflow: hidden; padding: 0 14px; pointer-events: none; white-space: nowrap; }
.titlebar-status-pattern span { flex: 0 0 auto; color: var(--warning-text); font-size: 11px; font-weight: 800; opacity: .16; }
.titlebar.is-offline .titlebar-status-pattern span { color: var(--danger-text); opacity: .18; }
.titlebar.has-global-search { grid-template-columns: minmax(0, 1fr) minmax(190px, min(360px, 28vw)) minmax(0, 1fr); }
.titlebar-leading { grid-column: 1; display: flex; align-items: center; min-width: 0; }
.titlebar-global-search { grid-column: 2; position: relative; width: 100%; min-width: 0; height: 34px; }
.titlebar-actions { grid-column: 2; display: flex; align-items: center; justify-self: end; min-width: 0; gap: 10px; }
.titlebar.has-global-search .titlebar-actions { grid-column: 3; }
.titlebar-context-selectors { display: flex; align-items: center; min-width: 0; gap: 12px; }
.acting-context-selector { display: inline-flex; align-items: center; min-width: 0; gap: 7px; color: var(--muted); }
.acting-context-selector select { width: auto; max-width: 260px; min-height: 32px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-strong); font: inherit; font-weight: 700; padding: 5px 24px 5px 8px; }
@@ -53,20 +44,31 @@
.titlebar-link, .titlebar-icon-link, .account-pill { border: 0; background: transparent; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: inherit; }
.titlebar-icon-link { width: 34px; height: 34px; justify-content: center; border-radius: 4px; cursor: pointer; }
.titlebar-icon-link:hover, .titlebar-link:hover { background: var(--titlebar-hover-bg); color: var(--text-strong); }
.titlebar-icon-link.is-context-active { background: var(--accent-hover-bg); color: var(--accent); }
.titlebar-icon-link.is-context-active:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); }
.titlebar-notification-button { position: relative; }
.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); }
.account-pill { color: var(--text); }
.maintenance-topbar-link,
.backend-offline-topbar-alert { width: 34px; height: 34px; flex: 0 0 auto; box-sizing: border-box; border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; padding: 0; font: inherit; box-shadow: 0 1px 2px var(--hover-tint); }
.maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--surface); color: var(--warning-text); cursor: pointer; }
.backend-offline-topbar-alert { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); min-height: 32px; max-width: min(560px, calc(100vw - 24px)); box-sizing: border-box; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; padding: 0 14px; overflow: hidden; font: inherit; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; box-shadow: 0 1px 2px var(--hover-tint); z-index: 1; }
.maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--warning-bg); color: var(--warning-text); cursor: pointer; }
.maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); }
.backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--surface); color: var(--danger-text); }
.backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--red); color: var(--on-accent); }
.language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; }
.language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
.language-menu { min-width: 210px; }
.language-menu .dropdown-item { justify-content: flex-start; }
.language-menu .dropdown-item svg { margin-left: auto; }
.language-option-code { width: 34px; color: var(--muted); }
.temporal-data-menu { width: min(360px, calc(100vw - 20px)); box-sizing: border-box; display: grid; gap: 12px; padding: 14px; }
.temporal-data-menu-heading { display: flex; align-items: center; min-height: 24px; }
.temporal-validity-control .segmented-control-option { padding-inline: 10px; }
.temporal-recorded-section { display: grid; gap: 10px; padding-top: 12px; border-top: var(--border-line); }
.temporal-data-menu .form-field { gap: 5px; }
.temporal-data-menu input,
.temporal-data-menu select { width: 100%; min-width: 0; box-sizing: border-box; }
.temporal-data-explanation { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.45; }
.temporal-data-menu-actions { display: flex; justify-content: flex-end; }
.api-mini { display: flex; gap: 6px; }
.api-mini input { width: 155px; height: 30px; border: var(--border-line); border-radius: var(--radius-sm); padding: 0 8px; }
.breadcrumb-bar { background: var(--bar); border-bottom: var(--border-line-dark); display: flex; align-items: center; padding: 0 22px; box-shadow: var(--shadow-chrome); z-index: 90; }
@@ -219,7 +221,6 @@
.code-panel { background: var(--code-bg); color: var(--code-text); padding: 18px; border-radius: 4px; overflow: auto; }
@media (max-width: 900px) {
.api-mini { display: none; }
.titlebar.has-global-search { grid-template-columns: minmax(0, 1fr) 34px auto; }
.workspace { grid-template-columns: 1fr; }
.section-sidebar { display: none; }
.wizard-card { grid-template-columns: 1fr; }
@@ -344,9 +345,8 @@
grid-template-rows: 104px 51px minmax(0, 1fr);
}
.titlebar,
.titlebar.has-global-search {
grid-template-columns: 34px minmax(0, 1fr);
.titlebar {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 42px 42px;
column-gap: 6px;
row-gap: 4px;
@@ -367,22 +367,12 @@
scrollbar-width: thin;
}
.titlebar-global-search {
.titlebar-actions {
grid-column: 1;
grid-row: 2;
}
.titlebar-actions,
.titlebar.has-global-search .titlebar-actions {
grid-column: 2;
grid-row: 2;
gap: 2px;
}
.titlebar:not(.has-global-search) .titlebar-actions {
grid-column: 1 / -1;
}
.language-menu-button {
min-width: 40px;
padding-inline: 4px;
+20
View File
@@ -337,6 +337,8 @@ export type AdminSectionContribution = {
anyOf?: string[];
allOf?: string[];
surfaceId?: string;
helpContextId?: string;
helpTopicId?: string;
render: (context: AdminSectionRenderContext) => ReactNode;
};
@@ -361,6 +363,8 @@ export type SettingsSectionContribution = {
anyOf?: string[];
allOf?: string[];
surfaceId?: string;
helpContextId?: string;
helpTopicId?: string;
render: (context: SettingsSectionRenderContext) => ReactNode;
};
@@ -374,12 +378,16 @@ export type PlatformRouteContribution = {
allOf?: string[];
order?: number;
surfaceId?: string;
helpContextId?: string;
helpTopicId?: string;
render: (context: PlatformRouteContext) => ReactNode;
};
export type PlatformPublicRouteContribution = {
path: string;
order?: number;
helpContextId?: string;
helpTopicId?: string;
render: (context: PlatformPublicRouteContext) => ReactNode;
};
@@ -388,6 +396,8 @@ export type PlatformUiCapabilities = Record<string, unknown>;
export type PlatformInterfaceIdentityProps = {
/** Stable control-plane identity; use a module-namespaced value. */
interfaceId?: string;
/** Stable contextual-help identifier associated with the control. */
helpContextId?: string;
/** Optional stable documentation/help topic associated with the control. */
helpTopicId?: string;
};
@@ -414,6 +424,7 @@ export type PlatformWebModule = {
uiCapabilities?: PlatformUiCapabilities;
runtimeUiCapabilities?: PlatformUiCapabilities;
viewSurfaces?: PlatformViewSurface[];
helpContexts?: PlatformDocumentationHelpContext[];
};
export type EffectiveViewOption = {
@@ -1040,10 +1051,18 @@ export type PlatformFrontendModuleInfo = {
}>;
};
export type PlatformDocumentationHelpContext = {
id: string;
topic_id: string;
title: string;
documentation_types: Array<"user" | "admin">;
};
export type PlatformPublicModuleInfo = {
id: string;
name: string;
version: string;
help_contexts?: PlatformDocumentationHelpContext[];
frontend: Pick<
PlatformFrontendModuleInfo,
| "module_id"
@@ -1090,6 +1109,7 @@ export type PlatformModuleInfo = {
dependencies: string[];
optional_dependencies: string[];
enabled: boolean;
help_contexts?: PlatformDocumentationHelpContext[];
architecture?: {
contract_version: string;
layer: string;
+385 -45
View File
@@ -1,74 +1,414 @@
import type { PlatformWebModule } from "../types";
export type HelpContextKind = "page" | "field" | "action" | "dialog" | "interface";
export type HelpContext = {
id: string;
title: string;
route: string;
kind: HelpContextKind;
moduleId?: string;
parentId?: string;
parentTitle?: string;
documentationTopicId?: string;
documentationType?: "user" | "admin";
};
const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = {
data: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26" },
campaign: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26" },
fields: { id: "campaign.fields", title: "i18n:govoplan-core.campaign_fields.969e7d80" },
template: { id: "campaign.template", title: "i18n:govoplan-core.template.3ec1ae06" },
files: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6" },
attachments: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6" },
recipients: { id: "campaign.recipients", title: "i18n:govoplan-core.sender_recipients.922c6d24" },
"recipient-data": { id: "campaign.recipient-data", title: "i18n:govoplan-core.recipient_data.c2baaf10" },
"mail-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7" },
"server-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7" },
mail: { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7" },
"global-settings": { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849" },
settings: { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849" },
review: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d" },
send: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d" },
report: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303" },
reports: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303" },
audit: { id: "campaign.audit", title: "i18n:govoplan-core.audit_log.3cfc5f1c" },
json: { id: "campaign.json", title: "i18n:govoplan-core.json.031a4e76" }
type ContextDefinition = Omit<HelpContext, "route" | "kind">;
const campaignSectionContexts: Record<string, ContextDefinition> = {
data: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26", moduleId: "campaigns" },
campaign: { id: "campaign.settings", title: "i18n:govoplan-core.campaign_settings.efffec26", moduleId: "campaigns" },
fields: { id: "campaign.fields", title: "i18n:govoplan-core.campaign_fields.969e7d80", moduleId: "campaigns" },
template: { id: "campaign.template", title: "i18n:govoplan-core.template.3ec1ae06", moduleId: "campaigns" },
files: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6", moduleId: "campaigns" },
attachments: { id: "campaign.attachments", title: "i18n:govoplan-core.attachments.6771ade6", moduleId: "campaigns" },
recipients: { id: "campaign.recipients", title: "i18n:govoplan-core.sender_recipients.922c6d24", moduleId: "campaigns" },
"recipient-data": { id: "campaign.recipient-data", title: "i18n:govoplan-core.recipient_data.c2baaf10", moduleId: "campaigns" },
"mail-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7", moduleId: "campaigns" },
"server-settings": { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7", moduleId: "campaigns" },
mail: { id: "campaign.server-settings", title: "i18n:govoplan-core.server_settings.28af3cc7", moduleId: "campaigns" },
"global-settings": { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849", moduleId: "campaigns" },
settings: { id: "campaign.global-settings", title: "i18n:govoplan-core.policies.8d611849", moduleId: "campaigns" },
review: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d", moduleId: "campaigns" },
send: { id: "campaign.review-send", title: "i18n:govoplan-core.review_send.1627617d", moduleId: "campaigns" },
report: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303", moduleId: "campaigns" },
reports: { id: "campaign.report", title: "i18n:govoplan-core.report.ee45c303", moduleId: "campaigns" },
audit: { id: "campaign.audit", title: "i18n:govoplan-core.audit_log.3cfc5f1c", moduleId: "campaigns" },
json: { id: "campaign.json", title: "i18n:govoplan-core.json.031a4e76", moduleId: "campaigns" }
};
const topLevelContexts: Record<string, Omit<HelpContext, "route">> = {
dashboard: { id: "app.dashboard", title: "i18n:govoplan-core.dashboard.d87f47b4" },
campaigns: { id: "campaigns.list", title: "i18n:govoplan-core.campaigns.01a23a28" },
templates: { id: "templates.list", title: "i18n:govoplan-core.templates.f25b700e" },
files: { id: "files.list", title: "i18n:govoplan-core.files.6ce6c512" },
mail: { id: "mail.list", title: "i18n:govoplan-mail.mail.92379cbb" },
"address-book": { id: "address-book.list", title: "i18n:govoplan-core.address_book.f6327f59" },
reports: { id: "reports.list", title: "i18n:govoplan-core.reports.88bc3fe3" },
settings: { id: "app.settings", title: "i18n:govoplan-core.settings.c7f73bb5" },
admin: { id: "app.admin", title: "i18n:govoplan-core.admin.4e7afebc" }
const topLevelContexts: Record<string, ContextDefinition> = {
dashboard: { id: "dashboard.page", title: "i18n:govoplan-core.dashboard.d87f47b4", moduleId: "dashboard" },
campaigns: { id: "campaigns.list", title: "i18n:govoplan-core.campaigns.01a23a28", moduleId: "campaigns" },
templates: { id: "templates.page", title: "i18n:govoplan-core.templates.f25b700e", moduleId: "templates" },
files: { id: "files.list", title: "i18n:govoplan-core.files.6ce6c512", moduleId: "files" },
mail: { id: "mail.list", title: "i18n:govoplan-mail.mail.92379cbb", moduleId: "mail" },
"address-book": { id: "addresses.page", title: "i18n:govoplan-core.address_book.f6327f59", moduleId: "addresses" },
settings: { id: "app.settings", title: "i18n:govoplan-core.settings.c7f73bb5", moduleId: "core", documentationType: "user" },
admin: { id: "app.admin", title: "i18n:govoplan-core.admin.4e7afebc", moduleId: "admin", documentationType: "admin" }
};
export function helpContextForPathname(pathname: string, search = ""): HelpContext {
export function helpContextForPathname(
pathname: string,
search = "",
modules: readonly PlatformWebModule[] = []
): HelpContext {
const route = pathname || "/";
const routeWithSearch = `${route}${search}`;
const segments = route.split("/").filter(Boolean);
if (segments[0] === "settings") {
const section = new URLSearchParams(search).get("section") || "";
if (section === "mail-profiles") {
return { id: "mail.profiles", title: "i18n:govoplan-core.mail_profiles.8a8018b7", route: `${route}${search}` };
if (segments[0] === "settings" || segments[0] === "admin") {
const area = segments[0];
const requestedSection = new URLSearchParams(search).get("section");
const section = requestedSection || (area === "admin" ? "overview" : "interface");
if (area === "settings" && section === "mail-profiles") {
return pageContext(
{ id: "mail.profiles", title: "i18n:govoplan-core.mail_profiles.8a8018b7", moduleId: "mail" },
routeWithSearch,
modules
);
}
const surfaceContext = sectionContext(modules, section, area);
if (surfaceContext) return pageContext(surfaceContext, routeWithSearch, modules);
const fallback = topLevelContexts[area];
return pageContext({
...fallback,
id: `${fallback.id}.${stableSlug(section)}`,
title: humanize(section),
documentationType: area === "admin" ? "admin" : "user"
}, routeWithSearch, modules);
}
if (segments[0] === "campaigns" && segments[1]) {
if (!segments[2]) return { id: "campaign.overview", title: "i18n:govoplan-core.campaign_overview.43c3d159", route };
if (segments[1] === "queue") {
return pageContext({ id: "campaign.operator-queue", title: "Operator queue", moduleId: "campaigns" }, routeWithSearch, modules);
}
if (segments[1] === "reports") {
return pageContext({ id: "campaign.report", title: "i18n:govoplan-core.reports.88bc3fe3", moduleId: "campaigns" }, routeWithSearch, modules);
}
if (!segments[2]) {
return pageContext({ id: "campaign.overview", title: "i18n:govoplan-core.campaign_overview.43c3d159", moduleId: "campaigns" }, routeWithSearch, modules);
}
if (segments[2] === "wizard") {
const step = segments[3] || "create";
return { id: `campaign.wizard.${step}`, title: `${capitalize(step)} wizard`, route };
return pageContext({ id: `campaign.wizard.${stableSlug(step)}`, title: `${humanize(step)} wizard`, moduleId: "campaigns" }, routeWithSearch, modules);
}
const context = campaignSectionContexts[segments[2]];
if (context) return { ...context, route };
return { id: "campaign.workspace", title: "i18n:govoplan-core.campaign_workspace.c345580f", route };
if (context) return pageContext(context, routeWithSearch, modules);
return pageContext({ id: "campaign.workspace", title: "i18n:govoplan-core.campaign_workspace.c345580f", moduleId: "campaigns" }, routeWithSearch, modules);
}
const context = topLevelContexts[segments[0] || "campaigns"];
if (context) return { ...context, route };
return { id: "app.general", title: "i18n:govoplan-core.application.b291beb8", route };
if (segments.length <= 1) {
const context = topLevelContexts[segments[0] || "campaigns"];
if (context) return pageContext(context, routeWithSearch, modules);
}
const matchedRoute = moduleRouteContext(route, modules);
if (matchedRoute) return pageContext(matchedRoute, routeWithSearch, modules);
const root = stableSlug(segments[0] || "application");
return pageContext({
id: root === "application" ? "app.general" : `${root}.page`,
title: root === "application" ? "i18n:govoplan-core.application.b291beb8" : humanize(segments[segments.length - 1] || root),
moduleId: root === "application" ? "core" : root
}, routeWithSearch, modules);
}
export function helpContextForTarget(
base: HelpContext,
target: EventTarget | null,
modules: readonly PlatformWebModule[] = []
): HelpContext {
if (typeof Element === "undefined" || !(target instanceof Element)) return base;
const explicit = target.closest<HTMLElement>(
"[data-help-context-id], [data-help-context], [data-help-topic-id], [data-interface-id]"
);
if (explicit) {
const contextId = explicit.dataset.helpContextId || explicit.dataset.helpContext;
const topicId = explicit.dataset.helpTopicId;
const interfaceId = explicit.dataset.interfaceId;
const title = helpLabel(explicit) || base.title;
const kind = helpKind(explicit, target);
if (contextId || topicId || interfaceId) {
return withDeclaredDocumentation(childContext(base, {
id: contextId || interfaceId || topicId || base.id,
title,
kind,
moduleId: explicit.dataset.helpModuleId || base.moduleId,
documentationTopicId: topicId || undefined,
documentationType: documentationType(explicit) ?? base.documentationType
}), modules);
}
}
const field = target.closest<HTMLElement>(
".form-field, .toggle-switch-row, .searchable-select, .email-address-input, .date-field, .time-field, .date-time-field"
);
if (field) {
const label = helpLabel(field) || helpLabel(target) || "Field";
return withDeclaredDocumentation(derivedChildContext(base, field, label, "field"), modules);
}
const control = target.closest<HTMLElement>(
"button, a, input, select, textarea, [role='button'], [role='checkbox'], [role='combobox'], [role='menuitem'], [role='option'], [role='radio'], [role='switch'], [role='tab']"
);
const dialog = target.closest<HTMLElement>("[role='dialog'], [role='alertdialog'], [data-help-scope='dialog']");
if (control) {
const isField = control.matches("input, select, textarea, [role='checkbox'], [role='combobox'], [role='radio'], [role='switch']");
const label = helpLabel(control) || (isField ? "Field" : "Action");
const slug = stableHelpKey(control, label);
if (dialog && ["close", "cancel"].includes(slug)) {
const dialogLabel = helpLabel(dialog) || "Dialog";
return withDeclaredDocumentation(derivedChildContext(base, dialog, dialogLabel, "dialog"), modules);
}
return withDeclaredDocumentation(derivedChildContext(base, control, label, isField ? "field" : "action"), modules);
}
if (dialog) {
return withDeclaredDocumentation(derivedChildContext(base, dialog, helpLabel(dialog) || "Dialog", "dialog"), modules);
}
const contextualRegion = target.closest<HTMLElement>("[data-help-scope], .card, .admin-section-page");
if (contextualRegion) {
return withDeclaredDocumentation(derivedChildContext(base, contextualRegion, helpLabel(contextualRegion) || base.title, "interface"), modules);
}
return base;
}
export function helpQueryForContext(context: HelpContext): string {
return `context=${encodeURIComponent(context.id)}`;
const params = new URLSearchParams();
if (context.documentationTopicId) params.set("topic", context.documentationTopicId);
else params.set("context", context.id);
if (context.parentId && context.parentId !== context.id) params.set("fallback_context", context.parentId);
if (context.moduleId) params.set("module", context.moduleId);
return params.toString();
}
function capitalize(value: string): string {
return value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
function pageContext(
definition: ContextDefinition,
route: string,
modules: readonly PlatformWebModule[]
): HelpContext {
return withDeclaredDocumentation({ ...definition, route, kind: "page" }, modules);
}
function childContext(
base: HelpContext,
child: Pick<HelpContext, "id" | "title" | "kind"> & Partial<HelpContext>
): HelpContext {
return {
...child,
route: base.route,
moduleId: child.moduleId ?? base.moduleId,
parentId: child.parentId ?? base.id,
parentTitle: child.parentTitle ?? base.title,
documentationType: child.documentationType ?? base.documentationType
};
}
function withDeclaredDocumentation(
context: HelpContext,
modules: readonly PlatformWebModule[]
): HelpContext {
if (context.documentationTopicId) return context;
const candidates = context.moduleId
? modules.filter((module) => module.id === context.moduleId)
: modules;
const aliases = [
context.id,
context.id.replace(".section.", "."),
context.id.replace(".route.", ".")
];
for (const module of candidates) {
const match = module.helpContexts?.find((item) => aliases.includes(item.id));
if (!match) continue;
const documentationType = context.documentationType
?? (match.documentation_types.includes("user") ? "user" : match.documentation_types[0]);
return {
...context,
id: match.id,
title: context.title || match.title,
moduleId: module.id,
documentationTopicId: match.topic_id,
documentationType
};
}
return context;
}
function derivedChildContext(base: HelpContext, element: HTMLElement, label: string, kind: HelpContextKind): HelpContext {
const prefix = contextPrefix(base);
const key = stableHelpKey(element, label);
return childContext(base, {
id: `${prefix}.${kind === "field" ? "field" : kind === "action" ? "action" : kind}.${key}`,
title: label,
kind,
documentationType: documentationType(element) ?? base.documentationType
});
}
function contextPrefix(context: HelpContext): string {
const idPrefix = context.id.split(".", 1)[0];
if (idPrefix && !["app", "admin"].includes(idPrefix)) return idPrefix;
return stableSlug(context.moduleId || idPrefix || "app");
}
function sectionContext(
modules: readonly PlatformWebModule[],
section: string,
area: "settings" | "admin"
): ContextDefinition | null {
const normalizedSection = stableSlug(section);
let best: { score: number; definition: ContextDefinition } | null = null;
for (const module of modules) {
for (const surface of module.viewSurfaces ?? []) {
if (surface.kind !== "section") continue;
const normalizedId = surface.id.replace(/_/g, "-").toLowerCase();
const normalizedLabel = stableSlug(surface.label);
let score = 0;
if (normalizedId.includes(`.${area}.${normalizedSection}`)) score = 120;
else if (normalizedId.includes(`.section.${normalizedSection}`)) score = 110;
else if (normalizedId.endsWith(`.${normalizedSection}`)) score = 100;
else if (normalizedLabel === normalizedSection) score = 80;
if (!score || (best && best.score >= score)) continue;
best = {
score,
definition: {
id: surface.id,
title: surface.label,
moduleId: surface.moduleId || module.id,
documentationType: area === "admin" ? "admin" : "user"
}
};
}
}
return best?.definition ?? null;
}
function moduleRouteContext(pathname: string, modules: readonly PlatformWebModule[]): ContextDefinition | null {
let best: { score: number; definition: ContextDefinition } | null = null;
for (const module of modules) {
for (const route of [...(module.routes ?? []), ...(module.publicRoutes ?? [])]) {
const score = routeMatchScore(route.path, pathname);
if (score < 0 || (best && best.score >= score)) continue;
const surfaceId = "surfaceId" in route ? route.surfaceId : undefined;
const surface = surfaceId ? module.viewSurfaces?.find((item) => item.id === surfaceId) : undefined;
const navItem = module.navItems?.find((item) => item.to === route.path || item.to === pathname);
best = {
score,
definition: {
id: route.helpContextId || surfaceId || `${module.id}.page.${stableSlug(route.path)}`,
title: surface?.label || navItem?.label || module.label || humanize(pathname),
moduleId: module.id,
documentationTopicId: route.helpTopicId
}
};
}
}
return best?.definition ?? null;
}
function routeMatchScore(pattern: string, pathname: string): number {
const patternSegments = pattern.split("/").filter(Boolean);
const pathSegments = pathname.split("/").filter(Boolean);
let score = 0;
let wildcard = false;
for (let index = 0; index < patternSegments.length; index += 1) {
const expected = patternSegments[index];
if (expected === "*") {
wildcard = true;
score += 1;
break;
}
const actual = pathSegments[index];
if (actual === undefined) return -1;
if (expected.startsWith(":")) score += 10;
else if (expected === actual) score += 100;
else return -1;
}
if (!wildcard && patternSegments.length !== pathSegments.length) return -1;
return score + patternSegments.length;
}
function helpKind(element: HTMLElement, target: Element): HelpContextKind {
const explicit = element.dataset.helpScope as HelpContextKind | undefined;
if (explicit && ["page", "field", "action", "dialog", "interface"].includes(explicit)) return explicit;
if (element.matches(".form-field, .toggle-switch-row, .searchable-select, .email-address-input, .date-field, .time-field, .date-time-field")) return "field";
if (target.closest("[role='dialog'], [role='alertdialog']")) return "dialog";
return "interface";
}
function helpLabel(element: Element): string {
const own = element as HTMLElement;
const direct = own.dataset?.helpLabel || own.getAttribute("aria-label") || own.getAttribute("title");
if (direct?.trim()) return direct.trim();
const labelledBy = own.getAttribute("aria-labelledby");
if (labelledBy && typeof document !== "undefined") {
const text = labelledBy
.split(/\s+/)
.map((id) => document.getElementById(id)?.textContent?.trim() || "")
.filter(Boolean)
.join(" ");
if (text) return text;
}
const fieldLabel = own.querySelector<HTMLElement>(".form-label, .toggle-switch-label");
if (fieldLabel?.textContent?.trim()) return fieldLabel.textContent.trim();
const dialogTitle = own.querySelector<HTMLElement>(".dialog-title");
if (dialogTitle?.textContent?.trim()) return dialogTitle.textContent.trim();
const heading = own.querySelector<HTMLElement>("h1, h2, h3");
if (heading?.textContent?.trim()) return heading.textContent.trim();
const associatedLabel = own.id && typeof document !== "undefined"
? document.querySelector<HTMLLabelElement>(`label[for="${cssEscape(own.id)}"]`)
: null;
if (associatedLabel?.textContent?.trim()) return associatedLabel.textContent.trim();
return own.textContent?.replace(/\s+/g, " ").trim().slice(0, 120) || "";
}
function stableHelpKey(element: HTMLElement, label: string): string {
const key = element.dataset.helpKey
|| element.getAttribute("name")
|| stableElementId(element.id)
|| element.getAttribute("aria-label")
|| element.getAttribute("title")
|| label;
return stableSlug(key || "item");
}
function stableElementId(value: string): string {
if (!value || /(?:^|[-_])(?:r\d+|\d{3,}|[0-9a-f]{8,})(?:$|[-_])/i.test(value)) return "";
return value;
}
function documentationType(element: HTMLElement): "user" | "admin" | undefined {
return element.dataset.helpDocumentationType === "admin" ? "admin"
: element.dataset.helpDocumentationType === "user" ? "user"
: undefined;
}
function stableSlug(value: string): string {
const i18nMatch = value.match(/^i18n:[^.]+\.([^.]+)(?:\.[0-9a-f]{8})?$/i);
const source = i18nMatch?.[1] || value;
return source
.replace(/^\/+|\/+$/g, "")
.replace(/:[^/]+/g, "item")
.replace(/\*/g, "all")
.replace(/[_\s/]+/g, "-")
.replace(/[^a-zA-Z0-9.-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase() || "item";
}
function humanize(value: string): string {
return value
.replace(/^\/+|\/+$/g, "")
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (letter) => letter.toUpperCase()) || "Application";
}
function cssEscape(value: string): string {
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(value);
return value.replace(/["\\]/g, "\\$&");
}