From 70fc6da811596a49d8f591caf7ec7c2272c2db46 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 07:22:16 +0200 Subject: [PATCH] Localize shared blocker guidance --- docs/UI_UX_DECISION_LEDGER.md | 2 + webui/package.json | 1 + webui/src/components/ActionBlockerHint.tsx | 23 +++++++--- webui/src/index.ts | 2 +- webui/tests/action-blocker-hint.test.tsx | 49 ++++++++++++++++++++++ webui/tsconfig.component-tests.json | 3 ++ 6 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 webui/tests/action-blocker-hint.test.tsx diff --git a/docs/UI_UX_DECISION_LEDGER.md b/docs/UI_UX_DECISION_LEDGER.md index a8dda61..e016b15 100644 --- a/docs/UI_UX_DECISION_LEDGER.md +++ b/docs/UI_UX_DECISION_LEDGER.md @@ -339,6 +339,8 @@ Every new or changed admin/configuration surface should answer: - Does the screen explain disabled actions and failed validation in plain language? - 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 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 4b4e2f8..53dc7cd 100644 --- a/webui/package.json +++ b/webui/package.json @@ -44,6 +44,7 @@ "test:metric-card": "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/metric-card.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: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: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/components/ActionBlockerHint.tsx b/webui/src/components/ActionBlockerHint.tsx index b1a72f1..9149453 100644 --- a/webui/src/components/ActionBlockerHint.tsx +++ b/webui/src/components/ActionBlockerHint.tsx @@ -11,17 +11,30 @@ export type ActionBlockerReason = { technicalDetails?: ReactNode; }; +export type ActionBlockerLabels = { + requiredAction?: ReactNode; + actor?: ReactNode; + target?: ReactNode; + technicalDetails?: ReactNode; +}; + type ActionBlockerHintProps = { reason: ActionBlockerReason; tone?: "info" | "warning" | "danger"; className?: string; + labels?: ActionBlockerLabels; }; function joinClasses(...classes: Array) { return classes.filter(Boolean).join(" "); } -export default function ActionBlockerHint({ reason, tone = "warning", className = "" }: ActionBlockerHintProps) { +export default function ActionBlockerHint({ + reason, + tone = "warning", + className = "", + labels = {} +}: ActionBlockerHintProps) { const Icon = tone === "info" ? Info : AlertTriangle; const hasActionRows = Boolean(reason.requiredAction || reason.actor || reason.target); @@ -35,26 +48,26 @@ export default function ActionBlockerHint({ reason, tone = "warning", className
{reason.requiredAction && ( <> -
Required action
+
{labels.requiredAction ?? "Required action"}
{reason.requiredAction}
)} {reason.actor && ( <> -
Who can fix it
+
{labels.actor ?? "Who can fix it"}
{reason.actor}
)} {reason.target && ( <> -
Where to go
+
{labels.target ?? "Where to go"}
{reason.target}
)}
)} {reason.technicalDetails && ( - +
{reason.technicalDetails}
)} diff --git a/webui/src/index.ts b/webui/src/index.ts index f140900..450b973 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -46,7 +46,7 @@ export * from "./i18n/LanguageContext"; export { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary"; export { default as ActionBlockerHint } from "./components/ActionBlockerHint"; -export type { ActionBlockerReason } from "./components/ActionBlockerHint"; +export type { ActionBlockerLabels, ActionBlockerReason } from "./components/ActionBlockerHint"; export { default as AdvancedOptionsPanel } from "./components/AdvancedOptionsPanel"; export { default as AdminIconButton } from "./components/admin/AdminIconButton"; export type { AdminIconButtonProps } from "./components/admin/AdminIconButton"; diff --git a/webui/tests/action-blocker-hint.test.tsx b/webui/tests/action-blocker-hint.test.tsx new file mode 100644 index 0000000..1d8e081 --- /dev/null +++ b/webui/tests/action-blocker-hint.test.tsx @@ -0,0 +1,49 @@ +function assert(condition: unknown, message = "assertion failed"): void { + if (!condition) throw new Error(message); +} + +import { renderToStaticMarkup } from "react-dom/server"; +import ActionBlockerHint from "../src/components/ActionBlockerHint"; + +const defaultMarkup = renderToStaticMarkup( + +); +assert(defaultMarkup.includes("Required action"), "the established default labels remain available"); +assert(defaultMarkup.includes("Who can fix it"), "the established actor label remains available"); +assert(defaultMarkup.includes("Where to go"), "the established target label remains available"); + +const localizedMarkup = renderToStaticMarkup( + +); +for (const token of [ + "i18n:required-action", + "i18n:actor", + "i18n:target", + "i18n:technical-details", + "i18n:diagnostics" +]) { + assert(localizedMarkup.includes(token), `${token} is rendered through the shared blocker contract`); +} +assert(localizedMarkup.includes("tone-danger"), "the consequence tone remains explicit"); diff --git a/webui/tsconfig.component-tests.json b/webui/tsconfig.component-tests.json index 4341173..e2b2ee8 100644 --- a/webui/tsconfig.component-tests.json +++ b/webui/tsconfig.component-tests.json @@ -18,6 +18,7 @@ "jsx": "react-jsx" }, "include": [ + "tests/action-blocker-hint.test.tsx", "tests/data-grid-actions.test.tsx", "tests/data-grid-sizing.test.ts", "tests/dialog-focus.test.tsx", @@ -30,6 +31,8 @@ "tests/selection-list.test.tsx", "tests/wysiwyg-editor-utils.test.ts", "src/components/CredentialPanel.tsx", + "src/components/ActionBlockerHint.tsx", + "src/components/AdvancedOptionsPanel.tsx", "src/components/email/EmailAddressInput.tsx", "src/components/PasswordField.tsx", "src/components/MessageDisplayPanel.tsx",