feat: add exact credential help contexts

This commit is contained in:
2026-08-17 19:51:51 +02:00
parent ff88142471
commit d307e29145
6 changed files with 57 additions and 17 deletions
@@ -36,6 +36,10 @@ assert.match(confirmDialog, /helpContextId=\{helpContextId\}/, "confirm dialogs
assert.match(credentials, /<ActionBlockerHint/, "credentials render the shared actionable blocker");
assert.match(credentials, /contextId: "access\.credentials"/, "credentials expose stable admin documentation");
assert.match(credentials, /access\.credentials\.field\.secret/, "credential secrets expose exact handling guidance");
assert.match(credentials, /access\.credentials\.field\.inherit-to-lower-scopes/, "credential inheritance exposes exact scope guidance");
assert.match(credentials, /access\.credentials\.confirm-delete/, "credential deletion exposes exact consequence guidance");
assert.match(credentials, /helpModuleId="access"/, "embedded credential controls retain Access as their documentation owner");
assert.match(credentials, /disabledReason: writeDisabledReason/, "credential row actions retain actionable disabled reasons");
assert.doesNotMatch(credentials, /<textarea/, "credentials use typed controls rather than a primary JSON editor");
@@ -98,6 +98,7 @@ const EMPTY_DRAFT: CredentialDraft = {
};
const CREDENTIAL_DOCUMENTATION = {
moduleId: "access",
contextId: "access.credentials",
documentationType: "admin" as const
};
@@ -409,8 +410,10 @@ export default function CredentialEnvelopeManager({
return (
<div className="credential-envelope-manager">
{targetOptions.length > 0 && (
<FormField label={targetLabel}>
<FormField label={targetLabel} helpContextId="access.credentials.target" helpModuleId="access">
<select
data-help-context-id="access.credentials.target"
data-help-module-id="access"
value={selectedTargetId}
disabled={saving}
onChange={(event) => setSelectedTargetId(event.target.value)}
@@ -433,6 +436,8 @@ export default function CredentialEnvelopeManager({
)}
<Card
title={title}
helpContextId="access.credentials"
helpModuleId="access"
actions={
<div className="button-row compact-actions">
<DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />
@@ -440,6 +445,8 @@ export default function CredentialEnvelopeManager({
type="button"
title="Reload credentials"
aria-label="Reload credentials"
helpContextId="access.credentials.action.reload"
helpModuleId="access"
onClick={() => void loadCredentials()}
disabled={loading || !scopeReady}
disabledReason={loading ? "Credentials are already loading." : !scopeReady ? "Select a credential owner before reloading." : undefined}
@@ -449,6 +456,8 @@ export default function CredentialEnvelopeManager({
<Button
type="button"
variant="primary"
helpContextId="access.credentials.action.create"
helpModuleId="access"
onClick={openCreate}
disabled={Boolean(writeDisabledReason) || loading}
disabledReason={loading ? "Wait until credentials have loaded." : writeDisabledReason}
@@ -471,6 +480,8 @@ export default function CredentialEnvelopeManager({
id: "edit",
label: `Edit ${credential.name}`,
icon: <Pencil size={15} />,
helpContextId: "access.credentials.action.edit",
helpModuleId: "access",
onClick: () => openEdit(credential),
disabled: !canWrite || saving,
disabledReason: writeDisabledReason
@@ -479,6 +490,8 @@ export default function CredentialEnvelopeManager({
id: "delete",
label: `Delete ${credential.name}`,
icon: <Trash2 size={15} />,
helpContextId: "access.credentials.action.delete",
helpModuleId: "access",
onClick: () => setDeleting(credential),
disabled: !canWrite || saving,
disabledReason: writeDisabledReason,
@@ -494,6 +507,8 @@ export default function CredentialEnvelopeManager({
<Dialog
open={Boolean(editing)}
title={editing === "new" ? "Add reusable credential" : "Edit reusable credential"}
helpContextId="access.credentials.editor"
helpModuleId="access"
onClose={closeEditor}
closeDisabled={saving}
className="admin-dialog admin-dialog-wide adaptive-config-dialog"
@@ -501,7 +516,7 @@ export default function CredentialEnvelopeManager({
footer={
<>
<Button onClick={closeEditor} disabled={saving} disabledReason={saving ? "Wait for the credential save to finish." : undefined}>Cancel</Button>
<Button variant="primary" onClick={() => void saveDraft()} disabled={saveDisabled} disabledReason={saveDisabledReason}>
<Button helpContextId="access.credentials.action.save" helpModuleId="access" variant="primary" onClick={() => void saveDraft()} disabled={saveDisabled} disabledReason={saveDisabledReason}>
<KeyRound size={16} /> {saving ? "Saving" : "Save credential"}
</Button>
</>
@@ -514,32 +529,36 @@ export default function CredentialEnvelopeManager({
<p>The name and type are visible; secret values are never returned by the API.</p>
</header>
<div className="form-grid two">
<FormField label="Name">
<input value={draft.name} disabled={saving} onChange={(event) => setDraft({ ...draft, name: event.target.value })} autoFocus />
<FormField label="Name" helpContextId="access.credentials.field.name" helpModuleId="access">
<input data-help-context-id="access.credentials.field.name" data-help-module-id="access" value={draft.name} disabled={saving} onChange={(event) => setDraft({ ...draft, name: event.target.value })} autoFocus />
</FormField>
<FormField label="Type">
<select value={draft.credentialKind} disabled={saving} onChange={(event) => setDraft({ ...draft, credentialKind: event.target.value as CredentialKind, secret: "", clearSecret: false })}>
<FormField label="Type" helpContextId="access.credentials.field.type" helpModuleId="access">
<select data-help-context-id="access.credentials.field.type" data-help-module-id="access" value={draft.credentialKind} disabled={saving} onChange={(event) => setDraft({ ...draft, credentialKind: event.target.value as CredentialKind, secret: "", clearSecret: false })}>
<option value="username_password">Username and password</option>
<option value="token">Access token</option>
<option value="api_key">API key</option>
</select>
</FormField>
<FormField label="Description">
<input value={draft.description} disabled={saving} onChange={(event) => setDraft({ ...draft, description: event.target.value })} />
<FormField label="Description" helpContextId="access.credentials.field.description" helpModuleId="access">
<input data-help-context-id="access.credentials.field.description" data-help-module-id="access" value={draft.description} disabled={saving} onChange={(event) => setDraft({ ...draft, description: event.target.value })} />
</FormField>
<FormField label={draft.credentialKind === "username_password" ? "Username" : "Account or key label"}>
<input value={draft.username} disabled={saving} onChange={(event) => setDraft({ ...draft, username: event.target.value })} />
<FormField label={draft.credentialKind === "username_password" ? "Username" : "Account or key label"} helpContextId="access.credentials.field.account-label" helpModuleId="access">
<input data-help-context-id="access.credentials.field.account-label" data-help-module-id="access" value={draft.username} disabled={saving} onChange={(event) => setDraft({ ...draft, username: event.target.value })} />
</FormField>
<FormField
label={secretFieldLabel(draft.credentialKind)}
help={editing !== "new" ? "Leave blank to retain the configured secret." : undefined}
helpContextId="access.credentials.field.secret"
helpModuleId="access"
>
<PasswordField value={draft.secret} onValueChange={(secret) => setDraft({ ...draft, secret, clearSecret: false })} disabled={saving} autoComplete="new-password" generator />
<PasswordField data-help-context-id="access.credentials.field.secret" data-help-module-id="access" value={draft.secret} onValueChange={(secret) => setDraft({ ...draft, secret, clearSecret: false })} disabled={saving} autoComplete="new-password" generator />
</FormField>
{editing !== "new" && (
<ToggleSwitch
checked={draft.clearSecret}
disabled={saving || Boolean(draft.secret)}
helpContextId="access.credentials.field.clear-secret"
helpModuleId="access"
onChange={(clearSecret) => setDraft({ ...draft, clearSecret })}
label="Remove configured secret"
/>
@@ -552,7 +571,7 @@ export default function CredentialEnvelopeManager({
<p>Empty module or server lists mean every module or server allowed by scope.</p>
</header>
<div className="form-grid two">
<FormField label="Modules" help="Choose installed modules that may use this credential. Empty means every module allowed by scope.">
<FormField label="Modules" help="Choose installed modules that may use this credential. Empty means every module allowed by scope." helpContextId="access.credentials.field.allowed-modules" helpModuleId="access">
<ReferenceMultiSelect
values={splitValues(draft.allowedModules)}
onChange={(values) => setDraft({ ...draft, allowedModules: values.join(", ") })}
@@ -562,7 +581,7 @@ export default function CredentialEnvelopeManager({
disabled={saving}
/>
</FormField>
<FormField label="Servers" help="Choose announced servers or enter an explicit module-qualified reference. Empty means every permitted server.">
<FormField label="Servers" help="Choose announced servers or enter an explicit module-qualified reference. Empty means every permitted server." helpContextId="access.credentials.field.allowed-servers" helpModuleId="access">
<ReferenceMultiSelect
values={splitValues(draft.allowedServerRefs)}
onChange={(values) => setDraft({ ...draft, allowedServerRefs: values.join(", ") })}
@@ -573,8 +592,8 @@ export default function CredentialEnvelopeManager({
disabled={saving}
/>
</FormField>
<ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} onChange={(inheritToLowerScopes) => setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" />
<ToggleSwitch checked={draft.isActive} disabled={saving} onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" />
<ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} helpContextId="access.credentials.field.inherit-to-lower-scopes" helpModuleId="access" onChange={(inheritToLowerScopes) => setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" />
<ToggleSwitch checked={draft.isActive} disabled={saving} helpContextId="access.credentials.field.active" helpModuleId="access" onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" />
</div>
</section>
</div>
@@ -582,6 +601,8 @@ export default function CredentialEnvelopeManager({
<ConfirmDialog
open={Boolean(deleting)}
helpContextId="access.credentials.confirm-delete"
helpModuleId="access"
title="Delete credential"
message={`Delete ${deleting?.name ?? "this credential"}? Connections that reference it will stop authenticating; the secret cannot be recovered.`}
confirmLabel="Delete"
@@ -1,8 +1,9 @@
import type { MouseEvent, ReactNode } from "react";
import Button from "../Button";
import { usePlatformLanguage } from "../../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../../types";
export type TableActionDefinition = {
export type TableActionDefinition = PlatformInterfaceIdentityProps & {
id: string;
label: string;
icon: ReactNode;
@@ -76,6 +77,10 @@ export default function TableActionGroup({
type="button"
variant={action.variant ?? "secondary"}
className="table-action-button"
interfaceId={action.interfaceId}
helpContextId={action.helpContextId}
helpModuleId={action.helpModuleId}
helpTopicId={action.helpTopicId}
aria-label={translatedLabel}
title={translatedLabel}
disabled={action.disabled || action.applicable === false}
+3 -1
View File
@@ -37,7 +37,7 @@ assertEqual((emptyAction.match(/table-action-placeholder/g) ?? []).length, 3, "e
const contextActions = renderToStaticMarkup(
<TableActionGroup
actions={[
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop },
{ id: "inspect", label: "Inspect", icon: <span>I</span>, helpContextId: "test.row.inspect", helpModuleId: "test-module", onClick: noop },
{ id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop },
{ id: "remove", label: "Remove", icon: <span>R</span>, disabledReason: "Permission denied", onClick: noop }
]}
@@ -51,6 +51,8 @@ assertEqual((contextActions.match(/table-action-placeholder/g) ?? []).length, 1,
assertEqual(contextActions.includes("disabled-action-tooltip"), true, "disabled table actions can explain their unavailable state");
assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label");
assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip");
assertEqual(contextActions.includes('data-help-context-id="test.row.inspect"'), true, "table actions propagate exact contextual help");
assertEqual(contextActions.includes('data-help-module-id="test-module"'), true, "table actions propagate the documentation owner");
assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative");
let propagationStopped = false;