You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit eab4bba
Browse filesBrowse the repository at this point in the historyBrowse files
feat(console): a greyed action says the reason its author declared (#26869)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBAQyBVaDQuUj1ZDR9GVydAG)_Fixes#26619 — the console half. The spec half,
`ActionSchema.disabledReason`, landed in #26786.
Clause-②: no
Metadata-protocol: none
Skills-docs: #26790
## 维护者速读
- **改了什么:** 置灰的动作按钮现在显示作者写的
`disabledReason`(悬停提示、读屏描述、菜单项第二行),没写或写了空白时仍显示「对此记录不可用」。覆盖记录页头(含 ⋯
溢出菜单)、记录区块按钮栏、声明动作栏,以及 `action:button` / `action:icon` / `action:group`
/ `action:menu` 四个渲染器。原因文案走和动作其他文案相同的翻译路径:先取翻译包
`objects.对象._actions.动作.disabledReason`,再取作者的内联多语言映射,最后取作者原文。
- **为什么改:** 裁决 A(6442737825)的第二步。规格已在 #26786 落地,这个 PR 让控制台真正读它,hotclm
的送审 / 发起签署 / 生效能说清缺什么。
- **风险与回滚:** 不写这个键的动作显示不变。只有声明的 `disabled`
判断为真时才显示原因;动作执行中、宿主禁用的按钮仍不给原因。纯展示,不参与服务端准入。回滚就是 revert 本 PR。
- **先看:**
`ui/packages/react/src/hooks/useActionTextLocalizer.ts`(解析原因文案),`ui/packages/components/src/renderers/action/disabled-reason.tsx`(四个渲染器的共用钩子),`packages/spec/liveness/action.json`(存活账本改为
live)。
- **两分钟验证:** `cd ui && pnpm exec vitest run
packages/components/src/renderers/action/__tests__/action-disabled-reason-26619.test.tsx`,预期全部通过:写了原因显示原因,中文环境取内联映射的中文,不写时仍是默认句。
- **需要你做的:** 无,席位落地。
## What changes
**The readers.** While an action's DECLARED `disabled` predicate is
TRUE, each surface shows its declared reason where it showed
`actions.notAvailableForRecord`. That generic sentence stays the answer
when the action declares none, or a blank one. The surfaces where
nothing earns a reason are unchanged: a button greyed while its own
action runs, the host's forwarded `disabled`, the host's Edit / Delete,
and the legacy `enabled` leg.
-
`ui/packages/components/src/renderers/layout/containers.tsx#disabledReasonFor`:
the record header, inline and in the ⋯ overflow.
- `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. The reason comes off the same localized copy as the
label.
-
`ui/packages/components/src/renderers/action/disabled-reason.tsx#useDisabledReason`:
the four `action:*` renderers. The hook takes the declared reason as a
second argument, and `action-button.tsx`, `action-icon.tsx`,
`action-group.tsx` (inline and dropdown) and `action-menu.tsx` pass it.
**Translation.** The reason is an `I18nLabel` and is resolved like the
action's other copy:
- `useObjectLabel` (`@object-ui/i18n`) gains
`actionDisabledReason(objectName, actionName, fallback?)`, the
`actionSuffixes` pattern: `objects.<o>._actions.<a>.disabledReason`, or
`globalActions.<a>.disabledReason` with no key object. It reads the same
two addresses `translateAction` and `os i18n extract` use.
- `useActionTextLocalizer` (`@object-ui/react`) collapses a declared
reason's locale map to the active language and asks the bundle, but only
when the action declares the key. That is the `confirmText` rule: a
bundle cannot give a reason to an action whose author wrote none, so
that action keeps the default. The record header, the section bar and
the declared actions bar already run their actions through it.
- The four `action:*` renderers read the action as authored (they do not
localize `label` through the bundle either), so the inline locale map is
the translation they honour. `useDisabledReason` collapses it with
`pickLocalized`.
**Types.** `UIActionSchema.disabledReason` (`@object-ui/types`), derived
from the spec (`SpecAction['disabledReason']`), beside `disabled`.
**Spec side.**
- `packages/spec/liveness/action.json` `disabledReason`: `planned` →
`live`, `evidenceScope: in-repo`, with the readers above as evidence.
`authorWarn` and `authorHint` are dropped in the same edit, so a warned
`live` row cannot red `check:liveness`.
`liveness/state-counts/action.md` was regenerated.
- `packages/lint/src/lint-liveness-properties.test.ts`: the #26619 block
becomes a silence pin. The key is authored beside a property that still
warns (`object.externalSharingModel`, `dead`) in the same call, so the
silence is a reading. The block also asserts the shipped row is `live`,
carries no warning, and cites `containers.tsx#disabledReasonFor`.
- The two `.describe()` texts (`action.zod.ts`, `translation.zod.ts`)
and `content/docs/protocol/objectui/actions.mdx` no longer say that
nothing shows the key. Four `content/docs/references/**` pages were
regenerated by `check:generated --fix`.
**Changeset.** `.changeset/26619-console-disabled-reason.md`, every
moved package at `patch`: `Metadata-protocol: none`, because nothing an
author can write changes (AGENTS.md step 3). `@objectstack/lint` changes
a test only.
## Tests
-
`ui/packages/react/src/hooks/__tests__/useActionTextLocalizer.disabledReason-26619.test.tsx`
(6): authored string, bundle outranks authored, locale map collapsed
(zh), the `globalActions` address, no key added when the action declares
none even with a bundle entry, a nameless action's map collapsed.
-
`ui/packages/components/src/renderers/action/__tests__/action-disabled-reason-26619.test.tsx`:
for the three button renderings, the declared string as description and
tooltip, the zh map, the default (en and zh), a blank map value falling
back to the default, and the predicate-false control. For the two menu
renderings, the declared second line, the default, and the control.
-
`ui/packages/components/src/__tests__/page-header-disabledReason-26619.test.tsx`:
inline, overflow, the zh bundle outranking the authored text, the
default, and the control.
-
`ui/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.disabledReason-26619.test.tsx`
and
`ui/packages/app-shell/src/views/__tests__/DeclaredActionsBar.disabledReason-26619.test.tsx`:
the declared string, the zh bundle, the default, and the control.
- The `-11811` / `-11839` sibling suites, which pin the mechanics and
the no-reason controls, pass unchanged.
<details><summary>Test readings (local, on the pushed head)</summary>
- Head `d96cc5bbb` (`f6a479a30` plus the i18n pin), branch restarted
from `main` at `aeb9cf0dd`.
- `pnpm --filter @objectstack/spec test`: Test Files 735 passed (735),
Tests 22041 passed, 1 todo.
- `pnpm --filter @objectstack/lint test`: Test Files 168 passed (168),
Tests 7432 passed.
- `pnpm --filter @objectstack/spec check:liveness`: OK. `check:generated
--fix`: 2 of 14 stale on the first run (`check:docs`, `check:liveness`),
regenerated and committed. `node scripts/check-doc-authoring.mjs`: exit
0.
- `pnpm --filter
@object-ui/{types,i18n,react,components,plugin-detail,app-shell}
type-check`: all exit 0, after building `@object-ui/app-shell^...`.
- `cd ui && pnpm exec vitest run packages/types/ packages/i18n/
packages/react/ packages/components/ packages/plugin-detail/
packages/app-shell/src/views/__tests__/`: Test Files 1442 passed, 1
failed, 2 skipped; Tests 21406 passed, 1 failed. The one failure was a
pin that moves with the new resolver:
`useObjectLabel-identity-5564.test.tsx` counts `useObjectLabel`'s
members (27 → 28). It is updated in `d96cc5bbb` and passes (5/5).
- `ui/packages/core` was not run locally. On `main`, its
`actionKeys.pin.test.ts` reds on the new spec key (SPEC_ACTION_KEYS
lacks `disabledReason`). The seat's hotfix #26859 fixes that, and this
branch takes it by a `main` merge once it lands. This PR does not add
the row.
- eslint over the 17 changed ui files: 0 errors (warnings are
pre-existing `no-explicit-any`).
</details>
## Acceptance notes
- **The `action:*` renderers honour the inline map, not the bundle.**
They render `label` as authored too. Giving them a bundle lookup would
also need a key object they do not carry, and that is a wider change
than this card. A registered action that reaches them through a surface
that localizes (the record header, the section bar, the declared actions
bar) already arrives with the bundle's sentence.
- **A blank reason falls back to the default** on every surface,
including a locale map whose value for the active language is
whitespace. The spec refuses a blank string but not a blank map value
(#26786's acceptance note).
## 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; eslint over the 17 changed ui files: 0
errors.
- [x] 4. One issue, one branch restarted from `main` after #26786
merged, one pull request.
- [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>
feat(console): a greyed action says the reason its author declared
12
+
13
+
Clause-②: no
14
+
Metadata-protocol: none
15
+
16
+
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.
disabledReason: 'An approved legal review is required before the contract enters approval.',
25
+
});
26
+
```
27
+
28
+
-**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.
29
+
-**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.
30
+
-**Typed.**`UIActionSchema` (`@object-ui/types`) declares `disabledReason`, derived from the spec's `ActionSchema`.
31
+
-**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.
Copy file name to clipboardExpand all lines: content/docs/protocol/objectui/actions.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -336,7 +336,7 @@ Predicates are **bare CEL** — `record.status == 'paid'`, not `${record.status
336
336
337
337
#### Saying why: `disabledReason`
338
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.
339
+
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".
Copy file name to clipboardExpand all lines: content/docs/references/data/object.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -499,7 +499,7 @@ const result = ApiMethod.parse(data);
499
499
|**requiresFeature**|`Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>`| optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
500
500
|**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. |
501
501
|**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. |
502
+
|**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. |
503
503
|**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. |
504
504
|**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. |
505
505
|**recordIdParam**|`string`| optional | Body key to inject the row id into when running from a list_item context. |
Copy file name to clipboardExpand all lines: content/docs/references/kernel/metadata-plugin.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -346,7 +346,7 @@ const result = MetadataBulkResultSchema.parse(data);
346
346
|**requiresFeature**|`Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>`| optional | Public auth feature flag gating this action; lowered into `visible` at parse time. |
347
347
|**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. |
348
348
|**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. |
349
+
|**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. |
350
350
|**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. |
351
351
|**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. |
352
352
|**recordIdParam**|`string`| optional | Body key to inject the row id into when running from a list_item context. |
|**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)|
131
+
|**disabledReason**|`string`| optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE |
132
132
|**outcomeMessages**|`Record<string, string>`| optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
|**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)|
231
+
|**disabledReason**|`string`| optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE |
232
232
|**outcomeMessages**|`Record<string, string>`| optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
|**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)|
389
+
|**disabledReason**|`string`| optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE |
390
390
|**outcomeMessages**|`Record<string, string>`| optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
|**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)|
609
+
|**disabledReason**|`string`| optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE |
610
610
|**outcomeMessages**|`Record<string, string>`| optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
|**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)|
798
+
|**disabledReason**|`string`| optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE |
799
799
|**outcomeMessages**|`Record<string, string>`| optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares |
Copy file name to clipboardExpand all lines: content/docs/references/ui/action.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -56,7 +56,7 @@ const result = ActionSchema.parse(data);
56
56
|**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. |
57
57
|**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. |
58
58
|**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. |
59
+
|**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. |
60
60
|**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. |
61
61
|**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. |
62
62
|**recordIdParam**|`string`| optional | Body key to inject the row id into when running from a list_item context. |
0 commit comments