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
+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, "\\$&");
}