Link contextual guidance to Docs

This commit is contained in:
2026-08-03 07:33:57 +02:00
parent 70fc6da811
commit b823a22b9b
13 changed files with 191 additions and 4 deletions
+3
View File
@@ -341,6 +341,9 @@ Every new or changed admin/configuration surface should answer:
- Does it say who can fix a blocker and where? - Does it say who can fix a blocker and where?
- Does a module-localized blocker pass its translated row labels through the - Does a module-localized blocker pass its translated row labels through the
shared `ActionBlockerHint` contract instead of reproducing the component? 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, - Does it reuse existing core patterns for wizard steps, problem lists, modals,
help, and review? help, and review?
- Is there a review or preflight step before broad, destructive, or risky - Is there a review or preflight step before broad, destructive, or risky
+1
View File
@@ -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: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: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: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: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" "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"
}, },
+6
View File
@@ -21,6 +21,8 @@ import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext"; import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary"; import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
import ModuleLoadBoundary from "./components/ModuleLoadBoundary"; import ModuleLoadBoundary from "./components/ModuleLoadBoundary";
import { DocumentationHelpProvider } from "./components/help/DocumentationHelpLink";
import { hasAnyScope } from "./utils/permissions";
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage")); const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage")); const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
@@ -494,6 +496,8 @@ export default function App() {
} }
const defaultRoute = firstAccessibleRoute(auth, webModules, viewProjection); 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) => ({ const authAvailableLanguages = auth.available_languages?.map((item) => ({
code: item.code, code: item.code,
label: item.label, label: item.label,
@@ -514,6 +518,7 @@ export default function App() {
onLanguageChange={persistLanguagePreference} onLanguageChange={persistLanguagePreference}
moduleTranslations={moduleTranslations}> moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}> <PlatformModulesProvider modules={webModules}>
<DocumentationHelpProvider localDocsAvailable={localDocsAvailable}>
<PlatformViewProvider modules={webModules} projection={viewProjection}> <PlatformViewProvider modules={webModules} projection={viewProjection}>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}> <AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
@@ -561,6 +566,7 @@ export default function App() {
</AppShell> </AppShell>
</UnsavedChangesProvider> </UnsavedChangesProvider>
</PlatformViewProvider> </PlatformViewProvider>
</DocumentationHelpProvider>
</PlatformModulesProvider> </PlatformModulesProvider>
</PlatformLanguageProvider>); </PlatformLanguageProvider>);
+6 -1
View File
@@ -1,6 +1,8 @@
import { AlertTriangle, Info } from "lucide-react"; import { AlertTriangle, Info } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import AdvancedOptionsPanel from "./AdvancedOptionsPanel"; import AdvancedOptionsPanel from "./AdvancedOptionsPanel";
import DocumentationHelpLink from "./help/DocumentationHelpLink";
import type { DocumentationHelpReference } from "./help/documentationHelp";
export type ActionBlockerReason = { export type ActionBlockerReason = {
summary: ReactNode; summary: ReactNode;
@@ -23,6 +25,7 @@ type ActionBlockerHintProps = {
tone?: "info" | "warning" | "danger"; tone?: "info" | "warning" | "danger";
className?: string; className?: string;
labels?: ActionBlockerLabels; labels?: ActionBlockerLabels;
documentation?: DocumentationHelpReference;
}; };
function joinClasses(...classes: Array<string | undefined | false>) { function joinClasses(...classes: Array<string | undefined | false>) {
@@ -33,7 +36,8 @@ export default function ActionBlockerHint({
reason, reason,
tone = "warning", tone = "warning",
className = "", className = "",
labels = {} labels = {},
documentation
}: ActionBlockerHintProps) { }: ActionBlockerHintProps) {
const Icon = tone === "info" ? Info : AlertTriangle; const Icon = tone === "info" ? Info : AlertTriangle;
const hasActionRows = Boolean(reason.requiredAction || reason.actor || reason.target); const hasActionRows = Boolean(reason.requiredAction || reason.actor || reason.target);
@@ -71,6 +75,7 @@ export default function ActionBlockerHint({
<div>{reason.technicalDetails}</div> <div>{reason.technicalDetails}</div>
</AdvancedOptionsPanel> </AdvancedOptionsPanel>
)} )}
{documentation && <DocumentationHelpLink reference={documentation} />}
</div> </div>
</section> </section>
); );
+3 -2
View File
@@ -1,14 +1,15 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import FieldLabel from "./help/FieldLabel"; import FieldLabel from "./help/FieldLabel";
import type { DocumentationHelpReference } from "./help/documentationHelp";
import { helpForFieldLabel } from "../utils/fieldHelp"; import { helpForFieldLabel } from "../utils/fieldHelp";
import { usePlatformLanguage } from "../i18n/LanguageContext"; 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 { translateText } = usePlatformLanguage();
const renderedLabel = typeof label === "string" ? translateText(label) : label; const renderedLabel = typeof label === "string" ? translateText(label) : label;
return ( return (
<label className="form-field"> <label className="form-field">
<FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)}>{renderedLabel}</FieldLabel> <FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)} documentation={documentation}>{renderedLabel}</FieldLabel>
{children} {children}
</label> </label>
); );
@@ -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 (
<DocumentationHelpAvailabilityContext.Provider value={localDocsAvailable}>
{children}
</DocumentationHelpAvailabilityContext.Provider>
);
}
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<HTMLAnchorElement>) => event.stopPropagation();
return (
<a
className={["documentation-help-link", className].filter(Boolean).join(" ")}
href={href}
aria-label={translatedLabel}
title={translatedLabel}
target={localDocsAvailable ? undefined : "_blank"}
rel={localDocsAvailable ? undefined : "noreferrer"}
onClick={stopLabelActivation}
onMouseDown={stopLabelActivation}
>
<BookOpen size={13} aria-hidden="true" />
</a>
);
}
+5 -1
View File
@@ -1,17 +1,21 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import DocumentationHelpLink from "./DocumentationHelpLink";
import type { DocumentationHelpReference } from "./documentationHelp";
import InlineHelp from "./InlineHelp"; import InlineHelp from "./InlineHelp";
type FieldLabelProps = { type FieldLabelProps = {
children: ReactNode; children: ReactNode;
help?: ReactNode; help?: ReactNode;
documentation?: DocumentationHelpReference;
className?: string; className?: string;
}; };
export default function FieldLabel({ children, help, className = "" }: FieldLabelProps) { export default function FieldLabel({ children, help, documentation, className = "" }: FieldLabelProps) {
return ( return (
<span className={`field-label ${className}`.trim()}> <span className={`field-label ${className}`.trim()}>
<span className="field-label-text">{children}</span> <span className="field-label-text">{children}</span>
{help && <InlineHelp>{help}</InlineHelp>} {help && <InlineHelp>{help}</InlineHelp>}
{documentation && <DocumentationHelpLink reference={documentation} />}
</span> </span>
); );
} }
@@ -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)}` : ""}`;
}
+3
View File
@@ -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 { 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 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 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 InlineHelp } from "./components/help/InlineHelp";
export { default as DataGrid, DataGridEmptyAction, DataGridPaginationBar, DataGridRowActions } from "./components/table/DataGrid"; 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"; export type { DataGridClientPagination, DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridProps, DataGridQueryState, DataGridServerPagination, DataGridSortDirection } from "./components/table/DataGrid";
+21
View File
@@ -1951,6 +1951,27 @@
.inline-help:focus-visible .inline-help-mark { .inline-help:focus-visible .inline-help-mark {
box-shadow: var(--focus-ring); 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 { .policy-path-help {
display: grid; display: grid;
gap: 3px; gap: 3px;
+5
View File
@@ -28,6 +28,7 @@ const localizedMarkup = renderToStaticMarkup(
target: "i18n:target", target: "i18n:target",
technicalDetails: "i18n:technical-details" technicalDetails: "i18n:technical-details"
}} }}
documentation={{ topicId: "docs.pattern.blocked-action" }}
reason={{ reason={{
summary: "i18n:summary", summary: "i18n:summary",
requiredAction: "i18n:action-copy", 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(token), `${token} is rendered through the shared blocker contract`);
} }
assert(localizedMarkup.includes("tone-danger"), "the consequence tone remains explicit"); 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"
);
@@ -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(
<DocumentationHelpLink reference={{ contextId: "files.list" }} />
);
assert(hostedMarkup.includes("https://govoplan.add-ideas.de/?type=user&amp;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(
<DocumentationHelpProvider localDocsAvailable>
<DocumentationHelpLink reference={{ topicId: "docs.pattern.field-help" }} />
</DocumentationHelpProvider>
);
assert(localMarkup.includes("/docs?type=user&amp;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(
<DocumentationHelpProvider localDocsAvailable>
<FieldLabel documentation={{ topicId: "access.reference.admin-access-fields" }}>Role</FieldLabel>
</DocumentationHelpProvider>
);
assert(fieldMarkup.includes("topic=access.reference.admin-access-fields"), "field labels can link to stable reference topics");
+4
View File
@@ -22,6 +22,7 @@
"tests/data-grid-actions.test.tsx", "tests/data-grid-actions.test.tsx",
"tests/data-grid-sizing.test.ts", "tests/data-grid-sizing.test.ts",
"tests/dialog-focus.test.tsx", "tests/dialog-focus.test.tsx",
"tests/documentation-help-link.test.tsx",
"tests/explorer-tree.test.tsx", "tests/explorer-tree.test.tsx",
"tests/icon-button.test.tsx", "tests/icon-button.test.tsx",
"tests/mail-components.test.tsx", "tests/mail-components.test.tsx",
@@ -33,6 +34,9 @@
"src/components/CredentialPanel.tsx", "src/components/CredentialPanel.tsx",
"src/components/ActionBlockerHint.tsx", "src/components/ActionBlockerHint.tsx",
"src/components/AdvancedOptionsPanel.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/email/EmailAddressInput.tsx",
"src/components/PasswordField.tsx", "src/components/PasswordField.tsx",
"src/components/MessageDisplayPanel.tsx", "src/components/MessageDisplayPanel.tsx",