Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/26619-console-disabled-reason.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/protocol/objectui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` | 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<string, string>` | 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<string, object>; … }` | 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. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/kernel/metadata-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` | 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<string, string>` | 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<string, object>; … }` | 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. |
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
| **params** | `Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Action parameter translations keyed by parameter name |
| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record<string, string> }` | optional | Translations for the action result dialog |
Expand Down Expand Up @@ -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<string, string>` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
| **params** | `Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Action parameter translations keyed by parameter name |
| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record<string, string> }` | optional | Translations for the action result dialog |
Expand Down Expand Up @@ -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<string, string>` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
| **params** | `Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Action parameter translations keyed by parameter name |
| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record<string, string> }` | optional | Translations for the action result dialog |
Expand Down Expand Up @@ -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<string, string>` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
| **params** | `Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Action parameter translations keyed by parameter name |
| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record<string, string> }` | optional | Translations for the action result dialog |
Expand Down Expand Up @@ -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<string, string>` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
| **params** | `Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Action parameter translations keyed by parameter name |
| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record<string, string> }` | optional | Translations for the action result dialog |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` | 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<string, string>` | 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<string, object>; … }` | 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. |
Expand Down
Loading
Loading