diff --git a/docs/CONTEXTUAL_HELP_CONTRACT.md b/docs/CONTEXTUAL_HELP_CONTRACT.md index 3e60185..b8ba7c5 100644 --- a/docs/CONTEXTUAL_HELP_CONTRACT.md +++ b/docs/CONTEXTUAL_HELP_CONTRACT.md @@ -46,6 +46,8 @@ than adding custom `F1` listeners: - `interfaceId` identifies a durable UI surface or action. - `helpContextId` identifies a documentation context when it differs from the interface identity. +- `helpModuleId` identifies the documentation-owning module when a shared + control is embedded in another module's page. - `helpTopicId` links directly to a module-owned documentation topic. - translated label keys provide deterministic field identities for ordinary `FormField`, `ToggleSwitch`, search, date/time, email, button, dialog, and card diff --git a/docs/LOCALIZATION_AND_HELP_QUALITY.md b/docs/LOCALIZATION_AND_HELP_QUALITY.md index ac64c40..0ba6015 100644 --- a/docs/LOCALIZATION_AND_HELP_QUALITY.md +++ b/docs/LOCALIZATION_AND_HELP_QUALITY.md @@ -32,6 +32,16 @@ should still add exact `metadata.help_contexts` entries for consequential, unfamiliar, policy-controlled, destructive, security-sensitive, or legally meaningful fields and actions. +The shared retention-policy editor exposes explicit contexts for each stored +data category, audit-detail control, lower-level override switch, target +selector, reload, and save action. The Policy module owns the matching German +administrator guidance. Retention execution surfaces use separate contexts for +dry-run, destructive apply, confirmation, and outcome review so F1 opens the +consequence and recovery guidance closest to the focused control. +Shared controls may set `helpModuleId` when their documentation owner differs +from the containing page; the retention editor uses this to resolve Policy help +from both administration and Campaign surfaces. + The generated `help_review_candidates` list is therefore a content-depth queue, not a list of controls on which F1 cannot work. It should prioritize: diff --git a/webui/scripts/test-core-interface-patterns.mjs b/webui/scripts/test-core-interface-patterns.mjs index b75558a..4fb8822 100644 --- a/webui/scripts/test-core-interface-patterns.mjs +++ b/webui/scripts/test-core-interface-patterns.mjs @@ -8,6 +8,7 @@ const read = (path) => readFileSync(resolve(webuiRoot, path), "utf8"); const settings = read("src/features/settings/SettingsPage.tsx"); const retention = read("src/features/privacy/RetentionPolicyManagement.tsx"); +const confirmDialog = read("src/components/ConfirmDialog.tsx"); const credentials = read("src/components/CredentialEnvelopeManager.tsx"); const iconRail = read("src/layout/IconRail.tsx"); const moduleLoadBoundary = read("src/components/ModuleLoadBoundary.tsx"); @@ -24,8 +25,14 @@ assert.match(settings, /There are no unsaved interface changes\./, "preference s assert.match(retention, / & PlatformInte disabledReason?: ReactNode; }; -export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpContextId, helpTopicId, children, ...props }: ButtonProps) { +export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpContextId, helpModuleId, helpTopicId, children, ...props }: ButtonProps) { const button = ( - + }> diff --git a/webui/src/components/DateTimeField.tsx b/webui/src/components/DateTimeField.tsx index 8de6704..cac3687 100644 --- a/webui/src/components/DateTimeField.tsx +++ b/webui/src/components/DateTimeField.tsx @@ -50,7 +50,7 @@ function combineDateTime(date: string, time: string): string { return `${date || dateString(new Date())}T${time || "00:00"}`; } -export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", interfaceId, helpContextId, helpTopicId, ...props }: BaseProps) { +export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", interfaceId, helpContextId, helpModuleId, helpTopicId, ...props }: BaseProps) { const selectedDate = parseDate(value); const [open, setOpen] = useState(false); const [visibleMonth, setVisibleMonth] = useState(() => selectedDate ?? new Date()); @@ -100,6 +100,7 @@ export function DateField({ value, onChange, min, max, disabled, className = "", data-help-scope="field" data-interface-id={interfaceId} data-help-context-id={helpContextId} + data-help-module-id={helpModuleId} data-help-topic-id={helpTopicId} data-help-key={props["aria-label"] ?? placeholder} > @@ -154,7 +155,7 @@ export function DateField({ value, onChange, min, max, disabled, className = "", } -export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", interfaceId, helpContextId, helpTopicId, ...props }: BaseProps) { +export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", interfaceId, helpContextId, helpModuleId, helpTopicId, ...props }: BaseProps) { const inputRef = useRef(null); useEffect(() => { const input = inputRef.current; @@ -172,6 +173,7 @@ export function TimeField({ value, onChange, min, max, className = "", placehold data-help-scope="field" data-interface-id={interfaceId} data-help-context-id={helpContextId} + data-help-module-id={helpModuleId} data-help-topic-id={helpTopicId} data-help-key={props["aria-label"] ?? placeholder} > @@ -190,7 +192,7 @@ export function TimeField({ value, onChange, min, max, className = "", placehold } -export function DateTimeField({ value, onChange, min, max, disabled, className = "", interfaceId, helpContextId, helpTopicId, ...props }: BaseProps) { +export function DateTimeField({ value, onChange, min, max, disabled, className = "", interfaceId, helpContextId, helpModuleId, helpTopicId, ...props }: BaseProps) { const parts = datePartsFromDateTime(value); const minParts = datePartsFromDateTime(min || ""); const maxParts = datePartsFromDateTime(max || ""); @@ -209,6 +211,7 @@ export function DateTimeField({ value, onChange, min, max, disabled, className = data-help-scope="field" data-interface-id={interfaceId} data-help-context-id={helpContextId} + data-help-module-id={helpModuleId} data-help-topic-id={helpTopicId} data-help-key={props["aria-label"]} > diff --git a/webui/src/components/Dialog.tsx b/webui/src/components/Dialog.tsx index fd0e8d8..f1e20cb 100644 --- a/webui/src/components/Dialog.tsx +++ b/webui/src/components/Dialog.tsx @@ -60,6 +60,7 @@ export default function Dialog({ backdropStyle, interfaceId, helpContextId, + helpModuleId, helpTopicId }: DialogProps) { const titleId = useId(); @@ -141,6 +142,7 @@ export default function Dialog({ data-help-scope="dialog" data-interface-id={interfaceId} data-help-context-id={helpContextId} + data-help-module-id={helpModuleId} data-help-topic-id={helpTopicId} data-help-key={typeof title === "string" ? title : undefined} aria-labelledby={titleId} diff --git a/webui/src/components/FormField.tsx b/webui/src/components/FormField.tsx index fd87bc4..d14dca3 100644 --- a/webui/src/components/FormField.tsx +++ b/webui/src/components/FormField.tsx @@ -12,7 +12,7 @@ type FormFieldProps = PlatformInterfaceIdentityProps & { children: ReactNode; }; -export default function FormField({ label, help, documentation, children, interfaceId, helpContextId, helpTopicId }: FormFieldProps) { +export default function FormField({ label, help, documentation, children, interfaceId, helpContextId, helpModuleId, helpTopicId }: FormFieldProps) { const { translateText } = usePlatformLanguage(); const renderedLabel = typeof label === "string" ? translateText(label) : label; return ( @@ -21,6 +21,7 @@ export default function FormField({ label, help, documentation, children, interf data-help-scope="field" data-interface-id={interfaceId} data-help-context-id={helpContextId ?? documentation?.contextId} + data-help-module-id={helpModuleId} data-help-topic-id={helpTopicId ?? documentation?.topicId} data-help-documentation-type={documentation?.documentationType} data-help-key={typeof label === "string" ? label : undefined} diff --git a/webui/src/components/SearchableSelect.tsx b/webui/src/components/SearchableSelect.tsx index c77e208..c445edb 100644 --- a/webui/src/components/SearchableSelect.tsx +++ b/webui/src/components/SearchableSelect.tsx @@ -99,6 +99,7 @@ export default function SearchableSelect({ className = "", interfaceId, helpContextId, + helpModuleId, helpTopicId }: SearchableSelectProps) { const { translateText } = usePlatformLanguage(); @@ -301,6 +302,7 @@ export default function SearchableSelect({ data-help-scope="field" data-interface-id={interfaceId} data-help-context-id={helpContextId} + data-help-module-id={helpModuleId} data-help-topic-id={helpTopicId} data-help-key={ariaLabel} onBlur={closeOnFocusLeave} diff --git a/webui/src/components/ToggleSwitch.tsx b/webui/src/components/ToggleSwitch.tsx index fb41b7e..77c0d5e 100644 --- a/webui/src/components/ToggleSwitch.tsx +++ b/webui/src/components/ToggleSwitch.tsx @@ -14,7 +14,7 @@ type ToggleSwitchProps = PlatformInterfaceIdentityProps & { help?: ReactNode; }; -export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help, interfaceId, helpContextId, helpTopicId }: ToggleSwitchProps) { +export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help, interfaceId, helpContextId, helpModuleId, helpTopicId }: ToggleSwitchProps) { const { translateText } = usePlatformLanguage(); const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined; const renderedLabel = typeof label === "string" ? translateText(label) : label; @@ -27,6 +27,7 @@ export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checke data-help-scope="field" data-interface-id={interfaceId} data-help-context-id={helpContextId} + data-help-module-id={helpModuleId} data-help-topic-id={helpTopicId} data-help-key={typeof label === "string" ? label : undefined} > diff --git a/webui/src/components/admin/AdminPageLayout.tsx b/webui/src/components/admin/AdminPageLayout.tsx index 7f06164..02c331e 100644 --- a/webui/src/components/admin/AdminPageLayout.tsx +++ b/webui/src/components/admin/AdminPageLayout.tsx @@ -29,6 +29,7 @@ export default function AdminPageLayout({ className = "", interfaceId, helpContextId, + helpModuleId, helpTopicId }: Props) { const { translateText } = usePlatformLanguage(); @@ -38,6 +39,7 @@ export default function AdminPageLayout({ data-help-scope="page" data-interface-id={interfaceId} data-help-context-id={helpContextId} + data-help-module-id={helpModuleId} data-help-topic-id={helpTopicId} data-help-documentation-type="admin" data-help-key={title} diff --git a/webui/src/components/email/EmailAddressInput.tsx b/webui/src/components/email/EmailAddressInput.tsx index 49409c2..725a499 100644 --- a/webui/src/components/email/EmailAddressInput.tsx +++ b/webui/src/components/email/EmailAddressInput.tsx @@ -47,6 +47,7 @@ export default function EmailAddressInput({ showAddButton, interfaceId, helpContextId, + helpModuleId, helpTopicId }: EmailAddressInputProps) { const { translateText } = usePlatformLanguage(); @@ -208,6 +209,7 @@ export default function EmailAddressInput({ data-help-scope="field" data-interface-id={interfaceId} data-help-context-id={helpContextId} + data-help-module-id={helpModuleId} data-help-topic-id={helpTopicId} data-help-key={emailPlaceholder} > diff --git a/webui/src/features/privacy/RetentionPolicyManagement.tsx b/webui/src/features/privacy/RetentionPolicyManagement.tsx index 4a0abc5..d8b5ed2 100644 --- a/webui/src/features/privacy/RetentionPolicyManagement.tsx +++ b/webui/src/features/privacy/RetentionPolicyManagement.tsx @@ -67,6 +67,7 @@ type DayKey = type FieldDefinition = { key: PrivacyRetentionPolicyFieldKey; label: string; + helpContextId: string; kind: "raw-json" | "days" | "audit"; systemOnly?: boolean; }; @@ -101,13 +102,13 @@ const defaultPrivacyPolicy: PrivacyRetentionPolicy = { }; const fieldDefinitions: FieldDefinition[] = [ -{ key: "store_raw_campaign_json", label: "i18n:govoplan-core.raw_campaign_json.4ca2265b", kind: "raw-json" }, -{ key: "raw_campaign_json_retention_days", label: "i18n:govoplan-core.raw_campaign_json_days.48805a59", kind: "days" }, -{ key: "generated_eml_retention_days", label: "i18n:govoplan-core.generated_eml_days.bcb273d9", kind: "days" }, -{ key: "stored_report_detail_retention_days", label: "i18n:govoplan-core.stored_report_detail_days.296f6dab", kind: "days" }, -{ key: "mock_mailbox_retention_days", label: "i18n:govoplan-core.mock_mailbox_days.a0cf3209", kind: "days", systemOnly: true }, -{ key: "audit_detail_retention_days", label: "i18n:govoplan-core.audit_detail_days.21c84dd1", kind: "days" }, -{ key: "audit_detail_level", label: "i18n:govoplan-core.audit_detail_level.b0565260", kind: "audit" }]; +{ key: "store_raw_campaign_json", label: "i18n:govoplan-core.raw_campaign_json.4ca2265b", helpContextId: "policy.retention.field.store-raw-campaign-json", kind: "raw-json" }, +{ key: "raw_campaign_json_retention_days", label: "i18n:govoplan-core.raw_campaign_json_days.48805a59", helpContextId: "policy.retention.field.raw-campaign-json-retention-days", kind: "days" }, +{ key: "generated_eml_retention_days", label: "i18n:govoplan-core.generated_eml_days.bcb273d9", helpContextId: "policy.retention.field.generated-eml-retention-days", kind: "days" }, +{ key: "stored_report_detail_retention_days", label: "i18n:govoplan-core.stored_report_detail_days.296f6dab", helpContextId: "policy.retention.field.stored-report-detail-retention-days", kind: "days" }, +{ key: "mock_mailbox_retention_days", label: "i18n:govoplan-core.mock_mailbox_days.a0cf3209", helpContextId: "policy.retention.field.mock-mailbox-retention-days", kind: "days", systemOnly: true }, +{ key: "audit_detail_retention_days", label: "i18n:govoplan-core.audit_detail_days.21c84dd1", helpContextId: "policy.retention.field.audit-detail-retention-days", kind: "days" }, +{ key: "audit_detail_level", label: "i18n:govoplan-core.audit_detail_level.b0565260", helpContextId: "policy.retention.field.audit-detail-level", kind: "audit" }]; export function RetentionPolicyScopeManager({ @@ -148,11 +149,13 @@ export function RetentionPolicyScopeManager({ {targetSelectionRequired && } >
- - setSelectedTargetId(event.target.value)}> {!hasSelectableTarget && } {targetOptions.map((option) => )} @@ -342,11 +345,13 @@ export function RetentionPolicyEditor({ return ( - - + +
}> @@ -401,6 +406,8 @@ export function RetentionPolicyEditor({ setAllowLowerLevelLimit(field.key, checked)} label="i18n:govoplan-core.allow_override.ffa6e9a0" /> : undefined} />); @@ -519,18 +526,19 @@ parentPolicy: PrivacyRetentionPolicy | null) { if (field.kind === "raw-json") { if (isSystem) { - return ; + return ; } - return ; + return ; } if (field.kind === "audit") { - return ; + return ; } const parentDayLimit = !isSystem && parentPolicy ? parentPolicy[field.key as DayKey] : null; return ( setRetentionDays(field.key as DayKey, value)} />); @@ -538,18 +546,18 @@ parentPolicy: PrivacyRetentionPolicy | null) } -function RawJsonSystemSelect({ value, disabled, onChange }: {value: "store" | "disabled";disabled: boolean;onChange: (value: "store" | "disabled") => void;}) { +function RawJsonSystemSelect({ value, disabled, helpContextId, onChange }: {value: "store" | "disabled";disabled: boolean;helpContextId: string;onChange: (value: "store" | "disabled") => void;}) { return ( - onChange(event.target.value as "store" | "disabled")}> ); } -function RawJsonScopedSelect({ value, parentStoresRawJson, disabled, onChange }: {value: RawJsonValue;parentStoresRawJson: boolean;disabled: boolean;onChange: (value: RawJsonValue) => void;}) { +function RawJsonScopedSelect({ value, parentStoresRawJson, disabled, helpContextId, onChange }: {value: RawJsonValue;parentStoresRawJson: boolean;disabled: boolean;helpContextId: string;onChange: (value: RawJsonValue) => void;}) { return ( - onChange(event.target.value as RawJsonValue)}> @@ -557,7 +565,7 @@ function RawJsonScopedSelect({ value, parentStoresRawJson, disabled, onChange }: } -function RetentionDaysField({ value, disabled, placeholder, max, onChange }: {value?: number | null;disabled: boolean;placeholder: string;max?: number | null;onChange: (value: string) => void;}) { +function RetentionDaysField({ value, disabled, helpContextId, placeholder, max, onChange }: {value?: number | null;disabled: boolean;helpContextId: string;placeholder: string;max?: number | null;onChange: (value: string) => void;}) { function handleChange(nextValue: string) { const trimmed = nextValue.trim(); if (trimmed === "" || max === null || max === undefined) { @@ -568,16 +576,16 @@ function RetentionDaysField({ value, disabled, placeholder, max, onChange }: {va onChange(Number.isFinite(parsed) && parsed > max ? String(max) : nextValue); } - return handleChange(event.target.value)} />; + return handleChange(event.target.value)} />; } -function AuditDetailSelect({ value, includeInherit, parentValue, disabled, onChange }: {value: AuditDetailValue;includeInherit: boolean;parentValue?: PrivacyRetentionPolicy["audit_detail_level"] | null;disabled: boolean;onChange: (value: AuditDetailValue) => void;}) { +function AuditDetailSelect({ value, includeInherit, parentValue, disabled, helpContextId, onChange }: {value: AuditDetailValue;includeInherit: boolean;parentValue?: PrivacyRetentionPolicy["audit_detail_level"] | null;disabled: boolean;helpContextId: string;onChange: (value: AuditDetailValue) => void;}) { function optionDisabled(option: PrivacyRetentionPolicy["audit_detail_level"]): boolean { return privacyRetentionAuditDetailOptionDisabled(option, parentValue); } return ( - onChange(event.target.value as AuditDetailValue)}> {includeInherit && } diff --git a/webui/src/types.ts b/webui/src/types.ts index 8a53fb3..2474696 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -426,6 +426,8 @@ export type PlatformInterfaceIdentityProps = { interfaceId?: string; /** Stable contextual-help identifier associated with the control. */ helpContextId?: string; + /** Documentation-owning module when it differs from the containing surface. */ + helpModuleId?: string; /** Optional stable documentation/help topic associated with the control. */ helpTopicId?: string; };