415 lines
18 KiB
TypeScript
415 lines
18 KiB
TypeScript
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";
|
|
};
|
|
|
|
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, 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 = "",
|
|
modules: readonly PlatformWebModule[] = []
|
|
): HelpContext {
|
|
const route = pathname || "/";
|
|
const routeWithSearch = `${route}${search}`;
|
|
const segments = route.split("/").filter(Boolean);
|
|
|
|
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[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 pageContext({ id: `campaign.wizard.${stableSlug(step)}`, title: `${humanize(step)} wizard`, moduleId: "campaigns" }, routeWithSearch, modules);
|
|
}
|
|
const context = campaignSectionContexts[segments[2]];
|
|
if (context) return pageContext(context, routeWithSearch, modules);
|
|
return pageContext({ id: "campaign.workspace", title: "i18n:govoplan-core.campaign_workspace.c345580f", moduleId: "campaigns" }, routeWithSearch, modules);
|
|
}
|
|
|
|
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 {
|
|
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 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, "\\$&");
|
|
}
|