From d10d1a20efb9ce047bcd03b8a84ff4caa80407f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 22:06:21 +0000 Subject: [PATCH] =?UTF-8?q?fix(components,plugin-detail):=20=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=20disabled=20=E7=9A=84=E3=80=8C=E5=B7=B2=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E3=80=8D=E5=88=A4=E5=AE=9A=E5=89=A9=E4=BD=99=E4=BA=94?= =?UTF-8?q?=E5=A4=84=E6=94=B9=E8=AF=BB=20hasDeclaredVisibilityGate=20(#384?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3842 / PR #3851 只修了 action:button 与 app-shell 的 DeclaredActionsBar,同形 的另外五处仍停在 `!= null`:同一个组件里 visible 门问「已声明」,隔一行的 disabled 门问 `!= null`,一个问题两种拼法。 - packages/components:action:icon、action:group 的内联按钮与下拉项、 action:menu 的菜单项(四处,均含 legacy `enabled` 腿) - packages/plugin-detail:record:quick_actions 的 QuickActionButton(无 `enabled` 腿) 机理与 #3842 逐字相同:`toPredicateInput('')` 为 `undefined`, `evaluateCondition(undefined)` 为 `true`。这个 `true` 在 visible 上意味着「显示」 (与过宽的「已声明」判定互相抵消),在 disabled 上意味着「禁用」——于是两个错误 叠加,`disabled: ''`(空谓词,等于没声明)变成永久置灰,作者写什么都解不开。 五处统一改读 hasDeclaredVisibilityGate(`!= null && !== ''`):包内三个文件从 `./visibility-gate` 相对取,plugin-detail 从 @object-ui/components barrel 取 (#3835 开的跨包路子)。按 #3842 裁定不重命名、不加别名、不上提 core,每处落点 就地注释说明。 行为变更面刻意窄:只有 `disabled: ''` 从「置灰」变「可点」。`disabled: true` 仍 置灰,`disabled: false` 与未声明仍不置灰,表达式取值的 verdict 一律不变。四处带 legacy `enabled` 腿的落点多一条推论:空 `disabled` 现在落到 `enabled` 腿而不再在 空谓词上短路。`enabled` 腿本身取反,四形状在新旧判定下同 verdict,属推演等价而非 修复(#3842 的推演表连同「无任何 enabled 用例能因还原该腿而变红」一并抄在钉子旁)。 反向验证:单还原 action-icon 一处的 `!= null` 后,恰好两条钉子变红且都点名 action:icon(`disabled: ''` 与「空 disabled 落到 enabled 腿」),其余四处与 record:quick_actions 全绿——方向与 issue 的预判一致。 --- ...tion-member-declared-disabled-gate-3849.md | 50 ++++ ...ion-member-disabled-declared-gate.test.tsx | 236 ++++++++++++++++++ .../src/renderers/action/action-group.tsx | 22 +- .../src/renderers/action/action-icon.tsx | 19 +- .../src/renderers/action/action-menu.tsx | 11 +- ...ck-actions.disabled-declared-gate.test.tsx | 91 +++++++ .../src/renderers/record-quick-actions.tsx | 14 +- 7 files changed, 433 insertions(+), 10 deletions(-) create mode 100644 .changeset/action-member-declared-disabled-gate-3849.md create mode 100644 packages/components/src/renderers/action/__tests__/action-member-disabled-declared-gate.test.tsx create mode 100644 packages/plugin-detail/src/renderers/__tests__/record-quick-actions.disabled-declared-gate.test.tsx diff --git a/.changeset/action-member-declared-disabled-gate-3849.md b/.changeset/action-member-declared-disabled-gate-3849.md new file mode 100644 index 0000000000..f66b66875c --- /dev/null +++ b/.changeset/action-member-declared-disabled-gate-3849.md @@ -0,0 +1,50 @@ +--- +"@object-ui/components": patch +"@object-ui/plugin-detail": patch +--- + +`disabled: ''` no longer greys out the remaining five action surfaces (objectui#3849) + +objectui#3842 / PR #3851 fixed the "is a `disabled` gate DECLARED?" test on +`action:button` and app-shell's `DeclaredActionsBar`. Five same-shaped sites were +outside that PR's scope and stayed on `!= null`, so within one component the +`visible` gate asked `hasDeclaredVisibilityGate` while the `disabled` gate on the +next line asked `!= null` — two spellings of one question: + +- `@object-ui/components` — `action:icon`, `action:group`'s inline button + (`InlineActionButton`) and dropdown item (`DropdownActionItem`), and + `action:menu`'s item (`ActionMenuItem`). +- `@object-ui/plugin-detail` — `record:quick_actions`' `QuickActionButton`. + +Why the missing `!== ''` half is a defect on this key and not on `visible`: +`toPredicateInput('')` is `undefined` and `evaluateCondition(undefined)` is +`true`. On `visible` that `true` means SHOW, so an over-broad "declared" test and +a permissive empty predicate cancel out. On `disabled` it means DISABLE, so they +compound — `disabled: ''` (an empty predicate: nothing declared) rendered a +permanently greyed-out control, with nothing the author could write to un-grey +it. Unlike #3842's approvals inbox, these five are the general action face +(toolbars, dropdowns, record quick actions), so the reach is wider even though no +single high-value host owns them. + +**Behaviour change surface, deliberately narrow.** Only `disabled: ''` changes — +from disabled to clickable, which is what "no predicate" asked for. `disabled: +true` still disables, `disabled: false` and an absent `disabled` still do not, and +no expression-valued `disabled` changes verdict. On the four sites that also carry +the legacy non-spec `enabled` fallback, one consequence follows: an empty +`disabled` now falls THROUGH to that leg instead of short-circuiting on the empty +predicate, so an action spelling both (`disabled: ''` + `enabled: true`) becomes +clickable. `record:quick_actions` has no `enabled` leg, so its chain is the single +gate. + +Routing those legacy `enabled` legs through the same definition is +behaviour-preserving by derivation rather than a fix: the leg is negated +(`disabled = !isEnabled`), so an empty predicate's `true` already arrived as "not +disabled" — the verdict "no gate declared" produces. #3842's four-shape derivation +table is reproduced next to the new pins, together with the statement that no +`enabled` case can go red by reverting that leg. + +`hasDeclaredVisibilityGate` keeps its historic name (the objectui#3842 ruling): the +predicate is key-neutral, and one implementation behind two names is how a repo +grows dialects. The three `@object-ui/components` sites import it relatively; +`record:quick_actions` takes it from the package barrel, the cross-package route +objectui#3835 opened. Every call site says so in a comment. diff --git a/packages/components/src/renderers/action/__tests__/action-member-disabled-declared-gate.test.tsx b/packages/components/src/renderers/action/__tests__/action-member-disabled-declared-gate.test.tsx new file mode 100644 index 0000000000..0cc3880a03 --- /dev/null +++ b/packages/components/src/renderers/action/__tests__/action-member-disabled-declared-gate.test.tsx @@ -0,0 +1,236 @@ +/** + * 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. + */ + +/** + * objectui#3849 — the declared-`disabled` gate on the four remaining action-face + * sites. objectui#3842 / PR #3851 fixed `action:button` (and app-shell's + * `DeclaredActionsBar`); these four stayed on `!= null`, so the same component + * asked "is a gate DECLARED?" one way for `visible` and another for `disabled`. + * + * The mechanism is #3842's, verbatim: `toPredicateInput('')` is `undefined` and + * `evaluateCondition(undefined)` is `true`. On `visible` that `true` means SHOW, + * so an over-broad "declared" test and a permissive empty predicate cancel out. + * On `disabled` the same `true` means DISABLE, so they compound — `disabled: ''` + * (an empty predicate, i.e. nothing declared) became "greyed out forever", with + * nothing the author could write to un-grey it. All four now read + * `hasDeclaredVisibilityGate` (`!= null && !== ''`), imported rather than + * re-spelled; historic name kept per the #3842 ruling. + * + * ## What each case detects + * + * • `disabled: ''` → NOT disabled. THE defect, and a genuine mutation + * detector on this key: restore `!= null` at one site and that site's `''` + * case alone goes red (the reverse-verification this PR ran). + * • `disabled: true` → disabled; `disabled: false` / undeclared → not + * disabled. Anti-mutation guards: "never disable anything" satisfies three + * of the four shapes on its own, and `true` is what refuses it. + * • expression-valued `disabled` → the verdict still decides, both ways. The + * gate narrowed; evaluation did not change. + * + * ## The legacy `enabled` leg — four cases that are documentation, one that moves + * + * The leg is NEGATED (`disabled = !isEnabled`), so an empty predicate's `true` + * arrives as `!true` = "not disabled", which is exactly what "no gate declared" + * produces. Every shape reaches the same verdict under either test, so the + * tightening is behaviour-preserving by derivation (#3842's table): + * + * | `enabled` | `!= null` (old) | `hasDeclaredVisibilityGate` (new) | + * |-------------|---------------------------|-----------------------------------| + * | `''` | gate → `!true` = enabled | no gate → `false` = enabled | + * | `true` | gate → `!true` = enabled | gate → `!true` = enabled | + * | `false` | gate → `!false` = DISABLED| gate → `!false` = DISABLED | + * | undeclared | no gate → `false` | no gate → `false` | + * + * Stated plainly rather than dressed up as coverage: no `enabled` case here can + * go red by reverting the `enabled` leg. They are kept because they pin the + * semantics the derivation asserts (`enabled: false` must still disable), which + * a future rewrite of this chain would otherwise break silently. The case that + * DOES move is precedence: with `disabled: ''` no longer a gate, the chain falls + * through to the legacy leg instead of short-circuiting on an empty predicate. + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { PredicateScopeProvider } from '@object-ui/react'; +// Module-scope side-effect imports so the two hosts are in the registry when +// `ComponentRegistry.get` runs — the light `dom` project deliberately does not +// load the `@object-ui/components` graph. Module scope, not a `beforeAll`, per +// AGENTS.md §测试纪律: the cost lands in the import phase, unbounded by any +// hook timeout. +import '../action-icon'; +import { DropdownActionItem } from '../action-group'; +import { ActionMenuItem } from '../action-menu'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../../../ui'; + +const LABEL = 'Act'; +const ACT = { name: 'act', label: LABEL, type: 'script' }; + +/** An ungated companion — a passing assertion must not mean "the host vanished". */ +const COMPANION = { name: 'view', label: 'View', type: 'script' }; + +function getRenderer(type: string) { + const R = ComponentRegistry.get(type); + if (!R) throw new Error(`${type} is not registered`); + return R; +} + +/** + * Site 1 — `action:icon`, mounted the way `action:bar` mounts a member: the + * whole action spread onto the leaf's own `schema` (`action-bar.tsx` resolves + * the renderer from the registry itself, so this gate is the only one on that + * path — the reachability #3823 established for the `visible` half of the same + * two lines). + */ +function mountIcon(action: any, scope: Record = {}) { + const Renderer = getRenderer('action:icon'); + return render( + + + , + ); +} + +/** + * Site 2 — `InlineActionButton`, through the real `action:group` host so the + * member gate is observed where it runs (the group `.map()`s its own `actions`; + * neither `SchemaRenderer` nor `ActionEngine` is in this path). + */ +function mountInlineGroup(action: any, scope: Record = {}) { + const Group = getRenderer('action:group'); + return render( + + + , + ); +} + +/** + * Sites 3 and 4 — the two dropdown leaves, each inside a controlled-open menu so + * the portal content mounts deterministically (Radix opens on pointerdown, flaky + * to synthesize in happy-dom). Same harness as + * `action-group-dropdown-visible.test.tsx` and `action-member-visible-gate.test.tsx`. + */ +function mountInMenu(node: React.ReactNode, scope: Record = {}) { + return render( + + + menu + {node} + + , + ); +} + +/** A `