Link contextual guidance to Docs
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<DocumentationHelpProvider localDocsAvailable={localDocsAvailable}>
|
||||
<PlatformViewProvider modules={webModules} projection={viewProjection}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
@@ -561,6 +566,7 @@ export default function App() {
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformViewProvider>
|
||||
</DocumentationHelpProvider>
|
||||
</PlatformModulesProvider>
|
||||
</PlatformLanguageProvider>);
|
||||
|
||||
|
||||
@@ -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<string | undefined | false>) {
|
||||
@@ -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({
|
||||
<div>{reason.technicalDetails}</div>
|
||||
</AdvancedOptionsPanel>
|
||||
)}
|
||||
{documentation && <DocumentationHelpLink reference={documentation} />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span className={`field-label ${className}`.trim()}>
|
||||
<span className="field-label-text">{children}</span>
|
||||
{help && <InlineHelp>{help}</InlineHelp>}
|
||||
{documentation && <DocumentationHelpLink reference={documentation} />}
|
||||
</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)}` : ""}`;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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&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&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");
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user