Skip to content

fix(app-shell): a single-record list header action is disabled with its reason unless exactly one row is selected - #27894

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-j9w213
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-j9w213

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh

Requested by Bill · project thread

Fixes #27831
Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:列表页头上的「单条记录」动作(如 Tasks → Reassign…),没选行或选了多行时画成禁用,悬停提示就是点击后原本才会弹出的那句拒绝理由(按会话语言);恰好选一行才可点;行根本不能勾选的列表上不再画这个按钮。
  • 为什么改:以前没选行时按钮看起来可用,点了才说「请先选择一行」;成员看到的表格没有勾选框,这个按钮对他永远是死路。
  • 风险与回滚:页头只是向动作执行器问「现在点会不会被拒」,规则还是原来那一条,点击时的拒绝仍作兜底;回滚即还原本 PR。ObjectGrid 里一段计算选择模式的代码整体上移到提前返回之前(逻辑未改)。
  • 先看: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:

  • 0 rows, or more than one row: disabled. Its tooltip and accessible description are the early refusal a click already gave ("This action runs on a single record — select a row first." / "… select exactly one row."), in the session's language, from the existing actions.selectRowFirst / actions.selectExactlyOneRow keys. No new i18n key.
  • Exactly one row: enabled, and it runs as before.
  • A list whose rows cannot be selected: not drawn.
  • The early refusal stays as the backstop for any launch that still reaches 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 isRecordScopedAction directly. ActionRunner.launchRefusal(action) (new, public) is exactly the refusal execute asks before the confirmation and the param dialog: it resolves the dispatch type the way execute does (resolveDispatchType), asks that handler's refuseBeforeInput, and translates the answer by its errorKey. For the console's script and flow handlers that is @object-ui/core's resolveServerActionRecordId, which reads isRecordScopedAction and the selection. Asking runs, sends and shows nothing. A core test holds launchRefusal equal to what execute refuses for the same context.

Where the selection count lives. ObjectGrid already publishes its checkbox selection into the runner context (selectedRecords, through useAction().updateContext). It now also publishes selectionAvailable (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 through useSyncExternalStore (the grid's selection array and the boolean), so an unrelated context write redraws nothing.

Where it is drawn. ListHeaderActionBar (objectViewHeaderLaunchGate.tsx) wraps the three action:bar render points of the ObjectView header: the toolbar in ObjectViewPageHeader, and the primary toolbar action and folded menu in useObjectViewHeaderChrome. It sets the disabled: true / disabledReason pair the action:* renderers already draw (tooltip trigger and aria-describedby from objectui#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 in updateContext.
  • ui/packages/types/src/ui-action.ts: ActionContext.selectionAvailable?: boolean.
  • ui/packages/plugin-grid/src/ObjectGrid.tsx: publishes selectionAvailable; 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.
  • Tests: 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 on ActionRunner and one optional ActionContext member, all additive. The claim said no; 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 from fec60e00, then put back from HEAD; git diff HEAD was 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:button and console action runtime:

  • 0 rows: disabled, with the zero-row refusal as its description. A click sends nothing.
  • 1 row: enabled, and a click POSTs the row's recordId.
  • 2 rows: disabled, with the "exactly one row" refusal.
  • zh session: the reason is the zh pack's actions.selectRowFirst.
  • No-selection grid (selection: { type: 'none' }): the record-scoped action is not drawn.
  • Control: an object-level list_toolbar action is enabled with 0 rows and dispatches. It is also drawn on the no-selection list.
  • Core: launchRefusal for 0, 1 and 2 rows and for no published selection. It equals what execute refuses, follows updateContext, 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. subscribeContext notifies, 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's runner.launchRefusal(…) call was replaced by undefined. The anchor went 1 → 0, and the blob went 3c7bbfc1872a → 51a18c64a2c5. The suite went 4 failed | 3 passed: the same four cases as on main. The file was restored to blob == HEAD with git diff HEAD empty. app-shell tests resolve @object-ui/* to src through the ui/vitest.config.mts aliases, so no dist was involved.

Local runs
  • pnpm --dir ui exec vitest run packages/core/src/actions/ packages/plugin-grid/: 307 files, 2967 tests passed.
  • 127 targeted files: every app-shell test naming list_toolbar, the header chrome, selectedRecords or selectionAvailable; every ObjectView* test; the action:* renderer tests; and react, components and plugin-list tests naming selectedRecords / updateContext. 127 files, 1440 tests passed.
  • pnpm --filter @object-ui/{types,core,plugin-grid,app-shell} type-check: all exit 0.
  • After merging origin/main, the rerun on the final head is listed under Verification.

Acceptance notes

  • Object-level actions with several rows. An action declared at list_toolbar alone 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.
  • Phone card view. On a phone, the grid's card list draws no checkboxes, but the grid's selection mode is still on, so a record-scoped header action is drawn disabled with its reason (in the overflow menu) rather than left out. This is accurate but not ideal. It is noted, not changed.
  • Non-grid views (kanban, calendar …) publish no selection into the runner, so a record-scoped header action there is drawn disabled with the zero-row reason. A click is refused the same way today.
  • Deep link. A ?runAction= naming a record-scoped action on a no-selection list is not drawn, so it does not run. The arming in useOfferedNavRunAction reads 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, as ObjectView.bulkWiredHeaderDuplicate-12462 pins.
  • EnvironmentListToolbar (sys_environment) draws its own bar from headerToolbarActions and is not routed through the gate.
  • Static properties.params on a header action are not part of what the gate asks. The resolver reads params.recordId only from the forwarded values, and a list_toolbar action authoring a static recordId is not a shape found in the tree.
  • ui/AGENTS.md ✨ Set up Copilot instructions #2 (docs-driven): no docs page describes ActionRunner's context members or the early refusal, so none was updated.

Verification

Final head d78f7954 (origin/main merged once, pnpm install --frozen-lockfile and the app-shell build closure rebuilt):

  • node scripts/pm/dispatch-gates.mjs --commands: 35 derived families, each run on this head, all exit 0. --ran reconciliation: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. node scripts/check-sdui-manifest.mjs is among them.
  • pnpm --dir ui check:new-line-citations: 0 new citations.
  • Targeted rerun: the three ObjectView header suites (27831, 12331, 12462), the core launchRefusal-27831 and recordRowGuard-13384 suites, the console flowRowGuard-13397 suite and every action:* renderer suite. 43 files, 665 tests passed.
  • pnpm --filter @object-ui/app-shell type-check: exit 0.
  • Not run locally, left to CI: the full Test UI shards, Lint UI and the repo lint. ESLint on the changed files reports one error, at a line ObjectGrid had before this change (react-hooks/static-components under --no-inline-config; the base blob reports the same error). The new files report none.

Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 30, 2026
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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh
Contract-review-claim: session_01Utp6CbDAucNyQ4UtzCJKyh · head 666e9df · 2026-10-30T03:12Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 666e9df6b78abc06745a974dcf3b16926730fce1
Local-runs: none

Face: declaration only. The PR body, the changeset and claim 6512388820 (amended 2026-10-30T02:56Z) each say Clause-②: yes (widening). The tree touches no packages/spec/src/**, no governed path and no narrowing line. Read against origin/main: 10 files, +699/−95. Inputs: the card, dev report 6513799481.

① Derived judgments

  • @object-ui/core (published). ActionRunner gains two public methods:
    • launchRefusal(action) returns the same early refusal execute asks before its confirmation and param dialog. It resolves the dispatch type with the same resolveDispatchType call execute uses and then calls the existing private refusalBeforeInput. It runs, sends and shows nothing.
    • subscribeContext(listener) notifies after every updateContext and returns an unsubscribe function.
    • No existing signature or behaviour changes: execute still asks refusalBeforeInput itself, so the early refusal stays the backstop. Additive.
  • @object-ui/types (published). ActionContext gains an optional selectionAvailable?: boolean. Unset means no list says. Additive.
  • @object-ui/plugin-grid (published). ObjectGrid publishes selectionAvailable into the action context in an effect and clears it on unmount. Its selection-mode block moves above the early returns unchanged. No export changes.
  • @object-ui/app-shell (private). The ObjectView header draws its schema-driven actions through a new ListHeaderActionBar. A refused action is drawn disabled with disabledReason, and where selectionAvailable is false it is left out. Host onClick actions are never asked. There is no new i18n key: the reasons are the existing actions.selectRowFirst and actions.selectExactlyOneRow.

② Semver level

patch on all four packages. That matches Metadata-protocol: none: AGENTS.md step 3 ships a non-protocol widening as patch.

③ Boundary flags

  • open_questions is empty.
  • The claim's file surface first named only the rendering packages. It was amended to add core ActionRunner.ts and types ui-action.ts before this review, with the reason. The dev declared the breach in its report.
  • Three out-of-scope findings are noted in the PR's Acceptance notes and not filed: the phone card view, EnvironmentListToolbar, and a ?runAction= deep link.
  • The seat pushed one comment-only commit (666e9df6) that puts refusalBeforeInput's docblock back on that method.

Implemented-by: claude/project-thread-j9w213 (in-seat subagent)
Reviewed-by: session_01Utp6CbDAucNyQ4UtzCJKyh

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh

席位核对

ACCEPT · head 666e9df6b78abc06745a974dcf3b16926730fce1 · 2026-10-30T03:13Z

  • Shape: draft → main, Fixes #27831. Clause-②: yes (widening) and Metadata-protocol: none. The seat's TIER_DEFAULT contract review is 6514041905 (claim 6514036640), VERDICT PASS on this head. The changeset is patch on four packages.
  • Change, read (10 files, +699/−95 against origin/main):
    • A single-record header action (Reassign… on Tasks) is drawn disabled, with the existing refusal sentence as its reason, unless exactly one row is selected.
    • Where the grid draws no row checkboxes it is not drawn at all.
    • The verdict is the runner's own early refusal (launchRefusal). The header carries no rule of its own, and the refusal in execute stays as the backstop.
    • useConsoleActionRuntime.tsx and RecordDetailView.tsx apiHandler (in-flight PR fix(console): a declared same-origin type: 'api' click goes through the action door #27793) are untouched.
  • Evidence (report 6513799481): the new ObjectView.headerSelectionGate-27831 suite went 4 failed / 3 passed against the base sources and 7/7 on the head. The ablation of the launchRefusal call goes 4 failed / 3 passed, and the restore is clean. Locally: core actions and plugin-grid 2967 passed, 127 targeted app-shell files 1440 passed, and type-check exit 0.
  • Deviations:
    • The file surface was amended for core and types, and Clause-② was amended from no to widening.
    • The local test scope was narrowed, as declared; the full Test UI shards are green in CI.
    • Three out-of-scope findings are noted in the PR and not filed.
  • Gates (check-runs on the head): 44 success, 11 skipped, 0 failure. check-governed-merges --pr 27894: NOT governed, 794 changed lines.
  • Landing: non-governed, all green, mergeable_state clean, so the seat lands it with pr_ready and automerge_enable.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 30, 2026 03:15
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 30, 2026 03:15
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit 2e20454 Oct 30, 2026
56 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-j9w213 branch October 30, 2026 03:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

List header: a single-record action (Reassign…) is live with no row selected, and a dead end where rows cannot be selected

1 participant