From b823a22b9b8f15510f403a2e6a49f5a28409f247 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 07:33:57 +0200 Subject: [PATCH] Link contextual guidance to Docs --- docs/UI_UX_DECISION_LEDGER.md | 3 + webui/package.json | 1 + webui/src/App.tsx | 6 ++ webui/src/components/ActionBlockerHint.tsx | 7 ++- webui/src/components/FormField.tsx | 5 +- .../components/help/DocumentationHelpLink.tsx | 63 +++++++++++++++++++ webui/src/components/help/FieldLabel.tsx | 6 +- .../src/components/help/documentationHelp.ts | 25 ++++++++ webui/src/index.ts | 3 + webui/src/styles/components.css | 21 +++++++ webui/tests/action-blocker-hint.test.tsx | 5 ++ webui/tests/documentation-help-link.test.tsx | 46 ++++++++++++++ webui/tsconfig.component-tests.json | 4 ++ 13 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 webui/src/components/help/DocumentationHelpLink.tsx create mode 100644 webui/src/components/help/documentationHelp.ts create mode 100644 webui/tests/documentation-help-link.test.tsx diff --git a/docs/UI_UX_DECISION_LEDGER.md b/docs/UI_UX_DECISION_LEDGER.md index e016b15..02a56b8 100644 --- a/docs/UI_UX_DECISION_LEDGER.md +++ b/docs/UI_UX_DECISION_LEDGER.md @@ -341,6 +341,9 @@ Every new or changed admin/configuration surface should answer: - Does it say who can fix a blocker and where? - Does a module-localized blocker pass its translated row labels through the shared `ActionBlockerHint` contract instead of reproducing the component? +- Does longer field or blocker guidance use a stable `DocumentationHelpLink` + topic/context reference, with hosted fallback when the optional Docs module + is absent? - Does it reuse existing core patterns for wizard steps, problem lists, modals, help, and review? - Is there a review or preflight step before broad, destructive, or risky diff --git a/webui/package.json b/webui/package.json index 53dc7cd..79702a9 100644 --- a/webui/package.json +++ b/webui/package.json @@ -45,6 +45,7 @@ "test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js", "test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js", "test:action-blocker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/action-blocker-hint.test.js", + "test:documentation-help": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/documentation-help-link.test.js", "test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js", "test:wysiwyg-editor": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/wysiwyg-editor-utils.test.js" }, diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 5777328..ca741f8 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -21,6 +21,8 @@ import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard"; import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext"; import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary"; import ModuleLoadBoundary from "./components/ModuleLoadBoundary"; +import { DocumentationHelpProvider } from "./components/help/DocumentationHelpLink"; +import { hasAnyScope } from "./utils/permissions"; const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage")); const SettingsPage = lazy(() => import("./features/settings/SettingsPage")); @@ -494,6 +496,8 @@ export default function App() { } const defaultRoute = firstAccessibleRoute(auth, webModules, viewProjection); + const localDocsAvailable = hasAnyScope(auth, ["docs:documentation:read", "docs:documentation:admin", "system:settings:read", "admin:settings:read"]) && + webModules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs")); const authAvailableLanguages = auth.available_languages?.map((item) => ({ code: item.code, label: item.label, @@ -514,6 +518,7 @@ export default function App() { onLanguageChange={persistLanguagePreference} moduleTranslations={moduleTranslations}> + @@ -561,6 +566,7 @@ export default function App() { + ); diff --git a/webui/src/components/ActionBlockerHint.tsx b/webui/src/components/ActionBlockerHint.tsx index 9149453..181beb3 100644 --- a/webui/src/components/ActionBlockerHint.tsx +++ b/webui/src/components/ActionBlockerHint.tsx @@ -1,6 +1,8 @@ import { AlertTriangle, Info } from "lucide-react"; import type { ReactNode } from "react"; import AdvancedOptionsPanel from "./AdvancedOptionsPanel"; +import DocumentationHelpLink from "./help/DocumentationHelpLink"; +import type { DocumentationHelpReference } from "./help/documentationHelp"; export type ActionBlockerReason = { summary: ReactNode; @@ -23,6 +25,7 @@ type ActionBlockerHintProps = { tone?: "info" | "warning" | "danger"; className?: string; labels?: ActionBlockerLabels; + documentation?: DocumentationHelpReference; }; function joinClasses(...classes: Array) { @@ -33,7 +36,8 @@ export default function ActionBlockerHint({ reason, tone = "warning", className = "", - labels = {} + labels = {}, + documentation }: ActionBlockerHintProps) { const Icon = tone === "info" ? Info : AlertTriangle; const hasActionRows = Boolean(reason.requiredAction || reason.actor || reason.target); @@ -71,6 +75,7 @@ export default function ActionBlockerHint({
{reason.technicalDetails}
)} + {documentation && } ); diff --git a/webui/src/components/FormField.tsx b/webui/src/components/FormField.tsx index 73016e0..b6e0887 100644 --- a/webui/src/components/FormField.tsx +++ b/webui/src/components/FormField.tsx @@ -1,14 +1,15 @@ import type { ReactNode } from "react"; import FieldLabel from "./help/FieldLabel"; +import type { DocumentationHelpReference } from "./help/documentationHelp"; import { helpForFieldLabel } from "../utils/fieldHelp"; import { usePlatformLanguage } from "../i18n/LanguageContext"; -export default function FormField({ label, help, children }: { label: ReactNode; help?: ReactNode; children: ReactNode }) { +export default function FormField({ label, help, documentation, children }: { label: ReactNode; help?: ReactNode; documentation?: DocumentationHelpReference; children: ReactNode }) { const { translateText } = usePlatformLanguage(); const renderedLabel = typeof label === "string" ? translateText(label) : label; return ( ); diff --git a/webui/src/components/help/DocumentationHelpLink.tsx b/webui/src/components/help/DocumentationHelpLink.tsx new file mode 100644 index 0000000..efd7016 --- /dev/null +++ b/webui/src/components/help/DocumentationHelpLink.tsx @@ -0,0 +1,63 @@ +import { BookOpen } from "lucide-react"; +import { createContext, useContext, type MouseEvent, type ReactNode } from "react"; + +import { usePlatformLanguage } from "../../i18n/LanguageContext"; +import { + HOSTED_DOCUMENTATION_URL, + documentationHelpHref, + type DocumentationHelpReference +} from "./documentationHelp"; + +export { documentationHelpHref } from "./documentationHelp"; +export type { DocumentationHelpReference } from "./documentationHelp"; + +const DocumentationHelpAvailabilityContext = createContext(false); + +export function DocumentationHelpProvider({ + localDocsAvailable, + children +}: { + localDocsAvailable: boolean; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export default function DocumentationHelpLink({ + reference, + label = "i18n:govoplan-core.open_user_documentation.084af515", + className = "" +}: { + reference: DocumentationHelpReference; + label?: string; + className?: string; +}) { + const { translateText } = usePlatformLanguage(); + const localDocsAvailable = useContext(DocumentationHelpAvailabilityContext); + const href = documentationHelpHref( + reference, + localDocsAvailable ? "/docs" : HOSTED_DOCUMENTATION_URL + ); + if (!href) return null; + + const translatedLabel = translateText(label); + const stopLabelActivation = (event: MouseEvent) => event.stopPropagation(); + return ( + + + ); +} diff --git a/webui/src/components/help/FieldLabel.tsx b/webui/src/components/help/FieldLabel.tsx index 5e96572..e1f2c9b 100644 --- a/webui/src/components/help/FieldLabel.tsx +++ b/webui/src/components/help/FieldLabel.tsx @@ -1,17 +1,21 @@ import type { ReactNode } from "react"; +import DocumentationHelpLink from "./DocumentationHelpLink"; +import type { DocumentationHelpReference } from "./documentationHelp"; import InlineHelp from "./InlineHelp"; type FieldLabelProps = { children: ReactNode; help?: ReactNode; + documentation?: DocumentationHelpReference; className?: string; }; -export default function FieldLabel({ children, help, className = "" }: FieldLabelProps) { +export default function FieldLabel({ children, help, documentation, className = "" }: FieldLabelProps) { return ( {children} {help && {help}} + {documentation && } ); } diff --git a/webui/src/components/help/documentationHelp.ts b/webui/src/components/help/documentationHelp.ts new file mode 100644 index 0000000..0304179 --- /dev/null +++ b/webui/src/components/help/documentationHelp.ts @@ -0,0 +1,25 @@ +export const HOSTED_DOCUMENTATION_URL = "https://govoplan.add-ideas.de/"; + +export type DocumentationHelpReference = { + topicId?: string; + contextId?: string; + documentationType?: "user" | "admin"; + anchorId?: string; +}; + +export function documentationHelpHref( + reference: DocumentationHelpReference, + baseUrl = "/docs" +): string | null { + const topicId = reference.topicId?.trim(); + const contextId = reference.contextId?.trim(); + if (!topicId && !contextId) return null; + + const params = new URLSearchParams({ + type: reference.documentationType === "admin" ? "admin" : "user" + }); + if (topicId) params.set("topic", topicId); + else if (contextId) params.set("context", contextId); + const anchorId = reference.anchorId?.trim(); + return `${baseUrl}?${params.toString()}${anchorId ? `#${encodeURIComponent(anchorId)}` : ""}`; +} diff --git a/webui/src/index.ts b/webui/src/index.ts index 450b973..59b7f6f 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -174,6 +174,9 @@ export { default as EmailAddressInput } from "./components/email/EmailAddressInp export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel"; export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsMode, MailServerSettingsPanelProps, MailServerSettingsSection, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel"; export { default as FieldLabel } from "./components/help/FieldLabel"; +export { default as DocumentationHelpLink, DocumentationHelpProvider } from "./components/help/DocumentationHelpLink"; +export { documentationHelpHref } from "./components/help/documentationHelp"; +export type { DocumentationHelpReference } from "./components/help/documentationHelp"; export { default as InlineHelp } from "./components/help/InlineHelp"; export { default as DataGrid, DataGridEmptyAction, DataGridPaginationBar, DataGridRowActions } from "./components/table/DataGrid"; export type { DataGridClientPagination, DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridProps, DataGridQueryState, DataGridServerPagination, DataGridSortDirection } from "./components/table/DataGrid"; diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css index 1f1a80c..e12b771 100644 --- a/webui/src/styles/components.css +++ b/webui/src/styles/components.css @@ -1951,6 +1951,27 @@ .inline-help:focus-visible .inline-help-mark { box-shadow: var(--focus-ring); } +.documentation-help-link { + display: inline-grid; + place-items: center; + flex: 0 0 auto; + width: 18px; + height: 18px; + border-radius: 3px; + color: var(--text-subtle); + text-decoration: none; +} +.documentation-help-link:hover { + color: var(--accent); + background: var(--hover-bg); +} +.documentation-help-link:focus-visible { + box-shadow: var(--focus-ring); + outline: none; +} +.ui-hide-help-hints .documentation-help-link { + display: none; +} .policy-path-help { display: grid; gap: 3px; diff --git a/webui/tests/action-blocker-hint.test.tsx b/webui/tests/action-blocker-hint.test.tsx index 1d8e081..450ea62 100644 --- a/webui/tests/action-blocker-hint.test.tsx +++ b/webui/tests/action-blocker-hint.test.tsx @@ -28,6 +28,7 @@ const localizedMarkup = renderToStaticMarkup( target: "i18n:target", technicalDetails: "i18n:technical-details" }} + documentation={{ topicId: "docs.pattern.blocked-action" }} reason={{ summary: "i18n:summary", requiredAction: "i18n:action-copy", @@ -47,3 +48,7 @@ for (const token of [ assert(localizedMarkup.includes(token), `${token} is rendered through the shared blocker contract`); } assert(localizedMarkup.includes("tone-danger"), "the consequence tone remains explicit"); +assert( + localizedMarkup.includes("topic=docs.pattern.blocked-action"), + "a blocker can link to a stable configured-system documentation topic" +); diff --git a/webui/tests/documentation-help-link.test.tsx b/webui/tests/documentation-help-link.test.tsx new file mode 100644 index 0000000..740ee08 --- /dev/null +++ b/webui/tests/documentation-help-link.test.tsx @@ -0,0 +1,46 @@ +function assert(condition: unknown, message = "assertion failed"): void { + if (!condition) throw new Error(message); +} + +import { renderToStaticMarkup } from "react-dom/server"; +import DocumentationHelpLink, { DocumentationHelpProvider } from "../src/components/help/DocumentationHelpLink"; +import FieldLabel from "../src/components/help/FieldLabel"; +import { documentationHelpHref } from "../src/components/help/documentationHelp"; + +assert( + documentationHelpHref({ topicId: "campaigns.workflow.complete-review" }) === + "/docs?type=user&topic=campaigns.workflow.complete-review", + "topic references use the stable Help Center query contract" +); +assert( + documentationHelpHref({ contextId: "campaign.review-send", documentationType: "admin" }) === + "/docs?type=admin&context=campaign.review-send", + "context references retain the requested audience projection" +); +assert( + documentationHelpHref({ topicId: "topic", anchorId: "details" }) === + "/docs?type=user&topic=topic#details", + "optional stable anchors are preserved" +); +assert(documentationHelpHref({}) === null, "an empty reference does not create a misleading link"); + +const hostedMarkup = renderToStaticMarkup( + +); +assert(hostedMarkup.includes("https://govoplan.add-ideas.de/?type=user&context=files.list"), "the link falls back to hosted documentation when Docs is absent"); +assert(hostedMarkup.includes('target="_blank"'), "hosted documentation is clearly external"); + +const localMarkup = renderToStaticMarkup( + + + +); +assert(localMarkup.includes("/docs?type=user&topic=docs.pattern.field-help"), "an enabled Docs module uses the local Help Center"); +assert(!localMarkup.includes('target="_blank"'), "local documentation stays in the application"); + +const fieldMarkup = renderToStaticMarkup( + + Role + +); +assert(fieldMarkup.includes("topic=access.reference.admin-access-fields"), "field labels can link to stable reference topics"); diff --git a/webui/tsconfig.component-tests.json b/webui/tsconfig.component-tests.json index e2b2ee8..16db9ec 100644 --- a/webui/tsconfig.component-tests.json +++ b/webui/tsconfig.component-tests.json @@ -22,6 +22,7 @@ "tests/data-grid-actions.test.tsx", "tests/data-grid-sizing.test.ts", "tests/dialog-focus.test.tsx", + "tests/documentation-help-link.test.tsx", "tests/explorer-tree.test.tsx", "tests/icon-button.test.tsx", "tests/mail-components.test.tsx", @@ -33,6 +34,9 @@ "src/components/CredentialPanel.tsx", "src/components/ActionBlockerHint.tsx", "src/components/AdvancedOptionsPanel.tsx", + "src/components/help/DocumentationHelpLink.tsx", + "src/components/help/documentationHelp.ts", + "src/components/help/FieldLabel.tsx", "src/components/email/EmailAddressInput.tsx", "src/components/PasswordField.tsx", "src/components/MessageDisplayPanel.tsx",