Files
govoplan-docs/webui/src/features/docs/DocsPage.tsx

984 lines
44 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
import {
Button,
DataGrid,
DismissibleAlert,
ExplorerTree,
LoadingFrame,
PageTitle,
SegmentedControl,
StatusBadge,
adminErrorMessage,
useGuardedNavigate,
usePlatformLanguage,
type ApiSettings,
type DataGridColumn
} from "@govoplan/core-webui";
import {
fetchDocsContext,
type DocsContext,
type DocsDocumentationTopic,
type DocsModule,
type DocsOptionalModuleEvidence,
type DocsRoute,
type DocsSource
} from "../../api/docs";
type DocumentationType = "admin" | "user";
type DocsPageNode =
| {
id: string;
title: string;
kind: "topic";
topic: DocsDocumentationTopic;
}
| {
id: string;
title: string;
kind: "topic-list";
topics: DocsDocumentationTopic[];
emptyText: string;
}
| {
id: string;
title: string;
kind: "admin-reference";
};
type DocsTreeNode = {
id: string;
title: string;
page: DocsPageNode;
children: DocsTreeNode[];
};
type OutlineItem = {
id: string;
label: string;
};
export default function DocsPage({ settings }: { settings: ApiSettings }) {
const location = useLocation();
const navigate = useGuardedNavigate();
const { language } = usePlatformLanguage();
const [context, setContext] = useState<DocsContext | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [documentationType, setDocumentationType] = useState<DocumentationType>(() => documentationTypeFromSearch(location.search));
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set());
const locale = localeFromSearch(location.search) ?? language;
const adminDocs = documentationType === "admin";
async function load() {
setLoading(true);
setError("");
try {
setContext(await fetchDocsContext(settings, { documentationType, locale }));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}
useEffect(() => {
const nextType = documentationTypeFromSearch(location.search);
setDocumentationType((current) => current === nextType ? current : nextType);
}, [location.search]);
useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, documentationType, locale]);
const treeNodes = useMemo(() => docsTreeNodes(context, adminDocs), [context, adminDocs]);
const pages = useMemo(() => flattenTreePages(treeNodes), [treeNodes]);
const selectedPage = selectedPageFromSearch(location.search, pages);
const topicById = useMemo(() => topicIndex(context), [context]);
const visibleRoutes = context?.layers.configured.routes ?? [];
const availableRoutes = context?.layers.available.routes ?? [];
const grantedPermissions = context?.layers.configured.permissions.filter((item) => item.granted) ?? [];
const moduleRows = useMemo(() => context?.layers.configured.modules ?? [], [context]);
const outlineItems = outlineForPage(selectedPage, adminDocs);
useEffect(() => {
setExpandedNodes(new Set(defaultExpandedNodeIds(treeNodes)));
}, [treeNodes]);
useEffect(() => {
if (!selectedPage) return;
const ancestorIds = ancestorNodeIdsForPage(treeNodes, selectedPage.id);
if (!ancestorIds.length) return;
setExpandedNodes((current) => {
const next = new Set(current);
for (const nodeId of ancestorIds) {
next.add(nodeId);
}
return next;
});
}, [treeNodes, selectedPage?.id]);
return (
<div className="workspace module-workspace docs-workspace">
<aside className="section-sidebar docs-outline" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
<div className="docs-sidebar-header">
<div className="section-title">i18n:govoplan-docs.help_center.f3f3a34b</div>
<AudienceToggle
selected={documentationType}
onSelect={selectDocumentationType}
canViewAdmin={context?.actor.available_documentation_types.includes("admin") ?? documentationType === "admin"}
/>
</div>
<nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
<ExplorerTree
nodes={treeNodes}
getNodeId={(node) => node.id}
getNodeLabel={(node) => node.title}
getNodeChildren={(node) => node.children}
activeId={activeNodeIdForPage(treeNodes, selectedPage?.id ?? "")}
expandedIds={expandedNodes}
depth={0}
childrenBaseClassName="docs-tree-children"
nodeContainerClassName="docs-tree-node"
nodeWrapBaseClassName="docs-tree-row"
toggleBaseClassName="docs-tree-toggle"
nodeButtonBaseClassName="docs-tree-page"
getNodeWrapStyle={(_node, context) => ({ paddingLeft: `${context.depth * 14}px` })}
getNodeButtonClassName={(node) => selectedPage?.id === node.page.id ? "is-active" : ""}
renderToggleIcon={(_node, context) => context.hasChildren ? context.expanded ? <ChevronDown size={15} /> : <ChevronRight size={15} /> : <span className="docs-tree-toggle-placeholder" />}
renderNodeContent={(node) => node.title}
onToggle={(node) => toggleNode(node.id)}
onOpen={(node) => selectPage(node.page)}
/>
</nav>
</aside>
<section className="workspace-content docs-workspace-content">
<div className="content-pad workspace-data-page docs-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-docs.help_center.f3f3a34b</PageTitle>
<p>{adminDocs ? "i18n:govoplan-docs.technical_documentation_for_the_modules_and_conf.267e739e" : "i18n:govoplan-docs.guidance_for_the_functions_available_in_this_ins.723b7cad"}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={() => void load()} disabled={loading}><RefreshCw size={16} /> i18n:govoplan-docs.reload.cce71553</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<LoadingFrame loading={loading} label="i18n:govoplan-docs.loading_documentation_context.1c091645">
<div className="docs-content">
<main className="docs-page-main">
<SelectedPageContent
page={selectedPage}
adminDocs={adminDocs}
documentationType={documentationType}
topicById={topicById}
moduleRows={moduleRows}
visibleRoutes={visibleRoutes}
availableRoutes={availableRoutes}
grantedPermissions={grantedPermissions}
evidenceModules={context?.layers.evidence.optional_modules ?? []}
evidenceSources={context?.layers.evidence.sources ?? []}
/>
</main>
<PageOutline items={outlineItems} />
</div>
</LoadingFrame>
</div>
</section>
</div>
);
function selectDocumentationType(type: DocumentationType) {
const params = new URLSearchParams(location.search);
params.set("type", type);
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}
function selectPage(page: DocsPageNode) {
const params = new URLSearchParams(location.search);
params.set("topic", page.id);
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}
function toggleNode(nodeId: string) {
setExpandedNodes((current) => {
const next = new Set(current);
if (next.has(nodeId)) {
next.delete(nodeId);
} else {
next.add(nodeId);
}
return next;
});
}
}
function AudienceToggle({ selected, onSelect, canViewAdmin }: { selected: DocumentationType; onSelect: (type: DocumentationType) => void; canViewAdmin: boolean }) {
const options: Array<{ id: DocumentationType; label: string }> = [
{ id: "user", label: "i18n:govoplan-docs.user_docs.1e38e8d3" }
];
if (canViewAdmin) {
options.unshift({ id: "admin", label: "i18n:govoplan-docs.admin_docs.bf504a56" });
}
return (
<SegmentedControl
className="docs-audience-toggle"
role="group"
size="equal"
width="fill"
ariaLabel="i18n:govoplan-docs.documentation_type.5a66690c"
value={selected}
onChange={onSelect}
options={options}
/>
);
}
function PageOutline({ items }: { items: OutlineItem[] }) {
return (
<aside className="docs-page-outline-box" aria-label="i18n:govoplan-docs.page_outline.e5940949">
<h2>i18n:govoplan-docs.page_outline.e5940949</h2>
{items.length ?
<nav>
{items.map((item) => <a key={item.id} href={`#${item.id}`}>{item.label}</a>)}
</nav> :
<p className="muted">i18n:govoplan-docs.no_outline_items.3ea2842e</p>
}
</aside>
);
}
function SelectedPageContent({
page,
adminDocs,
documentationType,
topicById,
moduleRows,
visibleRoutes,
availableRoutes,
grantedPermissions,
evidenceModules,
evidenceSources
}: {
page: DocsPageNode | null;
adminDocs: boolean;
documentationType: DocumentationType;
topicById: Map<string, DocsDocumentationTopic>;
moduleRows: DocsModule[];
visibleRoutes: DocsRoute[];
availableRoutes: DocsRoute[];
grantedPermissions: Array<{ scope: string; label: string; category: string }>;
evidenceModules: DocsOptionalModuleEvidence[];
evidenceSources: DocsSource[];
}) {
if (!page) {
return (
<section className="docs-section">
<h2>i18n:govoplan-docs.help_center.f3f3a34b</h2>
<p className="muted">i18n:govoplan-docs.no_system_topics_found.d01a8068</p>
</section>
);
}
if (page.kind === "topic") {
return <TopicPage topic={page.topic} showTechnical={adminDocs} documentationType={documentationType} topicById={topicById} />;
}
if (page.kind === "admin-reference") {
return (
<section className="docs-section" id={page.id}>
<h2>{page.title}</h2>
<section id="docs-admin-modules" className="docs-reference-block">
<h3>i18n:govoplan-docs.modules.04e9462c</h3>
<ModuleTable modules={moduleRows} />
</section>
<section id="docs-admin-routes" className="docs-reference-block">
<h3>i18n:govoplan-docs.visible_routes.6c9b1605</h3>
<RouteTable routes={visibleRoutes} emptyText="i18n:govoplan-docs.no_visible_routes_found_for_this_actor.3feeaf0b" />
<h3>i18n:govoplan-docs.available_routes.c2635868</h3>
<RouteTable routes={availableRoutes} emptyText="i18n:govoplan-docs.no_hidden_installed_routes_found.22f62845" />
</section>
<section id="docs-admin-permissions" className="docs-reference-block">
<h3>i18n:govoplan-docs.granted_permissions.0a232e78</h3>
<PermissionList permissions={grantedPermissions} />
<EvidenceList modules={evidenceModules} sources={evidenceSources} />
</section>
</section>
);
}
return (
<section className="docs-section" id={page.id}>
<h2>{page.title}</h2>
<DocumentationTopicList topics={page.topics} emptyText={page.emptyText} showTechnical={adminDocs} documentationType={documentationType} topicById={topicById} />
</section>
);
}
function TopicPage({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
return (
<article className={`docs-topic docs-topic-page docs-topic-${topic.kind}`} id={topicAnchorId(topic)}>
<div className="docs-topic-meta">
<StatusBadge status={topic.active ? "success" : "warning"} label={topic.active ? topic.kind : topic.target_layer} />
<span className="muted">{topic.source_module_id}</span>
{showTechnical && <span className="muted">{topic.i18n_key} · {topic.translation_locale}</span>}
</div>
<h2>{topic.title}</h2>
{topic.summary && <p id={`${topicAnchorId(topic)}-summary`} className="docs-topic-summary">{topic.summary}</p>}
<div id={`${topicAnchorId(topic)}-details`}>
<BodyText value={topic.body} />
<TopicMetadata topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} />
{!topic.active && <UnavailableDocumentationReason topic={topic} />}
</div>
{!!topic.unlocks.length && <p className="muted">i18n:govoplan-docs.unlocks.67d569db {topic.unlocks.join(" ")}</p>}
{!!topic.links.length && <TopicLinks topic={topic} />}
</article>
);
}
function DocumentationTopicList({ topics, emptyText, showTechnical = false, documentationType, topicById }: { topics: DocsDocumentationTopic[]; emptyText: string; showTechnical?: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
if (!topics.length) return <p className="muted">{emptyText}</p>;
return (
<div className="docs-topic-list">
{topics.map((topic) =>
<article className={`docs-topic docs-topic-${topic.kind}`} id={topicAnchorId(topic)} key={`${topic.source_module_id}-${topic.id}`}>
<div className="docs-topic-meta">
<StatusBadge status={topic.active ? "success" : "warning"} label={topic.active ? topic.kind : topic.target_layer} />
<span className="muted">{topic.source_module_id}</span>
{showTechnical && <span className="muted">{topic.i18n_key} · {topic.translation_locale}</span>}
</div>
<h3>{topic.title}</h3>
{topic.summary && <p className="docs-topic-summary">{topic.summary}</p>}
<BodyText value={topic.body} />
<TopicMetadata topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} />
{!topic.active && <UnavailableDocumentationReason topic={topic} />}
{!!topic.unlocks.length && <p className="muted">i18n:govoplan-docs.unlocks.67d569db {topic.unlocks.join(" ")}</p>}
{!!topic.links.length && <TopicLinks topic={topic} />}
</article>
)}
</div>
);
}
function TopicMetadata({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
const configuredDetails = <ConfiguredDetails topic={topic} />;
if (topic.kind === "workflow") return <>{configuredDetails}<WorkflowDetails topic={topic} documentationType={documentationType} topicById={topicById} /></>;
if (topic.kind === "reference") return <>{configuredDetails}<ReferenceDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} /></>;
if (topic.kind === "pattern") return <>{configuredDetails}<PatternDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} /></>;
return <>{configuredDetails}<RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} /></>;
}
function ConfiguredDetails({ topic }: { topic: DocsDocumentationTopic }) {
const currentConfiguration = metadataList(topic.metadata, "current_configuration");
const limitations = metadataList(topic.metadata, "limitations");
const constraints = metadataRecords(topic.metadata, "constraints");
const prefix = topicAnchorId(topic);
if (!currentConfiguration.length && !limitations.length && !constraints.length) return null;
return (
<div className="docs-topic-details">
{!!currentConfiguration.length && <DetailList id={`${prefix}-current-configuration`} title="i18n:govoplan-docs.this_system.b13a51ad" items={currentConfiguration} />}
{!!constraints.length && <ConstraintDetails id={`${prefix}-constraints`} constraints={constraints} />}
{!!limitations.length && <DetailList id={`${prefix}-limitations`} title="i18n:govoplan-docs.details.a6b3c45f" items={limitations} />}
</div>
);
}
function WorkflowDetails({ topic, documentationType, topicById }: { topic: DocsDocumentationTopic; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
const prerequisites = metadataList(topic.metadata, "prerequisites");
const steps = metadataList(topic.metadata, "steps");
const outcome = metadataString(topic.metadata, "outcome");
const result = metadataString(topic.metadata, "result");
const verification = metadataString(topic.metadata, "verification");
const prefix = topicAnchorId(topic);
return (
<div className="docs-topic-details">
{outcome && <DetailBlock id={`${prefix}-outcome`} title="i18n:govoplan-docs.outcome.10172bd3" value={outcome} />}
{!!prerequisites.length && <DetailList id={`${prefix}-prerequisites`} title="i18n:govoplan-docs.prerequisites.fdf2407f" items={prerequisites} />}
{!!steps.length &&
<div className="docs-detail-block" id={`${prefix}-steps`}>
<h4>i18n:govoplan-docs.steps.6041435e</h4>
<ol>
{steps.map((step) => <li key={step}>{step}</li>)}
</ol>
</div>
}
{result && <DetailBlock id={`${prefix}-result`} title="i18n:govoplan-docs.result.1f4fbf43" value={result} />}
{verification && <DetailBlock id={`${prefix}-verification`} title="i18n:govoplan-docs.verification.4b5f10dd" value={verification} />}
<RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} />
</div>
);
}
function ConstraintDetails({ id, constraints }: { id: string; constraints: Record<string, unknown>[] }) {
return (
<div className="docs-detail-block" id={id}>
<h4>i18n:govoplan-docs.requirements.09a428f9</h4>
<dl className="detail-list compact">
{constraints.map((constraint, index) => {
const label = metadataString(constraint, "label");
const description = metadataString(constraint, "description");
const values = metadataList(constraint, "values");
return (
<div key={metadataString(constraint, "id") || `${label}-${index}`}>
<dt>{label}</dt>
<dd>
{description}
{!!values.length && <span className="muted block">{values.join(", ")}</span>}
</dd>
</div>
);
})}
</dl>
</div>
);
}
function ReferenceDetails({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
const route = metadataString(topic.metadata, "route");
const screen = metadataString(topic.metadata, "screen");
const section = metadataString(topic.metadata, "section");
const fields = metadataRecords(topic.metadata, "fields");
return (
<div className="docs-topic-details">
{(route || screen || section) &&
<dl className="detail-list compact">
{screen && <div><dt>i18n:govoplan-docs.screen.c4878ec4</dt><dd>{screen}</dd></div>}
{section && <div><dt>i18n:govoplan-docs.section.5e498158</dt><dd>{section}</dd></div>}
{showTechnical && route && <div><dt>i18n:govoplan-docs.route.4999528e</dt><dd>{route}</dd></div>}
</dl>
}
{!!fields.length && <ReferenceFieldTable id={`${topicAnchorId(topic)}-fields`} fields={fields} showTechnical={showTechnical} />}
<RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} />
</div>
);
}
function ReferenceFieldTable({ id, fields, showTechnical }: { id: string; fields: Record<string, unknown>[]; showTechnical: boolean }) {
const columns: DataGridColumn<Record<string, unknown>>[] = [
{ id: "field", header: "i18n:govoplan-docs.field.7558c082", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (field) => metadataString(field, "label"), render: (field) => <strong>{metadataString(field, "label")}</strong> },
{ id: "meaning", header: "i18n:govoplan-docs.meaning.584d8aa0", width: "minmax(260px, 1.3fr)", minWidth: 220, resizable: true, filterable: true, value: (field) => [metadataString(field, "user_description"), metadataString(field, "admin_description"), metadataString(field, "provenance")].join(" "), render: (field) => <div>{metadataString(field, "user_description")}{showTechnical && metadataString(field, "admin_description") && <span className="muted block">{metadataString(field, "admin_description")}</span>}{showTechnical && metadataString(field, "provenance") && <span className="muted block">{metadataString(field, "provenance")}</span>}</div> },
...(showTechnical ? [
{ id: "api", header: "i18n:govoplan-docs.api_mapping.e969f6f7", width: "minmax(190px, .8fr)", minWidth: 170, resizable: true, filterable: true, value: (field: Record<string, unknown>) => `${metadataString(field, "api_path")} ${metadataString(field, "api_field")}`, render: (field: Record<string, unknown>) => <div>{metadataString(field, "api_path")}<span className="muted block">{metadataString(field, "api_field")}</span></div> },
{ id: "permission", header: "i18n:govoplan-docs.permission.d71c9448", width: 190, filterable: true, value: (field: Record<string, unknown>) => metadataString(field, "permission_scope") || "-", render: (field: Record<string, unknown>) => metadataString(field, "permission_scope") || "-" }
] satisfies DataGridColumn<Record<string, unknown>>[] : []),
{ id: "validation", header: "i18n:govoplan-docs.validation.ef7f6d9c", width: "minmax(180px, .7fr)", minWidth: 160, resizable: true, filterable: true, value: (field) => metadataString(field, "validation") || "-", render: (field) => metadataString(field, "validation") || "-" }
];
return (
<div id={id}>
<DataGrid id={`${id}-grid`} rows={fields} columns={columns} getRowKey={(field) => metadataString(field, "field_id") || metadataString(field, "label")} />
</div>
);
}
function PatternDetails({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
const purpose = metadataString(topic.metadata, "purpose");
const whenUsed = metadataString(topic.metadata, "when_used");
const explanation = showTechnical ? metadataString(topic.metadata, "admin_explanation") : metadataString(topic.metadata, "user_explanation");
const componentRefs = metadataList(topic.metadata, "component_refs");
const prefix = topicAnchorId(topic);
return (
<div className="docs-topic-details">
{purpose && <DetailBlock id={`${prefix}-purpose`} title="i18n:govoplan-docs.purpose.4af40581" value={purpose} />}
{whenUsed && <DetailBlock id={`${prefix}-when-used`} title="i18n:govoplan-docs.when_used.b870b361" value={whenUsed} />}
{explanation && <DetailBlock id={`${prefix}-details`} title="i18n:govoplan-docs.details.a6b3c45f" value={explanation} />}
{showTechnical && !!componentRefs.length && <DetailList id={`${prefix}-components`} title="i18n:govoplan-docs.components.e81e57d8" items={componentRefs} />}
<RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} />
</div>
);
}
function DetailBlock({ id, title, value }: { id: string; title: string; value: string }) {
return (
<div className="docs-detail-block" id={id}>
<h4>{title}</h4>
<p>{value}</p>
</div>
);
}
function DetailList({ id, title, items }: { id: string; title: string; items: string[] }) {
return (
<div className="docs-detail-block" id={id}>
<h4>{title}</h4>
<ul>
{items.map((item) => <li key={item}>{item}</li>)}
</ul>
</div>
);
}
function RelatedTopics({ topic, documentationType, topicById }: { topic: DocsDocumentationTopic; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
const related = metadataList(topic.metadata, "related_topic_ids")
.map((id) => topicById.get(id))
.filter((item): item is DocsDocumentationTopic => Boolean(item));
if (!related.length) return null;
return (
<p className="docs-topic-links">
<span>i18n:govoplan-docs.related_topics.d5eb8b74 </span>
{related.map((item, index) =>
<span key={item.id}>
{index > 0 && " | "}
<Link to={topicPageHref(item, documentationType)}>{item.title}</Link>
</span>
)}
</p>
);
}
function TopicLinks({ topic }: { topic: DocsDocumentationTopic }) {
return (
<p className="docs-topic-links">
{topic.links.map((link, index) =>
<span key={link.href}>
{index > 0 && " | "}
<a href={link.href} target={link.kind === "public" ? "_blank" : undefined} rel={link.kind === "public" ? "noreferrer" : undefined}>{link.label}</a>
</span>
)}
</p>
);
}
function BodyText({ value }: { value: string }) {
if (!value) return null;
return (
<>
{value.split("\n").filter(Boolean).map((line) => <p className="docs-topic-body" key={line}>{line}</p>)}
</>
);
}
function UnavailableDocumentationReason({ topic }: { topic: DocsDocumentationTopic }) {
const rows = [
["i18n:govoplan-docs.modules.04e9462c", topic.blockers.modules],
["i18n:govoplan-docs.capabilities.ca09c54b", topic.blockers.capabilities],
["i18n:govoplan-docs.permissions.d06d5557", topic.blockers.scopes]
].filter(([, values]) => Array.isArray(values) && values.length);
return (
<div className="docs-unavailable-reason">
<p className="muted">{topic.reason}</p>
{!!rows.length &&
<dl className="detail-list compact">
{rows.map(([label, values]) =>
<div key={String(label)}>
<dt>{label}</dt>
<dd>{(values as string[]).join(", ")}</dd>
</div>
)}
</dl>
}
</div>
);
}
function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTreeNode[] {
const allTopics = allDocumentationTopics(context);
const systemTopics = context?.topic_groups.system ?? [];
const workflowTopics = context?.topic_groups.workflow ?? [];
const referenceTopics = context?.topic_groups.reference ?? [];
const patternTopics = context?.topic_groups.pattern ?? [];
const systemTopicIds = new Set(systemTopics.map((topic) => topic.id));
const moduleTopics = allTopics.filter((topic) => !systemTopicIds.has(topic.id));
const workingTopics = uniqueTopics([...patternTopics, ...workflowTopics]);
const troubleshootingTopics = allTopics.filter((topic) => !topic.active || topic.layer === "evidence");
const basicsNode = categoryTreeNode(
"basics",
"i18n:govoplan-docs.basics.5fcebeef",
patternTopics,
"i18n:govoplan-docs.no_basic_topics_found.ee5ec3c6"
);
const commonTasksNode = categoryTreeNode(
"common-tasks",
"i18n:govoplan-docs.common_tasks.9f825c48",
workflowTopics,
"i18n:govoplan-docs.no_common_task_topics_found.c17814cf"
);
const nodes: DocsTreeNode[] = [
categoryTreeNode(
"about-govoplan",
"i18n:govoplan-docs.about_govoplan.6b2d7127",
systemTopics,
"i18n:govoplan-docs.no_about_topics_found.7cd8af6d"
),
{
id: "tree:working-with-govoplan",
title: "i18n:govoplan-docs.working_with_govoplan.81908ef4",
page: {
id: "working-with-govoplan:overview",
title: "i18n:govoplan-docs.working_with_govoplan.81908ef4",
kind: "topic-list",
topics: workingTopics,
emptyText: "i18n:govoplan-docs.no_working_with_govoplan_topics_found.b6db8384"
},
children: [basicsNode, commonTasksNode]
},
categoryTreeNode(
"advanced",
"i18n:govoplan-docs.advanced.203a8d6b",
referenceTopics,
"i18n:govoplan-docs.no_advanced_topics_found.271166c9"
),
{
id: "tree:modules",
title: "i18n:govoplan-docs.modules.04e9462c",
page: {
id: "modules:overview",
title: "i18n:govoplan-docs.modules.04e9462c",
kind: "topic-list",
topics: moduleTopics,
emptyText: "i18n:govoplan-docs.no_module_topics_found.0cbbdc9b"
},
children: moduleTreeNodes(moduleTopics, context)
}
];
if (adminDocs) {
nodes.push({
id: "tree:administration",
title: "i18n:govoplan-docs.administration.101aedaf",
page: {
id: "administration:overview",
title: "i18n:govoplan-docs.administration.101aedaf",
kind: "topic-list",
topics: referenceTopics,
emptyText: "i18n:govoplan-docs.no_administration_topics_found.6213dff2"
},
children: [
...referenceTopics.map(topicTreeNode),
{
id: "tree:administration:technical-reference",
title: "i18n:govoplan-docs.technical_reference.f271430d",
page: {
id: "system:technical-reference",
title: "i18n:govoplan-docs.technical_reference.f271430d",
kind: "admin-reference"
},
children: []
}
]
});
}
if (troubleshootingTopics.length) {
nodes.push(categoryTreeNode(
"troubleshooting",
"i18n:govoplan-docs.troubleshooting_and_support.3d42f434",
troubleshootingTopics,
"i18n:govoplan-docs.no_troubleshooting_topics_found.8c275468"
));
}
return nodes;
}
function categoryTreeNode(id: string, title: string, topics: DocsDocumentationTopic[], emptyText: string): DocsTreeNode {
const sortedTopics = [...topics].sort((left, right) => left.order - right.order || left.title.localeCompare(right.title));
return {
id: `tree:${id}`,
title,
page: {
id: `${id}:overview`,
title,
kind: "topic-list",
topics: sortedTopics,
emptyText
},
children: nestedTopicNodes(sortedTopics)
};
}
function topicPage(topic: DocsDocumentationTopic): DocsPageNode {
return {
id: topic.id,
title: topic.title,
kind: "topic",
topic
};
}
function topicTreeNode(topic: DocsDocumentationTopic): DocsTreeNode {
return {
id: `tree:topic:${topic.id}`,
title: topic.title,
page: topicPage(topic),
children: []
};
}
function moduleTreeNodes(topics: DocsDocumentationTopic[], context: DocsContext | null): DocsTreeNode[] {
const topicsByModule = new Map<string, DocsDocumentationTopic[]>();
for (const topic of topics) {
const moduleTopics = topicsByModule.get(topic.source_module_id) ?? [];
moduleTopics.push(topic);
topicsByModule.set(topic.source_module_id, moduleTopics);
}
const configuredModules = context?.layers.configured.modules ?? [];
const moduleOrder = new Map(configuredModules.map((module, index) => [module.id, index]));
return Array.from(topicsByModule.entries())
.sort(([left], [right]) => (moduleOrder.get(left) ?? 999) - (moduleOrder.get(right) ?? 999) || moduleTitle(left, context).localeCompare(moduleTitle(right, context)))
.map(([moduleId, moduleTopics]) => {
const sortedTopics = [...moduleTopics].sort((left, right) => left.order - right.order || left.title.localeCompare(right.title));
const title = moduleTitle(moduleId, context);
return {
id: `tree:module:${moduleId}`,
title,
page: {
id: `module:${moduleId}`,
title,
kind: "topic-list",
topics: sortedTopics,
emptyText: "i18n:govoplan-docs.no_system_topics_found.d01a8068"
},
children: nestedTopicNodes(sortedTopics)
};
});
}
function nestedTopicNodes(topics: DocsDocumentationTopic[]): DocsTreeNode[] {
const nodes = new Map(topics.map((topic) => [topic.id, topicTreeNode(topic)]));
const roots: DocsTreeNode[] = [];
for (const topic of topics) {
const node = nodes.get(topic.id);
if (!node) continue;
const parentId = topicParentId(topic);
const parent = parentId ? nodes.get(parentId) : undefined;
if (parent && parentId !== topic.id) {
parent.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
function topicParentId(topic: DocsDocumentationTopic): string {
return metadataString(topic.metadata, "tree_parent_id") || metadataString(topic.metadata, "parent_topic_id") || metadataString(topic.metadata, "parent_id");
}
function moduleTitle(moduleId: string, context: DocsContext | null): string {
const module = context?.layers.configured.modules.find((item) => item.id === moduleId);
if (module) return module.name;
return moduleId
.replace(/^govoplan[-_]/, "")
.split(/[-_]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ") || moduleId;
}
function flattenTreePages(nodes: DocsTreeNode[]): DocsPageNode[] {
return nodes.flatMap((node) => [node.page, ...flattenTreePages(node.children)]);
}
function defaultExpandedNodeIds(nodes: DocsTreeNode[]): string[] {
return nodes.filter((node) => node.children.length).map((node) => node.id);
}
function ancestorNodeIdsForPage(nodes: DocsTreeNode[], pageId: string, ancestors: string[] = []): string[] {
for (const node of nodes) {
if (node.page.id === pageId) return ancestors;
const match = ancestorNodeIdsForPage(node.children, pageId, [...ancestors, node.id]);
if (match.length) return match;
}
return [];
}
function activeNodeIdForPage(nodes: DocsTreeNode[], pageId: string): string {
if (!pageId) return "";
for (const node of nodes) {
if (node.page.id === pageId) return node.id;
const childId = activeNodeIdForPage(node.children, pageId);
if (childId) return childId;
}
return "";
}
function allDocumentationTopics(context: DocsContext | null): DocsDocumentationTopic[] {
const topics = Object.values(context?.topic_groups ?? {}).flat();
return uniqueTopics(topics).sort((left, right) => left.order - right.order || left.title.localeCompare(right.title));
}
function uniqueTopics(topics: DocsDocumentationTopic[]): DocsDocumentationTopic[] {
const seen = new Set<string>();
return topics.filter((topic) => {
if (seen.has(topic.id)) return false;
seen.add(topic.id);
return true;
});
}
function selectedPageFromSearch(search: string, pages: DocsPageNode[]): DocsPageNode | null {
if (!pages.length) return null;
const params = new URLSearchParams(search);
const requested = params.get("topic") || "";
if (requested) return pages.find((page) => page.id === requested) ?? pages[0];
const helpContext = params.get("context") || "";
if (helpContext) {
const exact = pages.find((page) => page.kind === "topic" && metadataList(page.topic.metadata, "help_contexts").includes(helpContext));
if (exact) return exact;
const moduleId = helpContextModuleId(helpContext);
const moduleTopic = pages.find((page) => page.kind === "topic" && page.topic.source_module_id === moduleId);
if (moduleTopic) return moduleTopic;
}
return pages[0];
}
function helpContextModuleId(value: string): string {
const prefix = value.split(".", 1)[0];
if (prefix === "campaign") return "campaigns";
if (prefix === "address-book") return "addresses";
return prefix;
}
function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): OutlineItem[] {
if (!page) return [];
if (page.kind === "admin-reference") {
return [
{ id: page.id, label: page.title },
{ id: "docs-admin-modules", label: "i18n:govoplan-docs.modules.04e9462c" },
{ id: "docs-admin-routes", label: "i18n:govoplan-docs.routes.03730e58" },
{ id: "docs-admin-permissions", label: "i18n:govoplan-docs.permissions.d06d5557" }
];
}
if (page.kind === "topic-list") {
return [
{ id: page.id, label: page.title },
...page.topics.map((topic) => ({ id: topicAnchorId(topic), label: topic.title }))
];
}
const topic = page.topic;
const prefix = topicAnchorId(topic);
const items: OutlineItem[] = [{ id: prefix, label: topic.title }];
if (topic.summary) items.push({ id: `${prefix}-summary`, label: "i18n:govoplan-docs.summary.d6b9936d" });
if (metadataList(topic.metadata, "current_configuration").length) items.push({ id: `${prefix}-current-configuration`, label: "i18n:govoplan-docs.this_system.b13a51ad" });
if (metadataRecords(topic.metadata, "constraints").length) items.push({ id: `${prefix}-constraints`, label: "i18n:govoplan-docs.requirements.09a428f9" });
if (metadataList(topic.metadata, "limitations").length) items.push({ id: `${prefix}-limitations`, label: "i18n:govoplan-docs.details.a6b3c45f" });
if (topic.kind === "workflow") {
if (metadataString(topic.metadata, "outcome")) items.push({ id: `${prefix}-outcome`, label: "i18n:govoplan-docs.outcome.10172bd3" });
if (metadataList(topic.metadata, "prerequisites").length) items.push({ id: `${prefix}-prerequisites`, label: "i18n:govoplan-docs.prerequisites.fdf2407f" });
if (metadataList(topic.metadata, "steps").length) items.push({ id: `${prefix}-steps`, label: "i18n:govoplan-docs.steps.6041435e" });
if (metadataString(topic.metadata, "result")) items.push({ id: `${prefix}-result`, label: "i18n:govoplan-docs.result.1f4fbf43" });
if (metadataString(topic.metadata, "verification")) items.push({ id: `${prefix}-verification`, label: "i18n:govoplan-docs.verification.4b5f10dd" });
}
if (topic.kind === "reference" && metadataRecords(topic.metadata, "fields").length) {
items.push({ id: `${prefix}-fields`, label: "i18n:govoplan-docs.field.7558c082" });
}
if (topic.kind === "pattern") {
if (metadataString(topic.metadata, "purpose")) items.push({ id: `${prefix}-purpose`, label: "i18n:govoplan-docs.purpose.4af40581" });
if (metadataString(topic.metadata, "when_used")) items.push({ id: `${prefix}-when-used`, label: "i18n:govoplan-docs.when_used.b870b361" });
if (showTechnical && metadataList(topic.metadata, "component_refs").length) items.push({ id: `${prefix}-components`, label: "i18n:govoplan-docs.components.e81e57d8" });
}
return items;
}
function documentationTypeFromSearch(search: string): DocumentationType {
return new URLSearchParams(search).get("type") === "admin" ? "admin" : "user";
}
function localeFromSearch(search: string): string | null {
const value = new URLSearchParams(search).get("locale");
if (!value) return null;
return value.toLowerCase().startsWith("de") ? "de" : "en";
}
function topicAnchorId(topic: DocsDocumentationTopic): string {
return topic.anchor_id || `docs-topic-${topic.source_module_id}-${topic.id}`.replace(/[^a-z0-9_-]+/gi, "-").toLowerCase();
}
function topicPageHref(topic: DocsDocumentationTopic, documentationType: DocumentationType): string {
const params = new URLSearchParams();
params.set("type", documentationType);
params.set("topic", topic.id);
return `?${params.toString()}#${topicAnchorId(topic)}`;
}
function topicIndex(context: DocsContext | null): Map<string, DocsDocumentationTopic> {
const index = new Map<string, DocsDocumentationTopic>();
for (const topic of allDocumentationTopics(context)) {
index.set(topic.id, topic);
}
return index;
}
function metadataString(metadata: Record<string, unknown>, key: string): string {
const value = metadata[key];
return typeof value === "string" ? value : "";
}
function metadataList(metadata: Record<string, unknown>, key: string): string[] {
const value = metadata[key];
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string");
}
function metadataRecords(metadata: Record<string, unknown>, key: string): Record<string, unknown>[] {
const value = metadata[key];
if (!Array.isArray(value)) return [];
return value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item));
}
function ModuleTable({ modules }: { modules: DocsModule[] }) {
if (!modules.length) return <p className="muted">i18n:govoplan-docs.no_configured_modules_found.f6f9ce24</p>;
const columns: DataGridColumn<DocsModule>[] = [
{ id: "module", header: "i18n:govoplan-docs.module.b8ff0289", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (module) => `${module.name} ${module.id} ${module.version}`, render: (module) => <div><strong>{module.name}</strong><span className="muted block">{module.id} {module.version}</span></div> },
{ id: "routes", header: "i18n:govoplan-docs.routes.03730e58", width: 170, sortable: true, value: (module) => module.route_count, render: (module) => <>{module.route_count} i18n:govoplan-docs.route.200e2a66 {module.nav_count} nav</> },
{ id: "permissions", header: "i18n:govoplan-docs.permissions.d06d5557", width: 130, sortable: true, value: (module) => module.permission_count },
{ id: "frontend", header: "i18n:govoplan-docs.frontend.152d1cf2", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (module) => module.frontend_package || "-", render: (module) => module.frontend_package || "-" },
{ id: "capabilities", header: "i18n:govoplan-docs.capabilities.ca09c54b", width: "minmax(260px, 1.2fr)", minWidth: 220, resizable: true, filterable: true, value: (module) => module.capabilities.join(" "), render: (module) => <div>{module.capabilities.length ? module.capabilities.join(", ") : "-"}{module.documentation_count || module.documentation_provider_count ? <span className="muted block">{module.documentation_count} i18n:govoplan-docs.docs.5dc1e9b8 {module.documentation_provider_count} provider</span> : null}</div> }
];
return (
<DataGrid id="docs-configured-modules" rows={modules} columns={columns} getRowKey={(module) => module.id} />
);
}
function RouteTable({ routes, emptyText }: { routes: DocsRoute[]; emptyText: string }) {
if (!routes.length) return <p className="muted">{emptyText}</p>;
const columns: DataGridColumn<DocsRoute>[] = [
{ id: "route", header: "i18n:govoplan-docs.route.4999528e", width: "minmax(240px, 1fr)", minWidth: 200, resizable: true, sortable: true, filterable: true, value: (route) => `${route.label} ${route.path}`, render: (route) => <div><strong>{route.label}</strong><span className="muted block">{route.path}</span></div> },
{ id: "module", header: "i18n:govoplan-docs.module.b8ff0289", width: 160, sortable: true, filterable: true, value: (route) => route.module_id },
{ id: "source", header: "i18n:govoplan-docs.source.6da13add", width: 160, sortable: true, filterable: true, value: (route) => route.source },
{ id: "requirements", header: "i18n:govoplan-docs.requirements.09a428f9", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (route) => routeRequirements(route) },
{ id: "status", header: "i18n:govoplan-docs.status.bae7d5be", width: 160, sortable: true, filterable: true, value: (route) => route.visible ? "visible" : route.reason, render: (route) => <StatusBadge status={route.visible ? "success" : "warning"} label={route.visible ? "visible" : route.reason} /> }
];
return (
<DataGrid id={`docs-routes-${emptyText}`} rows={routes} columns={columns} getRowKey={(route) => `${route.source}-${route.module_id}-${route.path}`} />
);
}
function PermissionList({ permissions }: { permissions: Array<{ scope: string; label: string; category: string }> }) {
if (!permissions.length) return <p className="muted">i18n:govoplan-docs.no_granted_platform_permissions_found.36010898</p>;
return (
<dl className="detail-list">
{permissions.slice(0, 24).map((permission) =>
<div key={permission.scope}>
<dt>{permission.category}</dt>
<dd><strong>{permission.label}</strong><span className="muted"> · {permission.scope}</span></dd>
</div>
)}
{permissions.length > 24 && <div><dt>i18n:govoplan-docs.more.4bab2d8f</dt><dd>{permissions.length - 24} i18n:govoplan-docs.additional_permissions.8042cb01</dd></div>}
</dl>
);
}
function EvidenceList({ modules, sources }: { modules: DocsOptionalModuleEvidence[]; sources: DocsSource[] }) {
if (!modules.length && !sources.length) return <p className="muted">i18n:govoplan-docs.no_evidence_sources_found.be3bb2f6</p>;
return (
<dl className="detail-list">
{modules.map((item) =>
<div key={`${item.source_module_id}-${item.module_id}`}>
<dt><StatusBadge status={item.status === "installed" ? "success" : "inactive"} label={item.status} /></dt>
<dd><strong>{item.module_id}</strong><span className="muted"> · {item.reason}</span></dd>
</div>
)}
{sources.map((item) =>
<div key={item.source}>
<dt>{item.layer}</dt>
<dd><strong>{item.label}</strong><span className="muted"> · {item.source}</span></dd>
</div>
)}
</dl>
);
}
function routeRequirements(route: DocsRoute): string {
const parts = [];
if (route.required_all.length) parts.push(`all: ${route.required_all.join(", ")}`);
if (route.required_any.length) parts.push(`any: ${route.required_any.join(", ")}`);
return parts.join(" | ") || "-";
}