Repository navigation
fix(app-shell): a single-record list header action is disabled with its reason unless exactly one row is selected - #27894
Conversation
…ed actions (repro) Claude-Session: https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh Co-authored-by: Claude <noreply@anthropic.com>
…s launch would end Claude-Session: https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh Co-authored-by: Claude <noreply@anthropic.com>
launchRefusal's docblock was inserted below refusalBeforeInput's, so the private method lost its doc and launchRefusal carried two. Comment-only. Claude-Session: https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh Co-authored-by: Claude <noreply@anthropic.com>
|
domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh |
|
domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh Contract reviewServed-tier: Face: declaration only. The PR body, the changeset and claim 6512388820 (amended 2026-10-30T02:56Z) each say ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh 席位核对ACCEPT · head
Generated by Claude Code |
domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh
Requested by Bill · project thread
Fixes #27831
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
ui/packages/app-shell/src/views/objectViewHeaderLaunchGate.tsx(页头门控)、ui/packages/core/src/actions/ActionRunner.ts(新增launchRefusal/subscribeContext)、ui/packages/plugin-grid/src/ObjectGrid.tsx(发布selectionAvailable的 effect 与上移的那段)。pnpm --dir ui exec vitest run packages/app-shell/src/views/ObjectView.headerSelectionGate-27831.test.tsx,预期 7 个用例全绿(0 行禁用并带理由、1 行可点并发出请求、2 行禁用、中文理由、不可勾选的列表不画、两个对照不受影响)。What changes
On a list, a header action that runs on a single record is now drawn the way a launch of it would end:
actions.selectRowFirst/actions.selectExactlyOneRowkeys. No new i18n key.execute(a?runAction=deep link, a surface that does not draw through the gate).How the header knows an action is record-scoped, with no second rule. The header asks the action runner, never
isRecordScopedActiondirectly.ActionRunner.launchRefusal(action)(new, public) is exactly the refusalexecuteasks before the confirmation and the param dialog: it resolves the dispatch type the wayexecutedoes (resolveDispatchType), asks that handler'srefuseBeforeInput, and translates the answer by itserrorKey. For the console'sscriptandflowhandlers that is@object-ui/core'sresolveServerActionRecordId, which readsisRecordScopedActionand the selection. Asking runs, sends and shows nothing. A core test holdslaunchRefusalequal to whatexecuterefuses for the same context.Where the selection count lives.
ObjectGridalready publishes its checkbox selection into the runner context (selectedRecords, throughuseAction().updateContext). It now also publishesselectionAvailable(whether its rows can be selected at all, i.e. its resolved selection mode is not off), and clears it on unmount. To publish while the grid is still loading, the existing selection-mode computation (bulk-action resolution included) moved, unchanged, above the grid's early returns; it is hook-free and reads only values declared before its new position.ActionRunner.subscribeContext(listener)(new, public) lets the header redraw when the grid publishes. The header reads two stable snapshots throughuseSyncExternalStore(the grid's selection array and the boolean), so an unrelated context write redraws nothing.Where it is drawn.
ListHeaderActionBar(objectViewHeaderLaunchGate.tsx) wraps the threeaction:barrender points of theObjectViewheader: the toolbar inObjectViewPageHeader, and the primary toolbar action and folded menu inuseObjectViewHeaderChrome. It sets thedisabled: true/disabledReasonpair theaction:*renderers already draw (tooltip trigger andaria-describedbyfromobjectui#11839), so no renderer changed. A host callback (onClick, such as Import in the folded menu) never reaches the runner and is never gated.Files:
ui/packages/core/src/actions/ActionRunner.ts:launchRefusal,subscribeContext, and the notification inupdateContext.ui/packages/types/src/ui-action.ts:ActionContext.selectionAvailable?: boolean.ui/packages/plugin-grid/src/ObjectGrid.tsx: publishesselectionAvailable; the selection-mode block moved above the early returns.ui/packages/app-shell/src/views/objectViewHeaderLaunchGate.tsx(new),objectViewPageHeader.tsx,objectViewHeaderChrome.tsx: the gate and its three render points.ObjectView.headerSelectionGate-27831.test.tsx(new),ActionRunner.launchRefusal-27831.test.ts(new),ObjectView.toolbarScriptSelection-12331.test.tsx(updated pin)..changeset/27831-list-header-selection-gate.md(patch).Clause-②: yes (widening): two new public members onActionRunnerand one optionalActionContextmember, all additive. The claim saidno; the order allows the widening arm when an export is needed, and these are needed, because the header cannot reach the runner's private refusal or learn of a context change otherwise.Tests
The measured repro on
main(fec60e00) comes first. The new ObjectView suite was run against the base sources (the five non-test files restored fromfec60e00, then put back fromHEAD;git diff HEADwas empty afterwards). It showed 4 failed | 3 passed (7). On main, 0 rows:expect(element).toBeDisabled()got "Received element is not disabled". 2 rows and zh: the same. No-selection list: "expected button … to be null". 1 row and both controls passed on main.New and changed pins, through the real
ObjectView, grid,action:bar/action:buttonand console action runtime:recordId.actions.selectRowFirst.selection: { type: 'none' }): the record-scoped action is not drawn.list_toolbaraction is enabled with 0 rows and dispatches. It is also drawn on the no-selection list.launchRefusalfor 0, 1 and 2 rows and for no published selection. It equals whatexecuterefuses, followsupdateContext, and is translated by key. A typeless declaration routes to the spec default handler. Controls: an object-level action, and a handler with no early refusal.subscribeContextnotifies, and stops after unsubscribe.ObjectView.toolbarScriptSelection-12331: its 0- and 2-row cases now read the refusal off the disabled header button instead of a toast after a click. No dialog opens and no request is sent, as before.Ablation (
node scripts/ablation-replace.mjs): the gate'srunner.launchRefusal(…)call was replaced byundefined. The anchor went 1 → 0, and the blob went3c7bbfc1872a→51a18c64a2c5. The suite went 4 failed | 3 passed: the same four cases as on main. The file was restored to blob == HEAD withgit diff HEADempty. app-shell tests resolve@object-ui/*tosrcthrough theui/vitest.config.mtsaliases, so nodistwas involved.Local runs
pnpm --dir ui exec vitest run packages/core/src/actions/ packages/plugin-grid/: 307 files, 2967 tests passed.list_toolbar, the header chrome,selectedRecordsorselectionAvailable; everyObjectView*test; theaction:*renderer tests; and react, components and plugin-list tests namingselectedRecords/updateContext. 127 files, 1440 tests passed.pnpm --filter @object-ui/{types,core,plugin-grid,app-shell} type-check: all exit 0.origin/main, the rerun on the final head is listed under Verification.Acceptance notes
list_toolbaralone is drawn disabled when several rows are selected, with the "select exactly one row" reason, because a click is refused the same way today: the resolver refuses any non-aggregate launch with more than one row. This follows the resolver and adds no rule. With 0 rows such an action is unaffected.?runAction=naming a record-scoped action on a no-selection list is not drawn, so it does not run. The arming inuseOfferedNavRunActionreads the ungated list, so by reading that hook (not measured) the link is still consumed. On a selectable list a disabled action still auto-triggers once and is refused by the backstop, asObjectView.bulkWiredHeaderDuplicate-12462pins.EnvironmentListToolbar(sys_environment) draws its own bar fromheaderToolbarActionsand is not routed through the gate.properties.paramson a header action are not part of what the gate asks. The resolver readsparams.recordIdonly from the forwarded values, and alist_toolbaraction authoring a staticrecordIdis not a shape found in the tree.ui/AGENTS.md✨ Set up Copilot instructions #2 (docs-driven): no docs page describesActionRunner's context members or the early refusal, so none was updated.Verification
Final head
d78f7954(origin/mainmerged once,pnpm install --frozen-lockfileand the app-shell build closure rebuilt):node scripts/pm/dispatch-gates.mjs --commands: 35 derived families, each run on this head, allexit 0.--ranreconciliation: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN.node scripts/check-sdui-manifest.mjsis among them.pnpm --dir ui check:new-line-citations: 0 new citations.ObjectViewheader suites (27831, 12331, 12462), the corelaunchRefusal-27831andrecordRowGuard-13384suites, the consoleflowRowGuard-13397suite and everyaction:*renderer suite. 43 files, 665 tests passed.pnpm --filter @object-ui/app-shell type-check: exit 0.Test UIshards,Lint UIand the repo lint. ESLint on the changed files reports one error, at a lineObjectGridhad before this change (react-hooks/static-componentsunder--no-inline-config; the base blob reports the same error). The new files report none.Generated by Claude Code