diff --git a/.changeset/26619-console-disabled-reason.md b/.changeset/26619-console-disabled-reason.md new file mode 100644 index 00000000000..c4d742c77ab --- /dev/null +++ b/.changeset/26619-console-disabled-reason.md @@ -0,0 +1,31 @@ +--- +'@objectstack/spec': patch +'@object-ui/types': patch +'@object-ui/i18n': patch +'@object-ui/react': patch +'@object-ui/components': patch +'@object-ui/plugin-detail': patch +'@object-ui/app-shell': patch +--- + +feat(console): a greyed action says the reason its author declared + +Clause-②: no +Metadata-protocol: none + +An action greyed out by its `disabled` predicate now shows its declared `disabledReason` as the button's hover title and accessible description, and as a menu item's second line. The console used to show "Not available for this record" for every such action. That sentence stays the default when the action declares no reason, or a blank one. + +```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.', +}); +``` + +- **Where it shows.** The record header (inline and in the ⋯ overflow), the record section bar, the declared actions bar, and the four `action:*` renderers (`action:button`, `action:icon`, `action:group`, `action:menu`). On every one, only the declared `disabled` predicate evaluated true earns a reason. A button greyed while its own action runs, or by the host, still gives none. +- **Translated like the other action copy.** `useActionTextLocalizer` (`@object-ui/react`) resolves the key when the action declares it, through the new `actionDisabledReason` resolver on `useObjectLabel` (`@object-ui/i18n`). The bundle's sentence at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an action with no `objectName`) comes first. After it comes the authored inline locale map, collapsed to the active language, then the authored string. A bundle entry never gives a reason to an action whose author wrote none. The four `action:*` renderers read the action as authored, so for them the inline locale map is the translation. +- **Typed.** `UIActionSchema` (`@object-ui/types`) declares `disabledReason`, derived from the spec's `ActionSchema`. +- **Liveness.** `disabledReason` is now `live` in `@objectstack/spec`'s ledger, with the console readers as evidence. The planned-property warning an author got for declaring it is gone. The key's description no longer says that nothing shows it. diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 752921ecb81..1ac1b3db471 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -336,7 +336,7 @@ Predicates are **bare CEL** — `record.status == 'paid'`, not `${record.status #### 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. +A greyed action says **why** with `disabledReason`, the sentence beside the `disabled` predicate. The console shows it while the predicate is true, as the button's hover title and its accessible description (a menu item shows it under its label). Without it the reason is the console's default, "Not available for this record". ```yaml name: submit_for_approval diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index af0165fe7be..44d71f0993d 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -499,7 +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. | +| **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while `disabled` evaluates TRUE — 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 6a233450284..03e44741552 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -346,7 +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. | +| **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while `disabled` evaluates TRUE — 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 beafb946cec..59342d0a068 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -128,7 +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) | +| **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | | **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 | @@ -228,7 +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) | +| **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | | **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 | @@ -386,7 +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) | +| **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | | **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 | @@ -606,7 +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) | +| **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | | **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 | @@ -795,7 +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) | +| **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | | **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 8954cc47ded..f7f888ed5a2 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -56,7 +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. | +| **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while `disabled` evaluates TRUE — 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/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index bba5583813e..ee06658e88d 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -2318,26 +2318,30 @@ describe('report period and export (#26548 — planned until the report renderer }); }); -// ── #26619: a greyed action's reason is declared before the console reads it ── +// ── #26619: a greyed action's reason, declared spec-first, now read by the console ── // -// `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)', () => { +// `disabledReason` shipped `planned` + `authorWarn` with the spec half (#26786) +// and is `live` since the console half: the greyed-action readers show it. The +// warning pin turned red at that flip ON PURPOSE and is re-subjected to the +// silence — a `live` row never warns. The key is authored beside a property +// that still warns (`object.externalSharingModel`, `dead`) in the SAME call: +// one ledger load, one warning, so the silence is a reading and not a lint +// that stopped loading ledgers (which also answers []). +describe('action disabled reason (#26619 — live, read by the console)', () => { + const LIT = { objects: [{ name: 'widget', externalSharingModel: 'read' }] }; 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([]); + it('is silent on an authored `disabledReason` — the row is `live`, cites the console readers, and carries no author warning', () => { + const findings = lintLivenessProperties({ ...LIT, ...action({ disabledReason: 'An approved legal review is required first.' }) }); + expect(ruleOf(findings, 'externalSharingModel')).toBe(LIVENESS_DEAD_PROPERTY); + expect(ruleOf(findings, 'disabledReason')).toBeUndefined(); + const row = JSON.parse(readFileSync(join(shippedLedgerDir(), 'action.json'), 'utf8')).props.disabledReason; + // Anti-vacuity: the row is the shape this pin now exists for. + expect(row.status).toBe('live'); + expect(row).not.toHaveProperty('authorWarn'); + expect(row).not.toHaveProperty('authorHint'); + expect(row.evidence).toContain('ui/packages/components/src/renderers/layout/containers.tsx#disabledReasonFor'); }); }); diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 99cf4515203..a76b4e010e8 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -152,12 +152,11 @@ "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", + "status": "live", "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." + "evidenceScope": "in-repo", + "evidence": "ui/packages/components/src/renderers/layout/containers.tsx#disabledReasonFor (the record header, inline and in the ⋯ overflow: the declared reason, else `actions.notAvailableForRecord`); ui/packages/plugin-detail/src/renderers/record-quick-actions.tsx#QuickActionButton (the record section bar); ui/packages/app-shell/src/views/DeclaredActionsBar.tsx#DeclaredActionButton (the declared actions bar, button and override-menu item); ui/packages/components/src/renderers/action/disabled-reason.tsx#useDisabledReason (the four `action:*` renderers: `action-button.tsx`, `action-icon.tsx`, `action-group.tsx`, `action-menu.tsx`); ui/packages/react/src/hooks/useActionTextLocalizer.ts#useActionTextLocalizer and ui/packages/i18n/src/useObjectLabel.ts#useObjectLabel (`actionDisabledReason`: the bundle's `_actions.{a}.disabledReason` / `globalActions.{a}.disabledReason`, else the authored I18nLabel collapsed to the active language); packages/spec/src/system/i18n-resolver.ts#translateAction (the served overlay); packages/cli/src/utils/i18n-extract.ts#collectExpectedEntries (the extract).", + "note": "LIVE since the console half of #26619 (ruling 6442737825, letter A: spec PR #26786 first, this console PR second). Each greyed-action surface shows the declared reason as the hover title and the accessible description (a menu item's second line), only while the DECLARED `disabled` predicate is TRUE, and shows `actions.notAvailableForRecord` (\"Not available for this record\") when the action declares none or a blank one. The record header, the section bar and the declared actions bar run actions through `useActionTextLocalizer`, so the bundle's sentence outranks the authored text; the four `action:*` renderers read the action as authored and honour its inline locale map. `authorWarn` / `authorHint` were dropped in the 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", diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index 485c04b2451..8e8f2660248 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 | 1 | 49 | +| `action` | 48 | 0 | 0 | 1 | 0 | 49 | diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 196f4801ff7..f55a764ae9d 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -215,7 +215,7 @@ const actionTranslationSchema = (surface: string) => strictObject({ 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)'), + disabledReason: z.string().optional().describe('Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE'), // `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 diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index eee6d0b25d5..efa6a69bd31 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1583,7 +1583,7 @@ const actionObject = () => strictObject({ * 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.'), + disabledReason: actionDisabledReason().optional().describe('Display-only reason a greyed action shows (its hover title and accessible description, or a menu item\'s second line) while `disabled` evaluates TRUE — 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 diff --git a/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx b/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx index bd27b2b24a7..f9cf7b207d8 100644 --- a/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx +++ b/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx @@ -373,8 +373,11 @@ const DeclaredActionButton: React.FC<{ }, [record, approverDirectory, t]); // The declared label, localized — resolved ONCE here so the button text, the // param dialog's title and the override framing below can never come from - // different bundle reads (objectui#4265). - const declaredLabel = (localizeActionTexts(objectName, action as Record).label as string) || ''; + // different bundle reads (objectui#4265). The greyed button's declared + // reason (objectstack#26619) comes off the same localized copy. + const localizedTexts = localizeActionTexts(objectName, action as Record); + const declaredLabel = (localizedTexts.label as string) || ''; + const declaredReason = typeof localizedTexts.disabledReason === 'string' ? localizedTexts.disabledReason : ''; /** * The override framing wrapped around the declared label. A placeholder * rather than a hard-coded prefix so a translator can reorder it — `Approve` @@ -591,12 +594,12 @@ const DeclaredActionButton: React.FC<{ // a fact about the record, so only it earns the "not available" reason. A // button greyed out while its own action runs already says why — the spinner. const disabledByPredicate = hasDeclaredVisibilityGate(action.disabled) ? isDisabledPred : false; - // The generic reason (objectui#11811). An author-written reason beside the - // predicate would be a spec key, which is objectstack's to declare — not one - // this bar may invent — so every predicate-disabled action says the same - // thing for now. + // The action's declared `disabledReason` (objectstack#26619), localized + // above, else the generic reason (objectui#11811). const disabledReason = disabledByPredicate - ? String(t('actions.notAvailableForRecord', { defaultValue: 'Not available for this record' })) + ? (declaredReason.trim() !== '' + ? declaredReason + : String(t('actions.notAvailableForRecord', { defaultValue: 'Not available for this record' }))) : undefined; if (asMenuItem) { diff --git a/ui/packages/app-shell/src/views/__tests__/DeclaredActionsBar.disabledReason-26619.test.tsx b/ui/packages/app-shell/src/views/__tests__/DeclaredActionsBar.disabledReason-26619.test.tsx new file mode 100644 index 00000000000..6af4fb4af19 --- /dev/null +++ b/ui/packages/app-shell/src/views/__tests__/DeclaredActionsBar.disabledReason-26619.test.tsx @@ -0,0 +1,123 @@ +/** + * objectstack#26619 — a `DeclaredActionsBar` action greyed out by its declared + * `disabled` predicate says the reason its author DECLARED, `disabledReason`, + * resolved off the same localized copy as its label: the bundle's sentence at + * `objects.._actions..disabledReason` when there is one, the + * authored text otherwise. "Not available for this record" stays the answer + * for an action that declares none. + * + * Doubles as the sibling suite (`DeclaredActionsBar.disabledReason-11811.test.tsx`) + * does: only the action dispatch and the console runtime shell. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import React from 'react'; + +const executeSpy = vi.fn().mockResolvedValue({ success: true }); + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ActionProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + useAction: () => ({ execute: executeSpy }), + }; +}); +vi.mock('../../hooks/useConsoleActionRuntime', () => ({ + useConsoleActionRuntime: () => ({ actionProviderProps: {}, dialogs: null }), +})); +vi.mock('../../providers/AdapterProvider', () => ({ useAdapter: () => ({}) })); +vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => null })); + +import { I18nProvider, useObjectTranslation } from '@object-ui/i18n'; +import { DeclaredActionsBar } from '../DeclaredActionsBar'; + +const DEFAULT_EN = 'Not available for this record'; +const DECLARED_EN = 'Finish the checklist before archiving.'; +const BUNDLE_ZH = '请先完成检查清单。'; + +const ARCHIVE = { + name: 'showcase_archive_task', + type: 'script', + label: 'Archive', + locations: ['record_section'], + disabled: { dialect: 'cel', source: 'has(record.done) && record.done != true' }, + disabledReason: DECLARED_EN, +}; +const { disabledReason: _none, ...ARCHIVE_UNDECLARED } = ARCHIVE; + +/** Loads a translation bundle into the provider's instance before its children render. */ +function WithBundle({ language, bundle, children }: { language: string; bundle: Record; children: React.ReactNode }) { + const { i18n } = useObjectTranslation(); + const loaded = React.useRef(false); + if (!loaded.current) { + i18n.addResourceBundle(language, 'translation', bundle, true, true); + loaded.current = true; + } + return <>{children}; +} + +function mount(record: Record, action: Record = ARCHIVE, zhBundle?: Record) { + const node = ( + + ); + return render( + zhBundle ? ( + + {node} + + ) : ( + node + ), + ); +} + +const archive = () => screen.getByTestId('declared-action-showcase_archive_task'); +const describedBy = (el: HTMLElement) => { + const id = el.getAttribute('aria-describedby'); + return id ? document.getElementById(id) : null; +}; + +beforeEach(() => executeSpy.mockReset().mockResolvedValue({ success: true })); + +describe('DeclaredActionsBar — the declared reason is the one a greyed action gives (objectstack#26619)', () => { + it('the declared string is the description and the tooltip', async () => { + const user = userEvent.setup(); + mount({ done: false }); + expect(archive()).toBeDisabled(); + expect(describedBy(archive())).toHaveTextContent(DECLARED_EN); + expect(archive()).toHaveAccessibleName('Archive'); + await user.hover(archive().parentElement as HTMLElement); + expect(await screen.findByRole('tooltip')).toHaveTextContent(DECLARED_EN); + expect(screen.queryByText(DEFAULT_EN)).toBeNull(); + }); + + it("the bundle's sentence outranks the authored text — zh", async () => { + mount({ done: false }, ARCHIVE, { + showcase: { objects: { showcase_task: { _actions: { showcase_archive_task: { disabledReason: BUNDLE_ZH } } } } }, + }); + const reason = await screen.findByText(BUNDLE_ZH); + expect(describedBy(archive())).toBe(reason); + expect(screen.queryByText(DECLARED_EN)).toBeNull(); + }); + + it('no declared reason: the default stays', () => { + mount({ done: false }, ARCHIVE_UNDECLARED); + expect(describedBy(archive())).toHaveTextContent(DEFAULT_EN); + }); + + it('control — the predicate does not hold: the declared reason is not shown', () => { + mount({ done: true }); + expect(archive()).not.toBeDisabled(); + expect(archive()).not.toHaveAttribute('aria-describedby'); + expect(screen.queryByText(DECLARED_EN)).toBeNull(); + }); +}); diff --git a/ui/packages/components/src/__tests__/page-header-disabledReason-26619.test.tsx b/ui/packages/components/src/__tests__/page-header-disabledReason-26619.test.tsx new file mode 100644 index 00000000000..5a80f0c3217 --- /dev/null +++ b/ui/packages/components/src/__tests__/page-header-disabledReason-26619.test.tsx @@ -0,0 +1,130 @@ +/** + * objectstack#26619 — a `page:header` action greyed out by its declared + * `disabled` predicate says the reason its author DECLARED, `disabledReason`, + * inline and in the ⋯ overflow. The header runs its authored actions through + * `useActionTextLocalizer`, so the reason is the bundle's sentence at + * `objects.._actions..disabledReason` when there is one, and + * the authored text otherwise. "Not available for this record" stays the + * answer for an action that declares none. + * + * The sibling suite (`page-header-disabledReason-11811.test.tsx`) pins the + * mechanics and the host-chrome controls; this one pins the text. + */ + +import * as React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { ActionProvider, RecordContextProvider } from '@object-ui/react'; +import { I18nProvider, useObjectTranslation } from '@object-ui/i18n'; +// Registers `page:header` at module scope (object-ui/no-dynamic-import-in-test-hook). +import '../renderers'; + +const DEFAULT_EN = 'Not available for this record'; +const DECLARED_EN = 'Finish the checklist before archiving.'; +const BUNDLE_ZH = '请先完成检查清单。'; +const DONE_GATE = { dialect: 'cel', source: 'has(record.done) && record.done != true' }; + +const ARCHIVE = { + name: 'showcase_archive_task', + label: 'Archive', + type: 'script', + locations: ['record_header'], + disabled: DONE_GATE, + disabledReason: DECLARED_EN, +}; +const ARCHIVE_MORE = { ...ARCHIVE, name: 'showcase_archive_more', label: 'Archive later', locations: ['record_more'] }; +const { disabledReason: _none, ...ARCHIVE_UNDECLARED } = ARCHIVE; + +function PageHeader({ schema }: { schema: any }) { + const Component = ComponentRegistry.get('page:header'); + if (!Component) throw new Error('page:header not registered'); + // eslint-disable-next-line react-hooks/static-components -- ComponentRegistry.get returns a registered component (stable), not one created during render + return ; +} + +/** Loads a translation bundle into the provider's instance before its children render. */ +function WithBundle({ language, bundle, children }: { language: string; bundle: Record; children: React.ReactNode }) { + const { i18n } = useObjectTranslation(); + const loaded = React.useRef(false); + if (!loaded.current) { + i18n.addResourceBundle(language, 'translation', bundle, true, true); + loaded.current = true; + } + return <>{children}; +} + +function mount(actions: Record[], record: Record, zhBundle?: Record) { + const node = ( + + + + + + ); + return render( + zhBundle ? ( + + {node} + + ) : ( + node + ), + ); +} + +const describedBy = (el: Element) => { + const id = el.getAttribute('aria-describedby'); + return id ? document.getElementById(id) : null; +}; + +describe('page:header — the declared reason is the one a greyed action gives (objectstack#26619)', () => { + it('inline: the declared string is the description and the tooltip', async () => { + const user = userEvent.setup(); + mount([ARCHIVE], { done: false }); + const button = screen.getByRole('button', { name: 'Archive' }); + expect(button).toBeDisabled(); + expect(describedBy(button)).toHaveTextContent(DECLARED_EN); + await user.hover(button.parentElement as HTMLElement); + expect(await screen.findByRole('tooltip')).toHaveTextContent(DECLARED_EN); + expect(screen.queryByText(DEFAULT_EN)).toBeNull(); + }); + + it('overflow: the declared string is the item\'s second line and description', async () => { + const user = userEvent.setup(); + mount([ARCHIVE_MORE], { done: false }); + await user.click(screen.getByRole('button', { name: /More actions/i })); + const item = await screen.findByRole('menuitem', { name: 'Archive later' }); + expect(describedBy(item)).toHaveTextContent(DECLARED_EN); + expect(describedBy(item)).toBeVisible(); + }); + + it("the bundle's sentence at `_actions..disabledReason` outranks the authored text — zh", async () => { + mount([ARCHIVE], { done: false }, { + showcase: { objects: { showcase_task: { _actions: { showcase_archive_task: { disabledReason: BUNDLE_ZH } } } } }, + }); + const reason = await screen.findByText(BUNDLE_ZH); + expect(describedBy(screen.getByRole('button', { name: 'Archive' }))).toBe(reason); + expect(screen.queryByText(DECLARED_EN)).toBeNull(); + }); + + it('no declared reason: the default stays', () => { + mount([ARCHIVE_UNDECLARED], { done: false }); + expect(describedBy(screen.getByRole('button', { name: 'Archive' }))).toHaveTextContent(DEFAULT_EN); + }); + + it('control — the predicate does not hold: the declared reason is not shown', () => { + mount([ARCHIVE], { done: true }); + const button = screen.getByRole('button', { name: 'Archive' }); + expect(button).not.toBeDisabled(); + expect(button).not.toHaveAttribute('aria-describedby'); + expect(screen.queryByText(DECLARED_EN)).toBeNull(); + }); +}); diff --git a/ui/packages/components/src/renderers/action/__tests__/action-disabled-reason-26619.test.tsx b/ui/packages/components/src/renderers/action/__tests__/action-disabled-reason-26619.test.tsx new file mode 100644 index 00000000000..d1f316ef195 --- /dev/null +++ b/ui/packages/components/src/renderers/action/__tests__/action-disabled-reason-26619.test.tsx @@ -0,0 +1,202 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#26619 — an `action:*` control greyed out by its declared + * `disabled` predicate says the reason its author DECLARED, `disabledReason`, + * where objectui#11839 made it say the generic one. "Not available for this + * record" stays the answer when the action declares none, or a blank one. + * + * The four renderers read the action as authored, so the translation they + * honour is the inline locale map, collapsed to the active language. The + * sibling suite (`action-disabled-reason-11839.test.tsx`) pins the mechanics + * (wrapper, tooltip, keyboard reach, the controls that give no reason); this + * one pins the text. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import type { ActionResult } from '@object-ui/core'; +import { ActionProvider } from '@object-ui/react'; +import { I18nProvider } from '@object-ui/i18n'; +// Module-scope side-effect imports (object-ui/no-dynamic-import-in-test-hook). +import '../action-button'; +import '../action-icon'; +import '../action-group'; +import '../action-menu'; + +const DEFAULT_EN = 'Not available for this record'; +const DEFAULT_ZH = '对此记录不可用'; +const DECLARED_EN = 'Finish the checklist before archiving.'; +const DECLARED_ZH = '请先完成检查清单再归档。'; +const DONE_GATE = { dialect: 'cel', source: 'has(record.done) && record.done != true' }; +const OPEN_TASK = { id: 't-1', done: false }; +const DONE_TASK = { id: 't-1', done: true }; + +const ARCHIVE = { name: 'archive', label: 'Archive', icon: 'archive', type: 'script', disabled: DONE_GATE }; +const VIEW = { name: 'view', label: 'View', type: 'script' }; + +afterEach(() => cleanup()); + +function getRenderer(type: string) { + const R = ComponentRegistry.get(type); + if (!R) throw new Error(`${type} is not registered`); + return R; +} + +function mount(node: React.ReactElement, opts: { zh?: boolean } = {}) { + const tree = ( + new Promise(() => {}) }}>{node} + ); + return render( + opts.zh ? ( + + {tree} + + ) : ( + tree + ), + ); +} + +/** The text of every element the control's `aria-describedby` names. */ +const description = (el: Element) => + (el.getAttribute('aria-describedby') ?? '') + .split(/\s+/) + .filter(Boolean) + .map((id) => document.getElementById(id)?.textContent ?? `(missing #${id})`) + .join(' | '); + +type Node = (action: Record, record: Record) => React.ReactElement; + +const BUTTON_SITES: { id: string; node: Node }[] = [ + { + id: 'action:button', + node: (action, record) => { + const R = getRenderer('action:button'); + return ; + }, + }, + { + id: 'action:icon', + node: (action, record) => { + const R = getRenderer('action:icon'); + return ; + }, + }, + { + id: 'action:group inline member', + node: (action, record) => { + const R = getRenderer('action:group'); + return ; + }, + }, +]; + +const MENU_SITES: { id: string; trigger: RegExp; node: Node }[] = [ + { + id: 'action:menu item', + trigger: /More/, + node: (action, record) => { + const R = getRenderer('action:menu'); + return ; + }, + }, + { + id: 'action:group dropdown member', + trigger: /Group menu/, + node: (action, record) => { + const R = getRenderer('action:group'); + return ( + + ); + }, + }, +]; + +const archiveButton = () => screen.getByRole('button', { name: 'Archive' }); + +describe.each(BUTTON_SITES)('$id — the declared reason is the one it gives (objectstack#26619)', (site) => { + it('a declared string is the accessible description and the tooltip', async () => { + const user = userEvent.setup(); + mount(site.node({ ...ARCHIVE, disabledReason: DECLARED_EN }, OPEN_TASK)); + const button = archiveButton(); + expect(button).toBeDisabled(); + expect(description(button)).toBe(DECLARED_EN); + await user.hover(button.parentElement as HTMLElement); + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent(DECLARED_EN); + expect(tooltip).not.toHaveTextContent(DEFAULT_EN); + }); + + it('an inline locale map collapses to the active language — zh', () => { + mount(site.node({ ...ARCHIVE, disabledReason: { en: DECLARED_EN, 'zh-CN': DECLARED_ZH } }, OPEN_TASK), { zh: true }); + expect(description(archiveButton())).toBe(DECLARED_ZH); + }); + + it('no declared reason: the default stays — en and zh', () => { + mount(site.node(ARCHIVE, OPEN_TASK)); + expect(description(archiveButton())).toBe(DEFAULT_EN); + cleanup(); + mount(site.node(ARCHIVE, OPEN_TASK), { zh: true }); + expect(description(archiveButton())).toBe(DEFAULT_ZH); + }); + + it('a map whose entry for the language is blank falls back to the default', () => { + mount(site.node({ ...ARCHIVE, disabledReason: { en: ' ' } }, OPEN_TASK)); + expect(description(archiveButton())).toBe(DEFAULT_EN); + }); + + it('a map with no entry for the language shows an authored entry (the `pickLocalized` chain), not the default', () => { + mount(site.node({ ...ARCHIVE, disabledReason: { en: DECLARED_EN } }, OPEN_TASK), { zh: true }); + expect(description(archiveButton())).toBe(DECLARED_EN); + }); + + it('control — the predicate does not hold: the declared reason is not shown', () => { + mount(site.node({ ...ARCHIVE, disabledReason: DECLARED_EN }, DONE_TASK)); + const button = archiveButton(); + expect(button).not.toBeDisabled(); + expect(button).not.toHaveAttribute('aria-describedby'); + expect(screen.queryByText(DECLARED_EN)).toBeNull(); + }); +}); + +describe.each(MENU_SITES)('$id — the declared reason is the item\'s second line (objectstack#26619)', (site) => { + async function openItem(user: ReturnType) { + await user.click(screen.getByRole('button', { name: site.trigger })); + return screen.findByRole('menuitem', { name: 'Archive' }); + } + + it('a declared string is the visible second line and the description; the name stays the label', async () => { + const user = userEvent.setup(); + mount(site.node({ ...ARCHIVE, disabledReason: DECLARED_EN }, OPEN_TASK)); + const item = await openItem(user); + expect(item).toHaveAttribute('data-disabled'); + expect(item).toHaveAccessibleName('Archive'); + expect(description(item)).toBe(DECLARED_EN); + expect(screen.getByText(DECLARED_EN)).toBeVisible(); + }); + + it('no declared reason: the default stays', async () => { + const user = userEvent.setup(); + mount(site.node(ARCHIVE, OPEN_TASK)); + expect(description(await openItem(user))).toBe(DEFAULT_EN); + }); + + it('control — the predicate does not hold: the declared reason is not shown', async () => { + const user = userEvent.setup(); + mount(site.node({ ...ARCHIVE, disabledReason: DECLARED_EN }, DONE_TASK)); + const item = await openItem(user); + expect(item).not.toHaveAttribute('data-disabled'); + expect(screen.queryByText(DECLARED_EN)).toBeNull(); + }); +}); diff --git a/ui/packages/components/src/renderers/action/action-button.tsx b/ui/packages/components/src/renderers/action/action-button.tsx index f4a51df33aa..ab291f1a85b 100644 --- a/ui/packages/components/src/renderers/action/action-button.tsx +++ b/ui/packages/components/src/renderers/action/action-button.tsx @@ -198,6 +198,7 @@ const ActionButtonRenderer = forwardRef< // leg disable the button without one. See `./disabled-reason`. const disabledReason = useDisabledReason( hasDeclaredVisibilityGate(schema.disabled) && isDisabled, + schema.disabledReason, ); // Resolve icon diff --git a/ui/packages/components/src/renderers/action/action-group.tsx b/ui/packages/components/src/renderers/action/action-group.tsx index 7f6f78c08d0..b0402fee0b7 100644 --- a/ui/packages/components/src/renderers/action/action-group.tsx +++ b/ui/packages/components/src/renderers/action/action-group.tsx @@ -131,6 +131,7 @@ const InlineActionButton: React.FC<{ // member's `loading` disable it without one. See `./disabled-reason`. const disabledReason = useDisabledReason( hasDeclaredVisibilityGate(action.disabled) && isDisabledPred, + action.disabledReason, ); const Icon = resolveIcon(action.icon); @@ -228,6 +229,7 @@ export const DropdownActionItem: React.FC<{ // item's description. See `./disabled-reason`. const disabledReason = useDisabledReason( hasDeclaredVisibilityGate(action.disabled) && isDisabledPred, + action.disabledReason, ); // Same declared-gate rule as `InlineActionButton` above — one action cannot be // hidden in one display mode and shown in the other (objectui#3812). diff --git a/ui/packages/components/src/renderers/action/action-icon.tsx b/ui/packages/components/src/renderers/action/action-icon.tsx index 073caef40a0..f5d525eede1 100644 --- a/ui/packages/components/src/renderers/action/action-icon.tsx +++ b/ui/packages/components/src/renderers/action/action-icon.tsx @@ -122,6 +122,7 @@ const ActionIconRenderer = forwardRef< // earns it. See `./disabled-reason`. const disabledReason = useDisabledReason( hasDeclaredVisibilityGate(schema.disabled) && isDisabledPred, + schema.disabledReason, ); const Icon = resolveIcon(schema.icon); diff --git a/ui/packages/components/src/renderers/action/action-menu.tsx b/ui/packages/components/src/renderers/action/action-menu.tsx index ced3908b0dd..41717cf344b 100644 --- a/ui/packages/components/src/renderers/action/action-menu.tsx +++ b/ui/packages/components/src/renderers/action/action-menu.tsx @@ -116,6 +116,7 @@ export const ActionMenuItem: React.FC<{ // description. See `./disabled-reason`. const disabledReason = useDisabledReason( hasDeclaredVisibilityGate(action.disabled) && isDisabledPred, + action.disabledReason, ); const iconElement = useMemo(() => { diff --git a/ui/packages/components/src/renderers/action/disabled-reason.tsx b/ui/packages/components/src/renderers/action/disabled-reason.tsx index b493cd3b1d4..2fd78cffb2c 100644 --- a/ui/packages/components/src/renderers/action/disabled-reason.tsx +++ b/ui/packages/components/src/renderers/action/disabled-reason.tsx @@ -31,6 +31,13 @@ * and the menu's roving focus skips a disabled item, so the keyboard never * reaches it. * + * The text is the action's declared `disabledReason` (objectstack#26619), its + * `I18nLabel` map collapsed to the active language, and the generic + * `actions.notAvailableForRecord` sentence when the action declares none (or + * declares a blank one). These four renderers read the action as authored, so + * an inline locale map is the translation they honour; the surfaces that run + * actions through `useActionTextLocalizer` hand them the bundle's sentence. + * * Which `disabled` earns the reason is each caller's to say, and the answer is * the same at all four: only the DECLARED `disabled` predicate, evaluated true. * That verdict is a fact about the record. The host's forwarded `disabled` and @@ -45,12 +52,12 @@ */ import React from 'react'; -import { useSafeTranslate } from '@object-ui/i18n'; +import { pickLocalized, useObjectTranslation, useSafeTranslate } from '@object-ui/i18n'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui'; /** A reason to show, with the ids the control's ARIA attributes point at. */ export interface DisabledReason { - /** The localized reason text. */ + /** The localized reason text: the declared reason, else the generic one. */ text: string; /** Id of the element holding the reason — the control's description. */ id: string; @@ -62,15 +69,23 @@ export interface DisabledReason { * The reason for a control, or `undefined` when it gives none. * * `disabledByPredicate` is the caller's DECLARED-`disabled` verdict alone, never - * the OR it hands the control's `disabled` prop. A hook, so call it with the - * other hooks, ahead of any early return. + * the OR it hands the control's `disabled` prop. `declaredReason` is the + * action's `disabledReason` as authored (a string or an inline locale map). A + * hook, so call it with the other hooks, ahead of any early return. */ -export function useDisabledReason(disabledByPredicate: boolean): DisabledReason | undefined { +export function useDisabledReason( + disabledByPredicate: boolean, + declaredReason?: unknown, +): DisabledReason | undefined { const tt = useSafeTranslate(); + const { language } = useObjectTranslation(); const id = React.useId(); if (!disabledByPredicate) return undefined; + const declared = pickLocalized(declaredReason, language); return { - text: tt('actions.notAvailableForRecord', 'Not available for this record'), + text: declared.trim() !== '' + ? declared + : tt('actions.notAvailableForRecord', 'Not available for this record'), id: `${id}-reason`, labelId: `${id}-label`, }; diff --git a/ui/packages/components/src/renderers/layout/containers.tsx b/ui/packages/components/src/renderers/layout/containers.tsx index bb375f2e687..3ca4975bd19 100644 --- a/ui/packages/components/src/renderers/layout/containers.tsx +++ b/ui/packages/components/src/renderers/layout/containers.tsx @@ -2366,14 +2366,18 @@ export const PageHeaderActions: React.FC = ({ // it: that verdict is a fact about the record, read off the action spec. // The inline-edit lock above is the host's own state, and the host's Edit / // Delete carry a boolean the host computed (see `authoredKeys` in the plan - // memo), so neither says "not available for this record". The reason is - // the generic one; an author-written reason beside the predicate would be a - // spec key, which is objectstack's to declare. + // memo), so neither says "not available for this record". The text is the + // action's declared `disabledReason` (objectstack#26619), already resolved + // against the bundle by `localizeActionTexts` in the `headerActions` memo, + // and the generic reason when it declares none. const disabledReasonFor = (action: any): string | undefined => { const key = (action?.name || action?.id || '') as string; if (key === '' || !authoredHeaderActionKeys.has(key)) return undefined; if (!resolveDisabled(action?.disabled, action?.name ?? action?.id)) return undefined; - return tt('actions.notAvailableForRecord', 'Not available for this record'); + const declared = typeof action?.disabledReason === 'string' ? action.disabledReason : ''; + return declared.trim() !== '' + ? declared + : tt('actions.notAvailableForRecord', 'Not available for this record'); }; const renderButton = (action: any, idx: number) => { const label = resolveLabel(action); diff --git a/ui/packages/i18n/src/__tests__/useObjectLabel-identity-5564.test.tsx b/ui/packages/i18n/src/__tests__/useObjectLabel-identity-5564.test.tsx index f8c03bad437..281b1754aca 100644 --- a/ui/packages/i18n/src/__tests__/useObjectLabel-identity-5564.test.tsx +++ b/ui/packages/i18n/src/__tests__/useObjectLabel-identity-5564.test.tsx @@ -172,11 +172,12 @@ describe('useObjectLabel identity (objectui#5564)', () => { // copy), taking it back to 27. objectui#11696 added `objectPluralLabel` // (the name of an object's list), taking it to 28. objectui#11389 retired // `widgetSubCaption` with the metric sub-caption (ruling C), taking it to - // 27. A new resolver must land + // 27. objectstack#26619 added `actionDisabledReason` (the reason a greyed + // action gives), taking it to 28. A new resolver must land // on both paths at once, because there is only one path — and a retired one // leaves both at once for the same reason, which is what the equality above // measures and this count anchors to an absolute. - expect(Object.keys(unbound.seen[0])).toHaveLength(27); + expect(Object.keys(unbound.seen[0])).toHaveLength(28); expect(Object.keys(unbound.seen[0])).not.toContain('widgetSubCaption'); expect(typeof unbound.seen[0].objectLabel).toBe('function'); expect(unbound.seen[0].objectLabel({ name: 'lead', label: 'Lead' })).toBe('Lead'); diff --git a/ui/packages/i18n/src/useObjectLabel.ts b/ui/packages/i18n/src/useObjectLabel.ts index 7799ed3b229..4d8998bddc3 100644 --- a/ui/packages/i18n/src/useObjectLabel.ts +++ b/ui/packages/i18n/src/useObjectLabel.ts @@ -622,6 +622,21 @@ export function useObjectLabel() { return resolved || undefined; }, + /** + * Resolve the translated reason a greyed action gives — its declared + * `disabledReason` (objectstack#26619), shown while its `disabled` + * predicate is TRUE. Convention: + * `{ns}.objects.{objectName}._actions.{actionName}.disabledReason`; with no + * key object, `{ns}.globalActions.{actionName}.disabledReason`. Falls back + * to the metadata's literal string; undefined when nothing resolves, so the + * caller shows its own default reason. + */ + actionDisabledReason: (objectName: string | undefined, actionName: string, fallback?: string) => { + const fb = fallback ?? ''; + const resolved = resolve(actionSuffixes(objectName, actionName, 'disabledReason'), fb); + return resolved || undefined; + }, + /** * Resolve a translated copy of an action's post-success RESULT DIALOG * (`title` / `description` / `acknowledge` + per-field labels). diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.disabledReason-26619.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.disabledReason-26619.test.tsx new file mode 100644 index 00000000000..ef8fa5311cb --- /dev/null +++ b/ui/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.disabledReason-26619.test.tsx @@ -0,0 +1,103 @@ +/** + * objectstack#26619 — a `record:quick_actions` action greyed out by its + * declared `disabled` predicate says the reason its author DECLARED, + * `disabledReason`. The bar localizes its actions through + * `useActionTextLocalizer`, so the bundle's sentence at + * `objects.._actions..disabledReason` outranks the authored + * text. "Not available for this record" stays the answer for an action that + * declares none. + * + * The sibling suite (`record-quick-actions.disabledReason-11811.test.tsx`) + * pins the mechanics; this one pins the text. + */ + +import * as React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { RecordContextProvider } from '@object-ui/react'; +import { I18nProvider, useObjectTranslation } from '@object-ui/i18n'; +import { RecordQuickActionsRenderer } from '../record-quick-actions'; + +const DEFAULT_EN = 'Not available for this record'; +const DECLARED_EN = 'Finish the checklist before archiving.'; +const BUNDLE_ZH = '请先完成检查清单。'; + +const ARCHIVE = { + name: 'showcase_archive_task', + label: 'Archive', + type: 'script', + locations: ['record_section'], + disabled: { dialect: 'cel', source: 'has(record.done) && record.done != true' }, + disabledReason: DECLARED_EN, +}; +const { disabledReason: _none, ...ARCHIVE_UNDECLARED } = ARCHIVE; + +/** Loads a translation bundle into the provider's instance before its children render. */ +function WithBundle({ language, bundle, children }: { language: string; bundle: Record; children: React.ReactNode }) { + const { i18n } = useObjectTranslation(); + const loaded = React.useRef(false); + if (!loaded.current) { + i18n.addResourceBundle(language, 'translation', bundle, true, true); + loaded.current = true; + } + return <>{children}; +} + +function mount(action: Record, record: Record, zhBundle?: Record) { + const node = ( + + + + ); + return render( + zhBundle ? ( + + {node} + + ) : ( + node + ), + ); +} + +const archive = () => screen.getByRole('button', { name: 'Archive' }); +const describedBy = (el: HTMLElement) => { + const id = el.getAttribute('aria-describedby'); + return id ? document.getElementById(id) : null; +}; + +describe('record:quick_actions — the declared reason is the one a greyed action gives (objectstack#26619)', () => { + it('the declared string is the description and the tooltip', async () => { + const user = userEvent.setup(); + mount(ARCHIVE, { done: false }); + expect(archive()).toBeDisabled(); + expect(describedBy(archive())).toHaveTextContent(DECLARED_EN); + expect(archive()).toHaveAccessibleName('Archive'); + await user.hover(archive().parentElement as HTMLElement); + expect(await screen.findByRole('tooltip')).toHaveTextContent(DECLARED_EN); + expect(screen.queryByText(DEFAULT_EN)).toBeNull(); + }); + + it("the bundle's sentence outranks the authored text — zh", async () => { + mount(ARCHIVE, { done: false }, { + showcase: { objects: { showcase_task: { _actions: { showcase_archive_task: { disabledReason: BUNDLE_ZH } } } } }, + }); + const reason = await screen.findByText(BUNDLE_ZH); + expect(describedBy(archive())).toBe(reason); + expect(screen.queryByText(DECLARED_EN)).toBeNull(); + }); + + it('no declared reason: the default stays', () => { + mount(ARCHIVE_UNDECLARED, { done: false }); + expect(describedBy(archive())).toHaveTextContent(DEFAULT_EN); + }); + + it('control — the predicate does not hold: the declared reason is not shown', () => { + mount(ARCHIVE, { done: true }); + expect(archive()).not.toBeDisabled(); + expect(archive()).not.toHaveAttribute('aria-describedby'); + expect(screen.queryByText(DECLARED_EN)).toBeNull(); + }); +}); diff --git a/ui/packages/plugin-detail/src/renderers/record-quick-actions.tsx b/ui/packages/plugin-detail/src/renderers/record-quick-actions.tsx index 8f3fed2d4f6..d3f142c59b6 100644 --- a/ui/packages/plugin-detail/src/renderers/record-quick-actions.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-quick-actions.tsx @@ -458,11 +458,13 @@ function QuickActionButton({ // button greyed out while its own action runs already says why — the spinner. const disabledByPredicate = hasDeclaredVisibilityGate((action as any).disabled) ? isDisabledPred : false; const isDisabled = disabledByPredicate || running; - // The generic reason (objectui#11811). The author-written reason beside the - // predicate is a spec question for objectstack, not a key this renderer may - // invent, so every predicate-disabled action says the same thing for now. + // The action's declared `disabledReason` (objectstack#26619), already + // resolved against the bundle by `localizeActionTexts` above, else the + // generic reason (objectui#11811). + const localizedReason: unknown = (action as { disabledReason?: unknown }).disabledReason; + const declaredReason = typeof localizedReason === 'string' ? localizedReason : ''; const disabledReason = disabledByPredicate - ? tt('actions.notAvailableForRecord', 'Not available for this record') + ? (declaredReason.trim() !== '' ? declaredReason : tt('actions.notAvailableForRecord', 'Not available for this record')) : undefined; // The declared `icon` (objectui#12772), resolved through the shared icon seam. // It sits beside the label and gives way to the spinner while the action diff --git a/ui/packages/react/src/hooks/__tests__/useActionTextLocalizer.disabledReason-26619.test.tsx b/ui/packages/react/src/hooks/__tests__/useActionTextLocalizer.disabledReason-26619.test.tsx new file mode 100644 index 00000000000..cc65e6adb83 --- /dev/null +++ b/ui/packages/react/src/hooks/__tests__/useActionTextLocalizer.disabledReason-26619.test.tsx @@ -0,0 +1,103 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#26619 — the reason a greyed action gives, `disabledReason`, + * resolves like the action's other copy: the bundle's sentence at + * `_actions..disabledReason` (or `globalActions..disabledReason` + * for an object-less action), else the authored `I18nLabel` collapsed to the + * active language. Like `confirmText`, it is resolved only when the action + * DECLARES one, so a bundle cannot give a reason to an action whose author + * wrote none (that action keeps the surface's default reason). + * + * Measured under a real `I18nProvider`, reading what the localizer returns. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, renderHook } from '@testing-library/react'; +import React from 'react'; +import { I18nProvider, useObjectTranslation } from '@object-ui/i18n'; +import { useActionTextLocalizer } from '../useActionTextLocalizer'; + +afterEach(() => cleanup()); + +const SUBMIT = { + name: 'submit_for_approval', + type: 'script', + label: 'Submit for approval', + disabled: { dialect: 'cel', source: "record.legal_review_status != 'approved'" }, + disabledReason: 'An approved legal review is required first.', +}; + +function harness(language: string, bundle?: Record) { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + const { result } = renderHook( + () => ({ localize: useActionTextLocalizer(), translation: useObjectTranslation() }), + { wrapper }, + ); + if (bundle) result.current.translation.i18n.addResourceBundle(language, 'translation', bundle, true, true); + return result; +} + +const zhBundle = { + clm: { + objects: { + clm_contract: { _actions: { submit_for_approval: { disabledReason: '需先通过法务审核。' } } }, + }, + globalActions: { open_inbox: { disabledReason: '收件箱暂不可用。' } }, + }, +}; + +describe('useActionTextLocalizer — disabledReason (objectstack#26619)', () => { + it('no bundle entry: the authored string is the answer', () => { + const result = harness('en'); + expect(result.current.localize('clm_contract', SUBMIT).disabledReason) + .toBe('An approved legal review is required first.'); + }); + + it("the bundle's sentence outranks the authored text, read off the action's own object", () => { + const result = harness('zh', zhBundle); + expect(result.current.localize('clm_contract', SUBMIT).disabledReason).toBe('需先通过法务审核。'); + }); + + it('an inline locale map collapses to the active language before the bundle is asked', () => { + const result = harness('zh'); + const localized = result.current.localize('clm_contract', { + ...SUBMIT, + disabledReason: { en: 'Legal review first.', 'zh-CN': '请先完成法务审核。' }, + }); + expect(localized.disabledReason).toBe('请先完成法务审核。'); + }); + + it('an object-less action reads `globalActions..disabledReason`', () => { + const result = harness('zh', zhBundle); + const localized = result.current.localize(undefined, { + name: 'open_inbox', type: 'url', label: 'Inbox', disabled: true, disabledReason: 'The inbox is offline.', + }); + expect(localized.disabledReason).toBe('收件箱暂不可用。'); + }); + + it('an action that declares no reason gets none, even when the bundle holds one', () => { + const result = harness('zh', zhBundle); + const { disabledReason: _omit, ...undeclared } = SUBMIT; + const localized = result.current.localize('clm_contract', undeclared); + expect('disabledReason' in localized).toBe(false); + }); + + it('a nameless action has no bundle key: its map still collapses to one string', () => { + const result = harness('zh'); + const localized = result.current.localize('clm_contract', { + type: 'script', label: 'Inline', disabled: true, + disabledReason: { en: 'Not yet.', 'zh-CN': '暂不可用。' }, + }); + expect(localized.disabledReason).toBe('暂不可用。'); + }); +}); diff --git a/ui/packages/react/src/hooks/useActionTextLocalizer.ts b/ui/packages/react/src/hooks/useActionTextLocalizer.ts index c9fe002df60..fe61d81db4a 100644 --- a/ui/packages/react/src/hooks/useActionTextLocalizer.ts +++ b/ui/packages/react/src/hooks/useActionTextLocalizer.ts @@ -70,6 +70,13 @@ * is, and the action runner, which has no language, only ever receives a * string. `errorMessage` has no bundle key (the spec's action translation * declares none), so the collapse is all it gets (objectui#12757). + * - **`disabledReason` follows the same declared-only rule** + * (objectstack#26619): the reason a greyed action gives resolves + * `_actions..disabledReason` only when the action declares one, after + * its `I18nLabel` map is collapsed like `label`. A bundle entry for an action + * that declares none is never read, so every such action keeps the + * surface's default reason ("Not available for this record"). It is display + * copy only: nothing here, nor the runner, reads it as a condition. * - **The success copy stays a template.** A `${result.*}` token, in a bundle * entry or in the authored text, passes through this hook untouched: the * action has not run yet, so there is no result to read. The runner fills @@ -99,8 +106,8 @@ export interface ActionTextLocalizerOptions { /** * Returns a copy of `action` with `label` — plus `confirmText`, - * `successMessage`, `errorMessage` and `outcomeMessages` when the action - * declares them — + * `successMessage`, `errorMessage`, `outcomeMessages` and `disabledReason` + * when the action declares them — * resolved from the active locale's bundle, falling back to the authored * literals. `objectName` is the host object the action is drawn on; the * action's own declared `objectName` wins over it when present. @@ -141,7 +148,7 @@ function resolveCopy( } export function useActionTextLocalizer(): ActionTextLocalizer { - const { actionLabel, actionConfirm, actionSuccess, actionOutcome } = useObjectLabel(); + const { actionLabel, actionConfirm, actionSuccess, actionOutcome, actionDisabledReason } = useObjectLabel(); const { language } = useObjectTranslation(); return useMemo(() => { @@ -171,6 +178,15 @@ export function useActionTextLocalizer(): ActionTextLocalizer { if (collapsed[key] !== declaredCopy) changed = true; } + // The greyed action's reason is an `I18nLabel` too, collapsed the same + // way; it never reaches the runner, so it is held apart from the copy + // keys above. + const declaredReason: unknown = (action as any).disabledReason; + if (declaredReason !== undefined) { + collapsed.disabledReason = pickLocalized(declaredReason, language); + if (collapsed.disabledReason !== declaredReason) changed = true; + } + // No name, no translation key: the literals are the answer. They are // still normalized (map to string, last-resort placeholder applied) so // every caller reads one shape. @@ -195,6 +211,9 @@ export function useActionTextLocalizer(): ActionTextLocalizer { if ((action as any).successMessage !== undefined) { out.successMessage = resolveCopy(collapsed.successMessage, (fallback) => actionSuccess(keyObject, name, fallback)); } + if (declaredReason !== undefined) { + out.disabledReason = actionDisabledReason(keyObject, name, collapsed.disabledReason); + } // The map is an authored bag (outcome → copy), so the one "is this a // real bag?" predicate in this package answers whether to read it. const authoredOutcomes: unknown = action.outcomeMessages; @@ -208,5 +227,5 @@ export function useActionTextLocalizer(): ActionTextLocalizer { } return out as T; }) as ActionTextLocalizer; - }, [actionLabel, actionConfirm, actionSuccess, actionOutcome, language]); + }, [actionLabel, actionConfirm, actionSuccess, actionOutcome, actionDisabledReason, language]); } diff --git a/ui/packages/types/src/ui-action.ts b/ui/packages/types/src/ui-action.ts index f426bd9dae4..baf675cc071 100644 --- a/ui/packages/types/src/ui-action.ts +++ b/ui/packages/types/src/ui-action.ts @@ -873,6 +873,16 @@ export interface UIActionSchema { * the author's predicate and lives on the action. */ disabled?: SpecAction['disabled']; + + /** + * The reason the control gives while its declared {@link disabled} predicate + * is TRUE (`ActionSchema.disabledReason`, objectstack#26619): its hover title + * and accessible description, or a menu item's second line. Without it the + * renderers say "Not available for this record". Display copy only, never a + * condition. DERIVED from the spec: an `I18nLabel`, a string or an inline + * per-locale map, the renderers collapse to the active language. + */ + disabledReason?: SpecAction['disabledReason']; /** Expression controlling enabled state (e.g., "hasPermission('edit')") */ enabled?: string;