Skip to content

Commit 13dc413

Browse files
feat(spec): a greyed action says why — disabledReason beside disabled (#26786)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBAQyBVaDQuUj1ZDR9GVydAG)_ Part of #26619 — the spec half. The console half (the greyed action's hover title and accessible description read the key) is the follow-up PR, which closes the card. Clause-②: yes (widening) Metadata-protocol: added Skills-docs: #26790 ## 维护者速读 - **改了什么:** 动作元数据在 `disabled` 旁新增一个可选键 `disabledReason`:按钮置灰时说明原因的那句话(可翻译,字符串或内联多语言映射)。翻译位在 `objects.OBJECT._actions.ACTION.disabledReason`,`translateAction` 会套用译文,`os i18n extract` 会生成这个键。空字符串和纯空白被拒绝。Studio 动作表单加了一行,四种语言的表单文案已补。 - **为什么改:** 裁决 A(6442737825)。现在置灰的按钮只能显示「对此记录不可用」,hotclm 的送审 / 发起签署 / 生效需要说清缺什么。这是规格的一半,控制台读取在下一个 PR。 - **风险与回滚:** 只加可选键,不写这个键的动作解析和显示都不变。它只用于展示:不参与服务端准入,lint 也不把它当判断条件读。控制台 PR 落地前没有渲染方读它(存活账本记为 planned,作者会收到 planned 提示)。回滚就是 revert 本 PR。 - **先看:** `packages/spec/src/ui/action.zod.ts`(新键与空白拒绝),`packages/spec/src/system/i18n-resolver.ts`(`translateAction` 套用译文),`packages/cli/src/utils/i18n-extract.ts`(生成翻译键)。 - **两分钟验证:** `pnpm --filter @objectstack/spec exec vitest run src/ui/action-disabled-reason.test.ts`,预期 16 个用例全部通过,其中一条断言像 CEL 的字符串原样保留、不会被当成表达式。 - **需要你做的:** 无,席位落地。 ## What changes **`ActionSchema.disabledReason`** (`packages/spec/src/ui/action.zod.ts`), an optional key beside `disabled`. It is the sentence a greyed action shows to say why it is unavailable. An action that does not declare it parses exactly as before. - **The name.** `disabledReason`, the card's candidate, kept for three reasons. It pairs with the key it explains (`disabled` → `disabledReason`), the way an author reads the two together. It names the content (why), not one place it lands: the console shows it as a button's hover title, as its accessible description, and as a menu item's second line, so `disabledTooltip` would name one of three. And it is the console's own word already (`useDisabledReason`, `disabledReasonFor`, `DisabledReason` in `renderers/action/disabled-reason.tsx`), so spec and renderer speak one vocabulary. Rejected: `disabledMessage` (reads as the `successMessage` / `errorMessage` family, copy shown after a run), `disabledTooltip`, `disabledHint`. All four, with `disabledText`, are renamed onto the key, on the action and in a translation bundle. - **The shape.** An I18nLabel: `z.union([z.string().refine(NON_BLANK_STRING), InlineLocaleMapSchema])`. The string arm refuses `''` and whitespace-only, with a message that names the default it would replace and says to omit the key instead. The refinement is the shared `NON_BLANK_STRING`, which `refinement-projection.ts` declares, so the published `json-schema/ui/Action.json` carries it (`minLength: 1`, `pattern: "\\S"`) and the dropped-refinement ledger gains no row. A first cut with a `superRefine` added 12 ledger rows; this one adds none. The map arm is `InlineLocaleMapSchema` unchanged, the I18nLabel every other copy key takes. - **Display only.** It never enters server admission: nothing in the runtime reads it, and a disabled action that is called anyway is refused by its own rules (its body or hook, `requiredPermissions`). It is not an expression: a predicate-looking value comes out of the parse as the same string, where `disabled` is lowered to its CEL envelope. `os validate` reads `disabled` and not `disabledReason`, and the lint's literal-member census (`validate-expressions.test.ts`, receiver `action`) stays `['disabled', 'name', 'objectName', 'visible']`. - **Translation slot.** `actionTranslationSchema` (`packages/spec/src/system/translation.zod.ts`) declares `disabledReason` at both addresses, `objects.OBJECT._actions.ACTION` and `globalActions.ACTION`, beside `confirmText` / `successMessage`. `translateAction` (`i18n-resolver.ts`) overlays it the way it overlays `description`: the bundle's sentence for the locale chain, else the authored value. The address lists in both files' docblocks name it. - **Extract.** `collectExpectedEntries` (`packages/cli/src/utils/i18n-extract.ts`) emits the key for a top-level object action, a global action and an action declared inline on an object. It is `pushOptional`, like `description`: an action that gives no reason seeds nothing and is not a coverage gap, because the console has its own default sentence. - **Studio.** `action.form.ts` gains a `disabledReason` row under `disabled` in the Placement section (the plain row `errorMessage` uses, same I18nLabel node). The four `metadata-forms` bundles in `@objectstack/platform-objects` carry its label and help (en, zh-CN, ja-JP, es-ES). - **Liveness.** `liveness/action.json` row `disabledReason`: `planned` with `authorWarn` and an `authorHint`, so `os validate` warns an author who declares it that nothing shows it yet. The framework read points are the evidence, and the four console readers are named as the follow-up that flips it to `live` and drops the warning. - **Docs.** `content/docs/protocol/objectui/actions.mdx` gains the key in the interface block and a "Saying why: `disabledReason`" section; `content/docs/ui/translations.mdx` names disabled reasons on the `_actions` row. - **Generated.** `authorable-surface/ui.json` (`ui/Action:disabledReason`), `liveness/state-counts/action.md`, and four `content/docs/references/**` pages, by `check:generated --fix`. - **Changeset.** `.changeset/26619-action-disabled-reason.md`: `@objectstack/spec`, `@objectstack/cli` and `@objectstack/platform-objects` at `minor` (AGENTS.md step 3: a kind other than `none` takes at least `minor` on a package the diff moves). `@objectstack/lint` changes a comment and a test only, so it publishes nothing. ## Tests - `packages/spec/src/ui/action-disabled-reason.test.ts` (16): the parse (string, locale map, optional), the not-a-condition pin, the I18nLabel refusals shared with `label`, the blank refusal (`''`, whitespace: path and message), the four near-miss renames, the inline action still refusing the key, the translation slot at both addresses, the bundle-side rename, and `translateAction` (translated, global address, fallback to authored, no key added). - `packages/cli/test/i18n-extract-action-disabled-reason.test.ts`: the three emit sites, nothing seeded for an action without a reason, the declared text carried into the bundle and gated until translated, and one fixture through schema → extract → translation schema. - `packages/lint/src/validate-expressions.test.ts`: a bare-field source that is an error on `disabled` produces no finding on `disabledReason`. - `packages/lint/src/lint-liveness-properties.test.ts`: the shipped ledger warns on `disabledReason` at the action, with the row's hint; an action without it draws nothing. Two pins move with the new key: `packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts` (the metadata-form catalog, 688 → 689 after the `main` merge; main's ADR-0142 `queue` / `assignment_rule` rows had moved it 686 → 688) and `packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts` (the action key set, 47 → 48, with `disabledReason` added to the named sample). Local runs (on `aa552ced`, before the `main` merge at `c81d8630e`, which touched none of these packages; the lint fix and the cli typing fix came after and were re-run on `1d2b0252`): <details><summary>Test readings</summary> - `pnpm --filter @objectstack/spec test` (under `os-verify-lock`): Test Files 733 passed (733), Tests 21985 passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck`: exit 0 (check:test-typecheck OK). - `pnpm --filter @objectstack/lint test`: Test Files 167 passed (167), Tests 7423 passed. The first run reded one pin, `validate-translation-references.test.ts` "classifies every key the action translation shape declares": its coverage table needed the new `disabledReason` row (`leaf-copy`, prose), added in `1d2b0252`. - `pnpm --filter @objectstack/lint typecheck`: exit 0. - `pnpm --filter @objectstack/cli exec vitest run --project unit test/i18n-extract test/i18n-coverage`: Test Files 10 passed, Tests 100 passed; the new file 6/6 after the typing fix. - `pnpm --filter @objectstack/cli typecheck`: exit 0 after `1d2b0252` (the first run named 4 TS18048 in the new test file, fixed with optional chaining rather than a debt-ledger row). - `pnpm check:i18n`: OK, 10 packages in sync. - `pnpm --filter @objectstack/spec check:generated`: 2 of 14 stale on the first run (`check:docs`, `check:liveness`), regenerated with `--fix`, committed in `aa552ced`. - Round 2 on `bdac02acf` (contract-review and CI fixes): `@objectstack/spec` test 733/733 files (21985 tests); `@objectstack/lint` test 167/167 files (7425 tests) and typecheck exit 0; `@objectstack/platform-objects` test 96/96 files (1361 tests); `@objectstack/metadata-protocol` test 269 passed, 3 skipped (21580 tests); `check:liveness` OK; `check:generated --fix` regenerated `check:docs` only (the `.describe()` texts). - Round 3 on `c9d6d356f` (delta-review fix `51685f13c`, `main` merged at `5b5e6275d`, reference docs regenerated on the merged tree in `c9d6d356f`): `@objectstack/platform-objects` test 96/96 files (1361 tests); `@objectstack/metadata-protocol` test 269 passed, 3 skipped (21616 tests), the action key-set pin still 48 after the merge; `@objectstack/spec` test 735/735 files (22034 tests); `@objectstack/lint` test 168/168 files (7433 tests); `@objectstack/spec check:generated` all current; `node scripts/check-doc-authoring.mjs` exit 0 (no internal issue id in customer text). </details> ## Acceptance notes - **The renderer half follows.** Between this landing and the console PR an author can declare the key and nothing renders it; the liveness row is `planned` and the author-warning lint says so. The docs section says the console does not show the key yet, names the default reason every greyed action shows until the readers ship, and says `os validate` warns an author who declares it. Direction of the window: spec accepts ahead of the renderer, never the reverse. - **Skills-docs is owed, not done here.** `skills/**` is Tier H. Putting the skills lines in this PR would make the spec half wait for a maintainer approval; the owed files are listed on the `Skills-docs:` line for the seat to file. `skills/objectstack-ui/rules/actions.md` and its eval file are at 0 token headroom, so that card pays under the ruling 「A:先削重复,余额按实测提高」. - **A blank value inside an inline locale map is not refused.** The map arm is `InlineLocaleMapSchema` as every I18nLabel takes it; refusing a blank map value would need a refinement the projection's closed list cannot publish, or a second locale-map schema. The ruling names the empty string, which is refused. ## Verification <details><summary>Derived gates (`dispatch-gates.mjs --commands`, 109 families, run serially on `c81d8630e`)</summary> - 106 exit 0. - 3 exit 3, PREREQUISITE NOT MET (read built output of the whole repo; no full build here): `pnpm --filter @objectstack/spec run check:skill-examples`, `pnpm check:dual-build-cjs-loads`, `pnpm check:i18n-coverage`. NOT MEASURED locally; CI runs them. - `dispatch-gates --ran`: "109 derived famil(ies) accounted for — 106 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3)." - Dogfood: not run. No `!` in the title and no `(narrowing)` arm. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (eslint over the 10 changed source and test files: exit 0; the repo-wide run is CI's). - [x] 4. One issue, one branch, one pull request; `main` merged in at `5b5e6275d` (no rebase), current with `main` at `f7c95cd19`. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzfHuAwrecfjUDtkwcm --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzfHuAwrecfjUDtkwcm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 55b5042 commit 13dc413

27 files changed

Lines changed: 538 additions & 14 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/cli': minor
4+
'@objectstack/platform-objects': minor
5+
---
6+
7+
feat(spec): a greyed action says why — `disabledReason` beside `disabled`
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: added
11+
12+
`ActionSchema` gains one optional key, `disabledReason`: the sentence a greyed action shows to say why it is unavailable, beside the `disabled` predicate that greys it. An action that does not declare it parses exactly as before, and the console keeps its default reason, "Not available for this record".
13+
14+
```ts
15+
defineAction({
16+
name: 'submit_for_approval',
17+
label: 'Submit for approval',
18+
objectName: 'clm_contract',
19+
disabled: "record.legal_review_status != 'approved'",
20+
disabledReason: 'An approved legal review is required before the contract enters approval.',
21+
});
22+
```
23+
24+
- **An `I18nLabel`.** A plain string or an inline locale map (`{ en, 'zh-CN' }`). A blank string is refused at parse: `''` or whitespace only. Omit the key to keep the default. The locale-map arm is the `InlineLocaleMapSchema` every `I18nLabel` takes, so a map is not checked for blank values. `disabledMessage`, `disabledTooltip`, `disabledHint` and `disabledText` are renamed onto it.
25+
- **Translated with the copy keys.** The slot is `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an action with no `objectName`), beside `confirmText` and `successMessage`. `translateAction` overlays it, and `os i18n extract` (`@objectstack/cli`) scaffolds it for an action that declares one and seeds nothing for one that does not.
26+
- **Display only.** It does not change what `disabled` evaluates to and is never read as a condition: `os validate` checks `disabled`, not `disabledReason`. It is not access control either. A call that reaches the server anyway is refused by the action's own rules (its body or hook, `requiredPermissions`), never by this text.
27+
- **Studio.** The action editor's Placement section gains a "Disabled Reason" row under "Disabled", translated in `@objectstack/platform-objects` for zh-CN, ja-JP and es-ES.
28+
29+
Nothing renders the key yet. The console's greyed-action surfaces (the record header, the record section bar, the declared actions bar and the four `action:*` renderers) read it in the follow-up change. Until then the liveness ledger marks it `planned` with `authorWarn`, so `os validate` warns an author who declares it that nothing shows it yet.

‎content/docs/protocol/objectui/actions.mdx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ interface Action {
245245
visible?: ExpressionInput; // Visibility predicate (CEL)
246246
requiresFeature?: string; // Public auth feature flag gate — lowered into `visible` at parse time
247247
disabled?: boolean | ExpressionInput; // Disabled when TRUE (CEL)
248+
disabledReason?: I18nLabel; // Why it is greyed — hover title + accessible description (display only)
248249

249250
// Bulk
250251
bulkEnabled?: boolean; // Apply to multiple selected records
@@ -333,6 +334,21 @@ disabled: "record.status != 'paid'"
333334
Predicates are **bare CEL** — `record.status == 'paid'`, not `${record.status == 'paid'}` and not `{record.status} == 'paid'` (in CEL, `{…}` is a map literal). A `${…}`-wrapped or brace-wrapped predicate is not evaluated as a boolean.
334335
</Callout>
335336

337+
#### Saying why: `disabledReason`
338+
339+
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.
340+
341+
```yaml
342+
name: submit_for_approval
343+
label: Submit for approval
344+
disabled: "record.legal_review_status != 'approved'"
345+
disabledReason: An approved legal review is required before the contract enters approval.
346+
```
347+
348+
- It is an `I18nLabel`: a plain string, translated at `objects.<object>._actions.<action>.disabledReason` (`globalActions.<action>.disabledReason` for an action with no `objectName`), or an inline locale map. `os i18n extract` scaffolds the key.
349+
- A blank string (`''` or whitespace only) is refused at parse. Omit the key to keep the default.
350+
- It is **display only**. It does not change what `disabled` evaluates to, nothing reads it as a condition, and it is not access control: a call that reaches the server anyway is refused by the action's own rules (its body or hook, `requiredPermissions`), never by this text.
351+
336352
#### Evaluation context
337353

338354
Action predicates evaluate against the **record the action is attached to**. Both `record.<field>` and the bare field name resolve to the current record's value:

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,7 @@ const result = ApiMethod.parse(data);
499499
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
500500
| **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. |
501501
| **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. |
502+
| **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. |
502503
| **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. |
503504
| **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. |
504505
| **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. |

‎content/docs/references/kernel/metadata-plugin.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ const result = MetadataBulkResultSchema.parse(data);
346346
| **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
347347
| **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. |
348348
| **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. |
349+
| **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. |
349350
| **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. |
350351
| **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. |
351352
| **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. |

‎content/docs/references/system/translation.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ Translation data for a single field
128128
| **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog |
129129
| **confirmText** | `string` | optional | Translated confirmation prompt |
130130
| **successMessage** | `string` | optional | Translated success toast/message |
131+
| **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) |
131132
| **outcomeMessages** | `Record<string, string>` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
132133
| **params** | `Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Action parameter translations keyed by parameter name |
133134
| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record<string, string> }` | optional | Translations for the action result dialog |
@@ -227,6 +228,7 @@ Translation data for a single object
227228
| **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog |
228229
| **confirmText** | `string` | optional | Translated confirmation prompt |
229230
| **successMessage** | `string` | optional | Translated success toast/message |
231+
| **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) |
230232
| **outcomeMessages** | `Record<string, string>` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
231233
| **params** | `Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Action parameter translations keyed by parameter name |
232234
| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record<string, string> }` | optional | Translations for the action result dialog |
@@ -384,6 +386,7 @@ Translation data for a single object
384386
| **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog |
385387
| **confirmText** | `string` | optional | Translated confirmation prompt |
386388
| **successMessage** | `string` | optional | Translated success toast/message |
389+
| **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) |
387390
| **outcomeMessages** | `Record<string, string>` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
388391
| **params** | `Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Action parameter translations keyed by parameter name |
389392
| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record<string, string> }` | optional | Translations for the action result dialog |
@@ -603,6 +606,7 @@ Translation data for a single object
603606
| **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog |
604607
| **confirmText** | `string` | optional | Translated confirmation prompt |
605608
| **successMessage** | `string` | optional | Translated success toast/message |
609+
| **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) |
606610
| **outcomeMessages** | `Record<string, string>` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
607611
| **params** | `Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Action parameter translations keyed by parameter name |
608612
| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record<string, string> }` | optional | Translations for the action result dialog |
@@ -791,6 +795,7 @@ Translation data for a single object
791795
| **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog |
792796
| **confirmText** | `string` | optional | Translated confirmation prompt |
793797
| **successMessage** | `string` | optional | Translated success toast/message |
798+
| **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) |
794799
| **outcomeMessages** | `Record<string, string>` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
795800
| **params** | `Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Action parameter translations keyed by parameter name |
796801
| **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record<string, string> }` | optional | Translations for the action result dialog |

‎content/docs/references/ui/action.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const result = ActionSchema.parse(data);
5656
| **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. |
5757
| **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. |
5858
| **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. |
59+
| **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. |
5960
| **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. |
6061
| **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. |
6162
| **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. |

‎content/docs/ui/translations.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export default defineStack({
7272
| Picklist option labels | `objects.<name>.fields.<field>.options.<value>` |
7373
| View titles, descriptions, empty states | `objects.<name>._views.<view>` |
7474
| Bulk-action copy on a list view (button, confirm prompt, dialog fields) | `objects.<name>._views.<view>.bulkActions.<def>.label` / `.confirmText` / `.confirmLabel` / `.params.<param>.label` / `.help` / `.placeholder` — a bulk param's hint is `help`, not `helpText` |
75-
| Action labels, confirm text, success messages | `objects.<name>._actions.<action>` |
75+
| Action labels, confirm text, success messages, disabled reasons | `objects.<name>._actions.<action>` |
7676
| Action result dialogs (title / description / acknowledge / field labels) | `objects.<name>._actions.<action>.resultDialog` |
7777
| Form sections | `objects.<name>._sections.<section>` |
7878
| Custom validation-rule messages | `objects.<name>._validations.<rule>.message` |

0 commit comments

Comments
 (0)