diff --git a/.changeset/26619-action-disabled-reason.md b/.changeset/26619-action-disabled-reason.md new file mode 100644 index 00000000000..aaef8cbc497 --- /dev/null +++ b/.changeset/26619-action-disabled-reason.md @@ -0,0 +1,29 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': minor +'@objectstack/platform-objects': minor +--- + +feat(spec): a greyed action says why — `disabledReason` beside `disabled` + +Clause-②: yes (widening) +Metadata-protocol: added + +`ActionSchema` gains one optional key, `disabledReason`: the sentence a greyed action shows to say why it is unavailable, beside the `disabled` predicate that greys it. An action that does not declare it parses exactly as before, and the console keeps its default reason, "Not available for this record". + +```ts +defineAction({ + name: 'submit_for_approval', + label: 'Submit for approval', + objectName: 'clm_contract', + disabled: "record.legal_review_status != 'approved'", + disabledReason: 'An approved legal review is required before the contract enters approval.', +}); +``` + +- **An `I18nLabel`.** A plain string or an inline locale map (`{ en, 'zh-CN' }`). A blank string is refused at parse: `''` or whitespace only. Omit the key to keep the default. The locale-map arm is the `InlineLocaleMapSchema` every `I18nLabel` takes, so a map is not checked for blank values. `disabledMessage`, `disabledTooltip`, `disabledHint` and `disabledText` are renamed onto it. +- **Translated with the copy keys.** The slot is `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an action with no `objectName`), beside `confirmText` and `successMessage`. `translateAction` overlays it, and `os i18n extract` (`@objectstack/cli`) scaffolds it for an action that declares one and seeds nothing for one that does not. +- **Display only.** It does not change what `disabled` evaluates to and is never read as a condition: `os validate` checks `disabled`, not `disabledReason`. It is not access control either. A call that reaches the server anyway is refused by the action's own rules (its body or hook, `requiredPermissions`), never by this text. +- **Studio.** The action editor's Placement section gains a "Disabled Reason" row under "Disabled", translated in `@objectstack/platform-objects` for zh-CN, ja-JP and es-ES. + +Nothing renders the key yet. The console's greyed-action surfaces (the record header, the record section bar, the declared actions bar and the four `action:*` renderers) read it in the follow-up change. Until then the liveness ledger marks it `planned` with `authorWarn`, so `os validate` warns an author who declares it that nothing shows it yet. diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 3da1cd14406..752921ecb81 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -245,6 +245,7 @@ interface Action { visible?: ExpressionInput; // Visibility predicate (CEL) requiresFeature?: string; // Public auth feature flag gate — lowered into `visible` at parse time disabled?: boolean | ExpressionInput; // Disabled when TRUE (CEL) + disabledReason?: I18nLabel; // Why it is greyed — hover title + accessible description (display only) // Bulk bulkEnabled?: boolean; // Apply to multiple selected records @@ -333,6 +334,21 @@ disabled: "record.status != 'paid'" Predicates are **bare CEL** — `record.status == 'paid'`, not `${record.status == 'paid'}` and not `{record.status} == 'paid'` (in CEL, `{…}` is a map literal). A `${…}`-wrapped or brace-wrapped predicate is not evaluated as a boolean. +#### Saying why: `disabledReason` + +A greyed action says **why** with `disabledReason`, the sentence beside the `disabled` predicate. It is meant for the button's hover title and its accessible description (a menu item, under its label). The console does not show it yet: until its greyed-action readers ship, every greyed action shows the console's default reason, "Not available for this record", and `os validate` warns an author who declares the key. + +```yaml +name: submit_for_approval +label: Submit for approval +disabled: "record.legal_review_status != 'approved'" +disabledReason: An approved legal review is required before the contract enters approval. +``` + +- It is an `I18nLabel`: a plain string, translated at `objects.._actions..disabledReason` (`globalActions..disabledReason` for an action with no `objectName`), or an inline locale map. `os i18n extract` scaffolds the key. +- A blank string (`''` or whitespace only) is refused at parse. Omit the key to keep the default. +- It is **display only**. It does not change what `disabled` evaluates to, nothing reads it as a condition, and it is not access control: a call that reaches the server anyway is refused by the action's own rules (its body or hook, `requiredPermissions`), never by this text. + #### Evaluation context Action predicates evaluate against the **record the action is attached to**. Both `record.` and the bare field name resolve to the current record's value: diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 5694cab1198..f5e77da2b99 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -499,6 +499,7 @@ const result = ApiMethod.parse(data); | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | +| **disabledReason** | `string \| Record` | optional | Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index dd7b44f62e5..6a233450284 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -346,6 +346,7 @@ const result = MetadataBulkResultSchema.parse(data); | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | +| **disabledReason** | `string \| Record` | optional | Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index a28db115d72..71981ff614c 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -128,6 +128,7 @@ Translation data for a single field | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -227,6 +228,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -384,6 +386,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -603,6 +606,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -791,6 +795,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index faf96b23b22..8954cc47ded 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -56,6 +56,7 @@ const result = ActionSchema.parse(data); | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| … +2 more>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| 'transfer_ownership' \| 'remove_member' \| 'create_team' \| 'update_team' \| 'remove_team' \| … +4 more>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | +| **disabledReason** | `string \| Record` | optional | Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 40c9233391a..56b18df8b84 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -72,7 +72,7 @@ export default defineStack({ | Picklist option labels | `objects..fields..options.` | | View titles, descriptions, empty states | `objects.._views.` | | Bulk-action copy on a list view (button, confirm prompt, dialog fields) | `objects.._views..bulkActions..label` / `.confirmText` / `.confirmLabel` / `.params..label` / `.help` / `.placeholder` — a bulk param's hint is `help`, not `helpText` | -| Action labels, confirm text, success messages | `objects.._actions.` | +| Action labels, confirm text, success messages, disabled reasons | `objects.._actions.` | | Action result dialogs (title / description / acknowledge / field labels) | `objects.._actions..resultDialog` | | Form sections | `objects.._sections.
` | | Custom validation-rule messages | `objects.._validations..message` | diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index ad6a953488e..887c3182bf2 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -84,12 +84,14 @@ * objects.._actions..description * objects.._actions..confirmText * objects.._actions..successMessage + * objects.._actions..disabledReason * objects.._actions..outcomeMessages. * objects.._actions..params..label / .helpText / .placeholder * objects.._actions..params..options. * objects.._actions..resultDialog.title / .description / .acknowledge * objects.._actions..resultDialog.fields. * globalActions..label / .description / .confirmText / .successMessage + * / .disabledReason * globalActions..outcomeMessages. * globalActions..params..* / .resultDialog.* (same shape as object actions) * apps..label / .description @@ -1452,6 +1454,7 @@ export function collectExpectedEntries( pushOptional(out, [...aroot, 'description'], action.description, 'action', { objectName }); pushOptional(out, [...aroot, 'confirmText'], actionCopy(action.confirmText), 'action', { objectName }); pushOptional(out, [...aroot, 'successMessage'], actionCopy(action.successMessage), 'action', { objectName }); + pushOptional(out, [...aroot, 'disabledReason'], action.disabledReason, 'action', { objectName }); pushActionOutcomeMessages(out, aroot, action, 'action', objectName); pushActionParams(out, ['objects', objectName, '_actions', aname], action, 'action', objectName); pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName); @@ -1545,6 +1548,10 @@ export function collectExpectedEntries( pushOptional(out, [...root, 'description'], action.description, kind, { objectName }); pushOptional(out, [...root, 'confirmText'], actionCopy(action.confirmText), kind, { objectName }); pushOptional(out, [...root, 'successMessage'], actionCopy(action.successMessage), kind, { objectName }); + // [#26619] OPTIONAL-not-derived like `description`: an action that gives no + // reason shows the console's own default sentence, so there is no gap to + // seed for it. + pushOptional(out, [...root, 'disabledReason'], action.disabledReason, kind, { objectName }); pushActionOutcomeMessages(out, root, action, kind, objectName); pushActionParams(out, root, action, kind, objectName); pushActionResultDialog(out, root, action, kind, objectName); diff --git a/packages/cli/test/i18n-extract-action-disabled-reason.test.ts b/packages/cli/test/i18n-extract-action-disabled-reason.test.ts new file mode 100644 index 00000000000..c8f34da7497 --- /dev/null +++ b/packages/cli/test/i18n-extract-action-disabled-reason.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#26619 — the extractor half of `action.disabledReason`. +// +// The ruling puts the reason's translation slot under +// `objects.OBJECT._actions.ACTION` beside `confirmText` / `successMessage`, so +// `os i18n extract` must scaffold it; without that a zh-CN workspace shows an +// English reason on a Chinese button with no diagnostic. The last test walks +// ONE fixture action through all three surfaces — `ActionSchema` accepts it, +// the extractor emits its key, the translation schema accepts the bundle +// written at that key — because each half passing alone does not prove the +// addresses agree. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage'; +import { ActionSchema } from '@objectstack/spec/ui'; +import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; + +const paths = (config: any) => collectExpectedEntries(config).map((e) => e.path.join('.')); + +const REASON = 'An approved legal review is required first.'; + +describe('the extractor emits action-level `disabledReason` (#26619)', () => { + it('emits it for a TOP-LEVEL object action that declares one', () => { + const keys = paths({ + actions: [{ name: 'submit_for_approval', objectName: 'clm_contract', label: 'Submit', disabledReason: REASON }], + }); + + expect(keys).toContain('objects.clm_contract._actions.submit_for_approval.disabledReason'); + }); + + it('emits it for a GLOBAL action that declares one', () => { + const keys = paths({ + actions: [{ name: 'leave_organization', label: 'Leave', disabledReason: 'You are the sole owner.' }], + }); + + expect(keys).toContain('globalActions.leave_organization.disabledReason'); + }); + + it('emits it for an action declared INLINE on the object', () => { + const keys = paths({ + objects: [ + { + name: 'clm_contract', + label: 'Contract', + actions: [{ name: 'submit_for_approval', label: 'Submit', disabledReason: REASON }], + }, + ], + }); + + expect(keys).toContain('objects.clm_contract._actions.submit_for_approval.disabledReason'); + }); + + it('seeds NOTHING for an action that declares none — the console has its own default reason', () => { + // Same shape as an undeclared `description`: `pushOptional` records a + // seed-less path, and nothing is gated as a translation gap. + const config = { + objects: [{ name: 'clm_contract', label: 'Contract', actions: [{ name: 'archive', label: 'Archive' }] }], + }; + const entries = collectExpectedEntries(config); + const root = 'objects.clm_contract._actions.archive'; + const at = (key: string) => entries.find((e) => e.path.join('.') === key); + + expect(at(`${root}.disabledReason`)?.inline).toBeUndefined(); + expect(at(`${root}.disabledReason`)?.sourceValue).toBeUndefined(); + + const { bundles } = extractTranslations(config, { defaultLocale: 'en', fill: 'default' }); + expect(bundles.en.objects?.clm_contract?._actions?.archive).not.toHaveProperty('disabledReason'); + expect(computeI18nCoverage({ ...config, translations: [{ 'zh-CN': {} }] }).issues.map((i) => i.key)) + .not.toContain(`${root}.disabledReason`); + }); + + it('carries the declared text into the bundle, and gates it until it is translated', () => { + const config: any = { + objects: [ + { + name: 'clm_contract', + label: 'Contract', + actions: [{ name: 'submit_for_approval', label: 'Submit', disabledReason: REASON }], + }, + ], + translations: [ + { 'zh-CN': { objects: { clm_contract: { label: '合同', _actions: { submit_for_approval: { label: '送审' } } } } } }, + ], + }; + const key = 'objects.clm_contract._actions.submit_for_approval.disabledReason'; + + const entry = collectExpectedEntries(config).find((e) => e.path.join('.') === key); + expect(entry?.source).toBe('action'); + expect(entry?.inline).toBe(REASON); + + const { bundles } = extractTranslations(config, { defaultLocale: 'en', fill: 'default' }); + expect(bundles.en.objects?.clm_contract?._actions?.submit_for_approval.disabledReason).toBe(REASON); + + expect(computeI18nCoverage(config).issues.map((i) => i.key)).toContain(key); + config.translations[0]['zh-CN'].objects.clm_contract._actions.submit_for_approval.disabledReason = + '需要一条通过的法务审查后才能送审。'; + expect(computeI18nCoverage(config).issues.filter((i) => i.source !== 'metadataForm')).toEqual([]); + }); +}); + +describe('one fixture action, all three surfaces (#26619 end-to-end)', () => { + const authored = { + name: 'submit_for_approval', + objectName: 'clm_contract', + label: 'Submit for approval', + target: 'submitForApproval', + disabled: "record.legal_review_status != 'approved'", + disabledReason: REASON, + }; + + it('parses as metadata, extracts to a key, and that key round-trips a bundle', () => { + const parsed = ActionSchema.safeParse(authored); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.disabledReason).toBe(REASON); + + const { bundles } = extractTranslations({ actions: [authored] }, { + defaultLocale: 'en', + locales: ['zh-CN'], + fill: 'empty', + }); + const bundle: any = bundles['zh-CN']; + expect(bundle.objects.clm_contract._actions.submit_for_approval).toHaveProperty('disabledReason'); + + bundle.objects.clm_contract._actions.submit_for_approval.disabledReason = '需要一条通过的法务审查后才能送审。'; + const translated = ObjectTranslationDataSchema.safeParse(bundle.objects.clm_contract); + expect(translated.success).toBe(true); + expect(translated.success && translated.data._actions?.submit_for_approval?.disabledReason) + .toBe('需要一条通过的法务审查后才能送审。'); + + expect(paths({ actions: [authored] })).toContain('objects.clm_contract._actions.submit_for_approval.disabledReason'); + }); +}); diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 6c201e12679..e0e128b89d4 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -2314,3 +2314,27 @@ describe('report period and export (#26548 — planned until the report renderer expect(lintLivenessProperties(report())).toEqual([]); }); }); + +// ── #26619: a greyed action's reason is declared before the console reads it ── +// +// `disabledReason` is `planned` + `authorWarn` until the console's greyed-action +// readers show it. Read off the SHIPPED ledger, so the console PR's flip to +// `live` (which drops `authorWarn`) turns the warning half of this block red +// on purpose — rewrite it as a silence pin in that same edit. +describe('action disabled reason (#26619 — planned until the console reads it)', () => { + const action = (extra: Record = {}) => ({ + actions: [{ name: 'submit_for_approval', label: 'Submit for approval', objectName: 'clm_contract', disabled: "record.legal_review_status != 'approved'", ...extra }], + }); + + it('warns on the key, at the action, with the row\'s author hint', () => { + const findings = lintLivenessProperties(action({ disabledReason: 'An approved legal review is required first.' })); + expect(ruleOf(findings, 'disabledReason')).toBe('liveness-planned-property'); + const f = findings.find((x) => x.rule === 'liveness-planned-property')!; + expect(f.where).toContain('submit_for_approval'); + expect(f.hint).toMatch(/^Keep it, but nothing reads it yet: no console shows a greyed action's `disabledReason`/); + }); + + it('CONTROL — an action declaring no reason draws no warning', () => { + expect(lintLivenessProperties(action())).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 2a852ba58dd..de22f851dd1 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -926,6 +926,21 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { expect(ok).toHaveLength(0); }); + it('never reads `disabledReason` as a predicate — it is display copy (#26619)', () => { + // The same bare-field source that is an error on `disabled` above is a + // plain sentence on `disabledReason`: nothing is checked, nothing flagged. + const issues = validateStackExpressions({ + objects: [{ name: 'crm_lead', fields: { status: { type: 'select' } } }], + actions: [{ + name: 'park', + objectName: 'crm_lead', + disabled: 'record.status == "converted"', + disabledReason: 'status == "converted"', + }], + }); + expect(issues).toHaveLength(0); + }); + it('validates an action attached to an object (record scope = parent object)', () => { const issues = validateStackExpressions({ objects: [{ @@ -3372,6 +3387,8 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s { receiver: 'action', // `object` is absent — the action schema's canonical key is `objectName`. + // `disabledReason` is absent by contract (#26619): display copy beside + // `disabled`, never read as a condition. expected: ['disabled', 'name', 'objectName', 'visible'], declaredBy: 'ObjectStackSchema.actions[]', keys: () => shapeKeysOf(ObjectStackSchema.shape.actions), diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 686ce4300f2..837615236d7 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -3947,6 +3947,7 @@ describe('validateTranslationReferences — keyed children of an action entry (# description: { kind: 'leaf-copy', why: 'prose' }, confirmText: { kind: 'leaf-copy', why: 'prose' }, successMessage: { kind: 'leaf-copy', why: 'prose' }, + disabledReason: { kind: 'leaf-copy', why: 'prose' }, outcomeMessages: { kind: 'reference-checked', host: 'outcome', diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 7d96c4db3e8..a3540c8fe1d 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -2437,8 +2437,8 @@ interface ActionEntryContext { * dialog that declares none — so declaring the dialog is the whole condition. * * The leaf keys beside them (`label`, `description`, `confirmText`, - * `successMessage`) are prose on a node that resolved, read whenever the - * action is, as on the object branch. + * `successMessage`, `disabledReason`) are prose on a node that resolved, read + * whenever the action is, as on the object branch. * * ⛔ One vocabulary: a key naming a target the action does not declare is * `translation-target-unknown` at `params`' severity; an OPTION key keyed off diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index b08b655c826..fefd90cba4c 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -253,10 +253,12 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no // major it retired in — at 18 they left the declared set with their // tombstones. The declared total shrinks by exactly those three; the // live sample below still holds every live key. - expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(47); + // [#26619] 47 → 48: `disabledReason` (why a greyed action is + // unavailable) joined the accepted set, and the sample names it. + expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(48); // A sample an author would actually address, and the one #17500's // repeater titles need a node to sit on. - for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages', 'requiresMembershipReach']) { + for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages', 'requiresMembershipReach', 'disabledReason']) { expect(Object.keys(properties)).toContain(key); } }); diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 873f050eeab..2c4187f2884 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1843,6 +1843,10 @@ export const enMetadataForms: NonNullable = { label: "Disabled", helpText: "CEL expression: disable when condition is true" }, + disabledReason: { + label: "Disabled Reason", + helpText: "Why the action is greyed, shown on hover and read by screen readers while Disabled is true (e.g. \"An approved legal review is required first.\"). Display only: the server still refuses the call on its own rules. Leave empty to show \"Not available for this record\"." + }, requiresFeature: { label: "Requires Feature", helpText: "Public auth feature flag gating this action. It is lowered into the `visible` predicate at parse time and stripped from the output, so no downstream consumer ever sees the key." diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index c924614ffe5..0a1eeb3ceb7 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1843,6 +1843,10 @@ export const esESMetadataForms: NonNullable = label: "Deshabilitado", helpText: "Expresión CEL: desactivar cuando la condición sea true" }, + disabledReason: { + label: "Motivo de deshabilitado", + helpText: "Por qué la acción aparece atenuada; se muestra al pasar el cursor y lo leen los lectores de pantalla mientras Deshabilitado es true (p. ej., \"Primero se requiere una revisión legal aprobada.\"). Solo de presentación: el servidor sigue rechazando la llamada según sus propias reglas. Déjalo vacío para mostrar \"No disponible para este registro\"." + }, requiresFeature: { label: "Requiere función", helpText: "Indicador público de función de autenticación que condiciona esta acción. Se traslada al predicado `visible` durante el análisis y se elimina de la salida, así que ningún consumidor posterior llega a ver la clave." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 364af011c59..e7fe0665702 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1843,6 +1843,10 @@ export const jaJPMetadataForms: NonNullable = label: "無効", helpText: "CEL 式: 条件が true の場合に無効化" }, + disabledReason: { + label: "無効の理由", + helpText: "アクションがグレーアウトされている理由。「無効」が true の間、ホバー時に表示され、スクリーンリーダーにも読み上げられます(例:「承認済みの法務レビューが先に必要です。」)。表示専用で、サーバーは引き続き独自のルールで呼び出しを拒否します。空欄の場合は「このレコードでは利用できません」と表示されます。" + }, requiresFeature: { label: "必要な認証機能", helpText: "このアクションの表示可否を決める公開認証機能フラグ。解析時に `visible` の述語へ畳み込まれ、出力からは取り除かれるため、下流の利用側がこのキーを見ることはありません。" diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 7501168615c..4ff100e4b22 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1228,7 +1228,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // row labels, authored in all three locales. // 688 since ADR-0142: `queue` and `assignment_rule` joined the registry, // each with its type-level display pair, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(688); + // 689 since #26619: the action form's `disabledReason` row (why a greyed + // action is unavailable) — one row label, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(689); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 6917511b34d..cc7e5b31c6c 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1843,6 +1843,10 @@ export const zhCNMetadataForms: NonNullable = label: "禁用", helpText: "CEL 表达式:满足条件时禁用" }, + disabledReason: { + label: "禁用原因", + helpText: "动作置灰的原因,在“禁用”为 true 时悬停显示,并由屏幕阅读器读出(例如“需要一条通过的法务审查后才能送审。”)。仅用于展示:服务端仍按自身规则拒绝调用。留空则显示“对此记录不可用”。" + }, requiresFeature: { label: "所需认证特性", helpText: "用于控制该动作是否出现的公共认证特性开关。它在解析时被降解进 `visible` 断言并从输出中移除,因此下游消费方永远看不到这个键。" diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 76e4b088ddb..fbba5ebb228 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -18,6 +18,7 @@ "ui/Action:confirmText", "ui/Action:description", "ui/Action:disabled", + "ui/Action:disabledReason", "ui/Action:errorMessage", "ui/Action:execution", "ui/Action:icon", diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 70b6a516ea9..99cf4515203 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -151,6 +151,14 @@ "evidence": "objectui: components action-button/-group/-icon/-menu + app-shell DeclaredActionsBar + plugin-detail record-quick-actions — all six rendering surfaces gate the Button on the predicate", "note": "EVIDENCE CORRECTED 2026-07 (#1878 §3 recheck): previously cited the metadata-admin PREVIEW renderer, which only echoes the authored value. At that time the predicate was actually enforced on ONE of six surfaces (#1885 wired action:button alone) — the 'live' verdict was right for the wrong reason and hid a real silent no-op on the other five. objectui#2863 wired the rest; showcase specimen `showcase_archive_task` (#3643) dogfoods it." }, + "disabledReason": { + "status": "planned", + "verifiedAt": "2026-10-27", + "authorWarn": true, + "authorHint": "Keep it, but nothing reads it yet: no console shows a greyed action's `disabledReason`, so the button still says \"Not available for this record\" until the console's greyed-action readers ship.", + "evidence": "packages/spec/src/system/i18n-resolver.ts#translateAction overlays the translated `objects.{o}._actions.{a}.disabledReason` (or `globalActions.{a}.disabledReason`) onto the served action; packages/cli/src/utils/i18n-extract.ts#collectExpectedEntries emits the key and packages/spec/src/system/translation.zod.ts declares the translation slot.", + "note": "PLANNED until the console half of #26619 lands (ruling 6442737825, letter A: spec PR first, console PR second). The framework half is live today — parse, blank refusal, translation slot, extract, translateAction overlay — but no renderer reads the key yet: the four greyed-action readers (ui/packages/components/src/renderers/layout/containers.tsx#disabledReasonFor, ui/packages/components/src/renderers/action/disabled-reason.tsx#useDisabledReason, ui/packages/plugin-detail/src/renderers/record-quick-actions.tsx, ui/packages/app-shell/src/views/DeclaredActionsBar.tsx) still show only `actions.notAvailableForRecord`. `authorWarn` is set because an author who writes the key today sees the default sentence, not theirs. The console PR flips this row to `live` with those read points, and drops `authorWarn` / `authorHint` in that same edit (a warned `live` row reds this gate, #21127). Display only by contract: never a server admission input, never read as a predicate." + }, "ai": { "status": "live", "verifiedAt": "2026-09-15", diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index d70333646f0..485c04b2451 100644 --- a/packages/spec/liveness/state-counts/action.md +++ b/packages/spec/liveness/state-counts/action.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `action` | 47 | 0 | 0 | 1 | 0 | 48 | +| `action` | 47 | 0 | 0 | 1 | 1 | 49 | diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 4e31a0be543..01a0ea1bd25 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -15,6 +15,7 @@ * objects.._actions..description * objects.._actions..confirmText * objects.._actions..successMessage + * objects.._actions..disabledReason * objects.._actions..outcomeMessages. * objects.._actions..params..label * objects.._actions..params..helpText @@ -46,8 +47,8 @@ * object-less action (no `objectName`) reads only: * * globalActions..label / .description / .confirmText / - * .successMessage / .outcomeMessages. / .params..* / - * .resultDialog.* + * .successMessage / .disabledReason / .outcomeMessages. / + * .params..* / .resultDialog.* * * That is the declaration `TranslationDataSchema.globalActions` makes: global * translations are for object-less actions. A `globalActions` entry that names @@ -222,6 +223,11 @@ export interface ActionLike { */ confirmText?: string | { dialect: 'template'; source: string }; successMessage?: string | { dialect: 'template'; source: string }; + /** + * `ActionSchema.disabledReason` (#26619) — the display-only reason a greyed + * action shows. Narrowed to `string` for the same reason `description` is. + */ + disabledReason?: string; /** * `ActionSchema.outcomeMessages` — success copy per handler `outcome`, * narrowed to `string` values for the same reason `successMessage` is. @@ -648,7 +654,7 @@ function actionTranslationNode( function lookupActionField( bundle: TranslationBundle | undefined, action: ActionLike, - field: 'label' | 'description' | 'confirmText' | 'successMessage', + field: 'label' | 'description' | 'confirmText' | 'successMessage' | 'disabledReason', opts?: ResolveOptions, ): string | undefined { if (!bundle) return undefined; @@ -1427,9 +1433,9 @@ export function translateView( /** * Apply the active locale to an action metadata document by overwriting - * `label`, `description`, `confirmText`, `successMessage`, each declared - * `outcomeMessages` entry, the `params[]` copy and the `resultDialog` copy with - * translated values when available. The + * `label`, `description`, `confirmText`, `successMessage`, `disabledReason`, + * each declared `outcomeMessages` entry, the `params[]` copy and the + * `resultDialog` copy with translated values when available. The * original document is not mutated; a shallow copy is returned. * * ## Why `description` and `params` are overlaid HERE @@ -1458,6 +1464,9 @@ export function translateAction( const description = lookupActionField(bundle, action, 'description', opts) ?? action.description; const confirmText = resolveActionConfirm(bundle, action, opts); const successMessage = resolveActionSuccess(bundle, action, opts); + // [#26619] Display-only copy, overlaid exactly like `description`: the + // bundle's sentence when there is one, else the authored value as written. + const disabledReason = lookupActionField(bundle, action, 'disabledReason', opts) ?? action.disabledReason; const outcomeMessages = resolveActionOutcomeMessages(bundle, action, opts); const resultDialog = resolveActionResultDialog(bundle, action, opts); const params = translateActionParams(action, bundle, opts); @@ -1467,6 +1476,7 @@ export function translateAction( ...(description !== undefined ? { description } : {}), ...(confirmText !== undefined ? { confirmText } : {}), ...(successMessage !== undefined ? { successMessage } : {}), + ...(disabledReason !== undefined ? { disabledReason } : {}), ...(outcomeMessages !== action.outcomeMessages ? { outcomeMessages } : {}), ...(resultDialog !== undefined ? { resultDialog } : {}), ...(params !== action.params ? { params } : {}), diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index d8bd010ad95..89c0e272fe9 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -192,6 +192,8 @@ const actionTranslationSchema = (surface: string) => strictObject({ name: 'label', title: 'label', confirm: 'confirmText', confirmation: 'confirmText', confirmMessage: 'confirmText', success: 'successMessage', successText: 'successMessage', toast: 'successMessage', + disabledMessage: 'disabledReason', disabledTooltip: 'disabledReason', + disabledHint: 'disabledReason', disabledText: 'disabledReason', parameters: 'params', args: 'params', inputs: 'params', result: 'resultDialog', dialog: 'resultDialog', }, @@ -206,6 +208,9 @@ const actionTranslationSchema = (surface: string) => strictObject({ description: z.string().optional().describe('Translated action description — the explanatory line under the title in the action\'s param dialog'), confirmText: z.string().optional().describe('Translated confirmation prompt'), successMessage: z.string().optional().describe('Translated success toast/message'), + // `ActionSchema.disabledReason` (#26619) — the display-only reason a greyed + // action shows; overlaid by `translateAction` like the copy keys above. + disabledReason: z.string().optional().describe('Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet)'), // `ActionSchema.outcomeMessages` (#21095) — one translated message per // declared outcome, keyed by the same snake_case outcome name, so a bundle // key that could never match a handler's `outcome` is refused here rather @@ -442,6 +447,7 @@ export const ObjectTranslationDataSchema = lazySchema(() => strictObject({ * objects.._actions..description * objects.._actions..confirmText * objects.._actions..successMessage + * objects.._actions..disabledReason * objects.._actions..outcomeMessages. * objects.._actions..params..label * objects.._actions..params..helpText @@ -958,6 +964,7 @@ const appTranslationDataShape = () => ({ * globalActions..description * globalActions..confirmText * globalActions..successMessage + * globalActions..disabledReason * globalActions..outcomeMessages. * globalActions..params..label * globalActions..params..helpText diff --git a/packages/spec/src/ui/action-disabled-reason.test.ts b/packages/spec/src/ui/action-disabled-reason.test.ts new file mode 100644 index 00000000000..f4692dc1feb --- /dev/null +++ b/packages/spec/src/ui/action-disabled-reason.test.ts @@ -0,0 +1,197 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#26619 — `disabledReason` on the action contract (ruling +// 6442737825, letter A). +// +// A greyed action could only say "Not available for this record". The ruled +// key is the sentence that says WHY, beside the `disabled` predicate that greys +// it: an I18nLabel, translated under `objects.OBJECT._actions.ACTION` with the +// copy keys, display only. These pins hold the four things the ruling names: +// the parse (both I18nLabel forms), the blank refusal, the translation slot and +// its overlay, and that the key is a sentence, never a condition. The blank +// refusal reaches the published JSON Schema too: it is `NON_BLANK_STRING`, and +// the build's dropped-refinement ledger refuses a refinement that does not +// project. The extract +// half is pinned in `packages/cli/test/i18n-extract-action-disabled-reason.test.ts` +// and the lint half in `packages/lint/src/validate-expressions.test.ts`. + +import { describe, it, expect } from 'vitest'; +import { ActionSchema, InlineActionSchema } from './action.zod'; +import { ObjectTranslationDataSchema, TranslationDataSchema, type TranslationBundle } from '../system/translation.zod'; +import { translateAction } from '../system/i18n-resolver'; + +/** + * Minimum legal registered action, greyed by a predicate. `type` defaults to + * `script`, which needs a dispatch `target`. + */ +const base = { + name: 'submit_for_approval', + label: 'Submit for approval', + objectName: 'clm_contract', + target: 'submitForApproval', + disabled: "record.legal_review_status != 'approved'", +} as const; + +const REASON = 'An approved legal review is required before the contract enters approval.'; + +describe('ActionSchema.disabledReason — the parse', () => { + it('accepts a plain string and keeps it verbatim', () => { + const result = ActionSchema.safeParse({ ...base, disabledReason: REASON }); + + expect(result.success).toBe(true); + expect(result.success && result.data.disabledReason).toBe(REASON); + }); + + it('accepts the inline locale map, as every I18nLabel does', () => { + const localized = { en: REASON, 'zh-CN': '需要一条通过的法务审查后才能送审。' }; + const result = ActionSchema.safeParse({ ...base, disabledReason: localized }); + + expect(result.success).toBe(true); + expect(result.success && result.data.disabledReason).toEqual(localized); + }); + + it('is optional — an action without it parses, and the console keeps its default reason', () => { + const result = ActionSchema.safeParse(base); + + expect(result.success).toBe(true); + expect(result.success && 'disabledReason' in result.data).toBe(false); + }); + + it('is a sentence, never a condition — a predicate-looking value is not normalized', () => { + // `disabled` lowers a CEL string into its `{ dialect: 'cel' }` envelope at + // parse; `disabledReason` must come out as the same string it went in as, + // or something downstream would be invited to evaluate it. + const predicateLooking = "record.legal_review_status != 'approved'"; + const result = ActionSchema.safeParse({ ...base, disabledReason: predicateLooking }); + + expect(result.success).toBe(true); + expect(result.success && result.data.disabledReason).toBe(predicateLooking); + expect(result.success && result.data.disabled).toEqual( + expect.objectContaining({ dialect: 'cel', source: predicateLooking }), + ); + }); + + it('refuses every non-I18nLabel shape the `label` key refuses', () => { + for (const value of [42, null, ['a list'], { en: 7 }, { key: 'actions.reason' }] as unknown[]) { + const asLabel = ActionSchema.safeParse({ ...base, label: value }).success; + const asReason = ActionSchema.safeParse({ ...base, disabledReason: value }).success; + + expect({ value, asLabel, asReason }).toEqual({ value, asLabel: false, asReason: false }); + } + }); +}); + +describe('ActionSchema.disabledReason — the blank refusal', () => { + const refusal = (disabledReason: unknown) => { + const result = ActionSchema.safeParse({ ...base, disabledReason }); + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + expect(issue?.path).toEqual(['disabledReason']); + return issue?.message ?? ''; + }; + + it('refuses the empty string, naming the default it would have replaced', () => { + const message = refusal(''); + expect(message).toMatch(/^a blank `disabledReason` would replace the console's default reason/); + expect(message).toContain('omit the key to keep the default'); + }); + + it('refuses a whitespace-only string — it renders exactly as blank', () => { + expect(refusal(' ')).toMatch(/^a blank `disabledReason`/); + }); +}); + +describe('ActionSchema.disabledReason — the shape around it', () => { + it('renames the near-miss spellings onto the key', () => { + for (const nearMiss of ['disabledMessage', 'disabledTooltip', 'disabledHint', 'disabledText']) { + const result = ActionSchema.safeParse({ ...base, [nearMiss]: REASON }); + + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain(`\`${nearMiss}\` → \`disabledReason\``); + } + }); + + it('is not picked onto the INLINE action — that surface has no `disabled` to explain', () => { + const result = InlineActionSchema.safeParse({ + type: 'url', + target: '/docs', + label: 'Docs', + disabledReason: REASON, + }); + + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain('disabledReason'); + }); +}); + +describe('the translation slot and its overlay', () => { + it('accepts the key at the object-scoped address, beside confirmText / successMessage', () => { + const result = ObjectTranslationDataSchema.safeParse({ + _actions: { + submit_for_approval: { label: '送审', confirmText: '确认送审?', disabledReason: '需要一条通过的法务审查后才能送审。' }, + }, + }); + + expect(result.success).toBe(true); + expect(result.success && result.data._actions?.submit_for_approval?.disabledReason) + .toBe('需要一条通过的法务审查后才能送审。'); + }); + + it('accepts it at the globalActions address an object-less action reads', () => { + const result = TranslationDataSchema.safeParse({ + globalActions: { leave_organization: { disabledReason: '你是唯一的所有者,不能退出组织。' } }, + }); + + expect(result.success).toBe(true); + expect(result.success && result.data.globalActions?.leave_organization?.disabledReason) + .toBe('你是唯一的所有者,不能退出组织。'); + }); + + it('renames the same near-misses inside a bundle', () => { + const result = ObjectTranslationDataSchema.safeParse({ + _actions: { submit_for_approval: { disabledTooltip: '需要法务审查。' } }, + }); + + expect(result.success).toBe(false); + expect(result.success ? undefined : result.error.issues[0]?.message) + .toContain('`disabledTooltip` → `disabledReason`'); + }); + + const bundle: TranslationBundle = { + 'zh-CN': { + objects: { + clm_contract: { + _actions: { submit_for_approval: { disabledReason: '需要一条通过的法务审查后才能送审。' } }, + }, + }, + globalActions: { + leave_organization: { disabledReason: '你是唯一的所有者,不能退出组织。' }, + }, + }, + }; + const action = { name: 'submit_for_approval', label: 'Submit', objectName: 'clm_contract', disabledReason: REASON }; + + it('translateAction overlays the translated reason for the requested locale', () => { + expect(translateAction(action, bundle, { locale: 'zh-CN' }).disabledReason) + .toBe('需要一条通过的法务审查后才能送审。'); + }); + + it('reads an object-less action at globalActions', () => { + const leave = { name: 'leave_organization', label: 'Leave', disabledReason: 'You are the sole owner.' }; + expect(translateAction(leave, bundle, { locale: 'zh-CN' }).disabledReason) + .toBe('你是唯一的所有者,不能退出组织。'); + }); + + it('falls back to the authored reason when the locale has none', () => { + expect(translateAction(action, bundle, { locale: 'ja-JP' }).disabledReason).toBe(REASON); + }); + + it('adds no key to an action that declares none and has no translation', () => { + const bare = { name: 'archive', label: 'Archive', objectName: 'clm_contract' }; + expect('disabledReason' in translateAction(bare, bundle, { locale: 'zh-CN' })).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/action.form.ts b/packages/spec/src/ui/action.form.ts index 97125cc2039..2057710da5b 100644 --- a/packages/spec/src/ui/action.form.ts +++ b/packages/spec/src/ui/action.form.ts @@ -163,6 +163,9 @@ export const actionForm = defineForm({ { field: 'component', helpText: 'How to render (button, icon, menu item)' }, { field: 'visible', widget: 'textarea', helpText: 'CEL expression: show only when condition is true' }, { field: 'disabled', widget: 'textarea', helpText: 'CEL expression: disable when condition is true' }, + // #26619 — an I18nLabel, the node `errorMessage` carries, so it copies + // that plain row and the renderer derives the same face. + { field: 'disabledReason', helpText: 'Why the action is greyed, shown on hover and read by screen readers while Disabled is true (e.g. "An approved legal review is required first."). Display only: the server still refuses the call on its own rules. Leave empty to show "Not available for this record".' }, // The enum derives: every member IS the public auth feature flag it // names, so an inline list would only restate them and could drift from // the registry the parse step resolves against. diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 245acf8f37c..eee6d0b25d5 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -23,7 +23,8 @@ import { BulkActionExecutionSchema } from './bulk-action.zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema, ExpressionSchema } from '../shared/expression.zod'; import { evaluatedExpressionUnionRefusal } from '../shared/evaluated-slot-union'; -import { I18nLabelSchema } from './i18n.zod'; +import { I18nLabelSchema, InlineLocaleMapSchema } from './i18n.zod'; +import { NON_BLANK_STRING } from '../shared/refinement-projection'; import { ACTION_CONFIRM_TEXT_TEMPLATE_DESCRIBE, ACTION_SUCCESS_MESSAGE_TEMPLATE_DESCRIBE, @@ -887,6 +888,37 @@ const ActionConditionInputSchema = z.union([z.boolean(), EvaluatedExpressionInpu */ const actionCopy = (key: 'confirmText' | 'successMessage') => actionCopyTemplateSlot(templateExpressionInput(ExpressionSchema, actionCopyRefusals(key, { localeMap: true })), key, { localeMap: true }); +/** + * [#26619] `disabledReason` — the sentence a greyed action shows to say WHY it + * is unavailable, beside the `disabled` predicate that greys it. + * + * An I18nLabel (a plain string translated at + * `objects.OBJECT._actions.ACTION.disabledReason`, or an inline locale map), + * so it takes the copy keys' translation route and not a predicate's. It is + * DISPLAY ONLY, and both halves of that are the contract: + * + * - it never enters the server's admission — a disabled action that is called + * anyway is refused by its own rules (a hook, `requiredPermissions`), never + * by this text, so a wrong reason misleads a reader and leaks nothing; + * - it is not an expression — no lint or evaluator reads it as a condition, + * and a predicate-looking value is just a sentence. + * + * A blank string is refused: it would replace the console's default, "Not + * available for this record", with an empty tooltip. Omitting the key is how + * an author keeps that default. The rule is {@link NON_BLANK_STRING}, so the + * published JSON Schema carries it too (`minLength` + a `\S` pattern) rather + * than accepting a document the parse refuses. The locale-map arm is + * `InlineLocaleMapSchema` unchanged — the I18nLabel every other copy key takes. + */ +const DISABLED_REASON_BLANK = + 'a blank `disabledReason` would replace the console\'s default reason, "Not available for this ' + + 'record", with an empty tooltip. Write the sentence that says what is missing (e.g. "An ' + + 'approved legal review is required first."), or omit the key to keep the default.'; +const actionDisabledReason = () => z.union([ + z.string().refine(NON_BLANK_STRING, { message: DISABLED_REASON_BLANK }), + InlineLocaleMapSchema, +]); + /** * The object half of {@link ActionSchema}, before its refinements. * @@ -916,6 +948,11 @@ const actionObject = () => strictObject({ success: 'successMessage', successText: 'successMessage', toast: 'successMessage', visibleWhen: 'visible', showWhen: 'visible', disabledWhen: 'disabled', + // [#26619] The words an author reaches for when naming the greyed + // action's explanation by the surface it lands on (a tooltip, a hint) or + // by the copy keys' `*Message` family. + disabledMessage: 'disabledReason', disabledTooltip: 'disabledReason', + disabledHint: 'disabledReason', disabledText: 'disabledReason', style: 'variant', color: 'variant', appearance: 'variant', placement: 'locations', location: 'locations', position: 'locations', verb: 'method', httpMethod: 'method', @@ -1542,6 +1579,11 @@ const actionObject = () => strictObject({ * gone entirely. */ disabled: ActionConditionInputSchema.optional().describe('Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled.'), + /** + * Why the action is greyed — the reason `disabled` gives the user. Display + * only; the contract is on {@link actionDisabledReason}. + */ + disabledReason: actionDisabledReason().optional().describe('Display-only reason for a greyed action, for its hover title and accessible description while `disabled` evaluates TRUE (declared ahead of its console reader: no renderer shows it yet) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused.'), /** * [ADR-0066 D4] System capabilities required to INVOKE this action — a