From 91afb2221a41cae14dda89829c2dc2ac7eb1a75a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:17:58 +0000 Subject: [PATCH 1/6] test(app-shell): pin the list header's selection gate for record-scoped actions (repro) Claude-Session: https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh Co-authored-by: Claude --- ...ectView.headerSelectionGate-27831.test.tsx | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 ui/packages/app-shell/src/views/ObjectView.headerSelectionGate-27831.test.tsx diff --git a/ui/packages/app-shell/src/views/ObjectView.headerSelectionGate-27831.test.tsx b/ui/packages/app-shell/src/views/ObjectView.headerSelectionGate-27831.test.tsx new file mode 100644 index 00000000000..da76828e4b2 --- /dev/null +++ b/ui/packages/app-shell/src/views/ObjectView.headerSelectionGate-27831.test.tsx @@ -0,0 +1,316 @@ +/** + * 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. + */ + +/** + * objectstack#27831 — a single-record action in a list's header is drawn the + * way a launch of it would end. + * + * The showcase's Tasks → "Reassign…" (`type: 'flow'`, placed at `list_item` and + * `list_toolbar`) was drawn enabled with nothing selected, and said "select a + * row first" only after a click; on a list whose rows draw no checkbox it could + * never work at all. + * + * The header asks the action runner for the early refusal its `execute` gives + * before any dialog (`ActionRunner.launchRefusal`, which asks the dispatch + * handler's `refuseBeforeInput` — `@object-ui/core`'s + * `resolveServerActionRecordId`). It holds no rule of its own: + * + * - a refusal for the current selection draws the action disabled, with that + * refusal, in the session's language, as its reason; + * - no refusal leaves the action as it was; + * - a refusal where the list says its rows cannot be selected + * (`selectionAvailable: false`, published by the grid) leaves the action + * out: no selection can ever lift it. + * + * Everything here is the real chain: the real `ObjectView`, the real grid + * publishing its selection into the runner context, the real `action:bar` → + * `action:button`, the real console action runtime and its handlers. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, screen, fireEvent, waitFor, act, within } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { toast } from 'sonner'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + // Stable identities — `ListView` names `perms` in its fetch dependencies. + const perms = { + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }; + const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] }; + return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Member' }, activeOrganization: { id: 'org1', name: 'Acme' } }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), + // The handlers' authenticated fetch, routed to the stubbed global. + createAuthenticatedFetch: () => (url: string, init?: RequestInit) => fetch(url, init), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +vi.mock('./metadata-admin/useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => ({ save: vi.fn(async () => ({})), get: vi.fn(async () => null) }), +})); + +import { ObjectView } from './ObjectView'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; +import { ConsoleToaster } from '../chrome/ConsoleToaster'; + +/** The showcase's Reassign…, as `examples/app-showcase` declares it. */ +const REASSIGN = { + name: 'showcase_bulk_reassign', + label: 'Reassign…', + icon: 'users', + objectName: 'showcase_task', + type: 'flow', + target: 'showcase_reassign_wizard', + locations: ['list_item', 'list_toolbar'], + refreshAfter: true, +}; + +/** CONTROL: an object-level header action — declared at `list_toolbar` alone. */ +const EXPORT_TASKS = { + name: 'showcase_export_tasks', + label: 'Export Tasks', + objectName: 'showcase_task', + type: 'script', + locations: ['list_toolbar'], +}; + +const REASSIGN_URL = '/api/v1/actions/showcase_task/showcase_bulk_reassign'; +const EXPORT_URL = '/api/v1/actions/showcase_task/showcase_export_tasks'; + +const LIST_VIEWS = { + all: { name: 'all', label: 'All', type: 'grid', columns: ['name'], selection: { type: 'multiple' } }, + // A list whose rows draw no checkbox: the selection's own explicit off. + unselectable: { name: 'unselectable', label: 'Unselectable', type: 'grid', columns: ['name'], selection: { type: 'none' } }, +}; + +const OBJECTS = [ + { + name: 'showcase_task', + label: 'Task', + pluralLabel: 'Tasks', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + }, + actions: [REASSIGN, EXPORT_TASKS], + listViews: LIST_VIEWS, + }, +]; + +const TASKS = [ + { id: 'task_1', name: 'Draft the brief' }, + { id: 'task_2', name: 'Review the brief' }, + { id: 'task_3', name: 'Ship the brief' }, +]; + +const ZERO_ROW_REFUSAL = 'This action runs on a single record — select a row first.'; +const MULTI_ROW_REFUSAL = 'This action runs on a single record — select exactly one row.'; +/** `actions.selectRowFirst` in the zh pack (pinned in `@object-ui/i18n`'s own suite). */ +const ZERO_ROW_REFUSAL_ZH = '此操作针对单条记录,请先选择一行。'; + +let fetchSpy: ReturnType; + +beforeEach(() => { + fetchSpy = vi.fn(async (url: unknown) => { + const body = String(url).includes('/api/v1/actions/') + ? { success: true, data: {} } + : { data: [] }; + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + }); + vi.stubGlobal('fetch', fetchSpy); +}); + +afterEach(() => { + act(() => { toast.dismiss(); }); + cleanup(); + vi.unstubAllGlobals(); +}); + +function mountTasks(viewName: keyof typeof LIST_VIEWS, language = 'en') { + const i18n = createI18n({ defaultLanguage: language, detectBrowserLanguage: false }); + const dataSource = { + find: vi.fn(async () => ({ data: TASKS, total: TASKS.length })), + findOne: vi.fn(async () => null), + getObjectSchema: vi.fn(async (name: string) => OBJECTS.find((o) => o.name === name) ?? null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + } as never; + return render( + + + + + + {}} />} + /> + + + + , + ); +} + +const callsTo = (url: string) => fetchSpy.mock.calls.filter(([u]) => String(u).includes(url)); + +/** The page header, once its toolbar has drawn the control action. */ +async function header(): Promise { + const el = await screen.findByTestId('page-header', undefined, { timeout: 5000 }); + await within(el).findByRole('button', { name: /Export Tasks/ }, { timeout: 5000 }); + return el; +} + +/** The grid's rows, once drawn. */ +async function rowsDrawn(): Promise { + await waitFor(() => expect(document.querySelectorAll('tbody tr').length).toBeGreaterThanOrEqual(TASKS.length), { timeout: 5000 }); +} + +/** Tick the first `n` row checkboxes once the grid has drawn them. */ +async function tickRows(n: number): Promise { + const boxes = await waitFor(() => { + const found = Array.from(document.querySelectorAll('tbody [role="checkbox"]')) as HTMLElement[]; + expect(found).toHaveLength(TASKS.length); + return found; + }, { timeout: 5000 }); + for (const box of boxes.slice(0, n)) { + await act(async () => { fireEvent.click(box); }); + } +} + +const reassignIn = (el: HTMLElement) => within(el).queryByRole('button', { name: /Reassign/ }); + +/** The text of the description a control points `aria-describedby` at. */ +function reasonOf(button: HTMLElement): string { + const ids = (button.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean); + return ids.map((id) => document.getElementById(id)?.textContent ?? '').join(' ').trim(); +} + +describe('objectstack#27831 — a record-scoped header action follows the row selection', () => { + it('0 rows: drawn disabled, the early refusal as its reason, and a click sends nothing', async () => { + mountTasks('all'); + const el = await header(); + await tickRows(0); + + const button = await waitFor(() => { + const found = reassignIn(el); + expect(found).toBeDisabled(); + return found!; + }, { timeout: 5000 }); + expect(reasonOf(button)).toBe(ZERO_ROW_REFUSAL); + // The tooltip trigger is the wrapper a disabled button needs to be hovered. + expect(button.closest('[data-disabled-reason]')).not.toBeNull(); + + fireEvent.click(button); + expect(callsTo(REASSIGN_URL)).toHaveLength(0); + }); + + it('1 row: enabled, and a click dispatches it for that row', async () => { + mountTasks('all'); + const el = await header(); + await tickRows(1); + + const button = await waitFor(() => { + const found = reassignIn(el); + expect(found).toBeEnabled(); + return found!; + }, { timeout: 5000 }); + expect(button.closest('[data-disabled-reason]')).toBeNull(); + + await act(async () => { fireEvent.click(button); }); + await waitFor(() => expect(callsTo(REASSIGN_URL)).toHaveLength(1), { timeout: 5000 }); + expect(JSON.parse(String((callsTo(REASSIGN_URL)[0][1] as RequestInit).body)).recordId).toBe('task_1'); + }); + + it('2 rows: drawn disabled again, with the "exactly one row" refusal as its reason', async () => { + mountTasks('all'); + const el = await header(); + await tickRows(2); + + const button = await waitFor(() => { + const found = reassignIn(el); + expect(found).toBeDisabled(); + expect(reasonOf(found!)).toBe(MULTI_ROW_REFUSAL); + return found!; + }, { timeout: 5000 }); + + fireEvent.click(button); + expect(callsTo(REASSIGN_URL)).toHaveLength(0); + }); + + it('the reason is in the session\'s language, from the refusal\'s own pack key', async () => { + mountTasks('all', 'zh'); + const el = await header(); + await tickRows(0); + + await waitFor(() => { + const found = reassignIn(el); + expect(found).toBeDisabled(); + expect(reasonOf(found!)).toBe(ZERO_ROW_REFUSAL_ZH); + }, { timeout: 5000 }); + }); + + it('a list whose rows cannot be selected: the record-scoped action is not drawn', async () => { + mountTasks('unselectable'); + const el = await header(); + await rowsDrawn(); + expect(document.querySelectorAll('tbody [role="checkbox"]')).toHaveLength(0); + + await waitFor(() => expect(reassignIn(el)).toBeNull(), { timeout: 5000 }); + }); + + it('CONTROL — an object-level header action is unaffected: enabled with 0 rows, and it dispatches', async () => { + mountTasks('all'); + const el = await header(); + await tickRows(0); + + const button = within(el).getByRole('button', { name: /Export Tasks/ }); + expect(button).toBeEnabled(); + expect(button.closest('[data-disabled-reason]')).toBeNull(); + await act(async () => { fireEvent.click(button); }); + await waitFor(() => expect(callsTo(EXPORT_URL)).toHaveLength(1), { timeout: 5000 }); + }); + + it('CONTROL — an object-level header action is drawn on a list whose rows cannot be selected', async () => { + mountTasks('unselectable'); + const el = await header(); + await rowsDrawn(); + + expect(within(el).getByRole('button', { name: /Export Tasks/ })).toBeEnabled(); + }); +}); From 45c59d8bc1967202a324e4786c22c77859666392 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:27:35 +0000 Subject: [PATCH 2/6] fix(app-shell): a list header draws a single-record action the way its launch would end Claude-Session: https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh Co-authored-by: Claude --- ...View.toolbarScriptSelection-12331.test.tsx | 22 ++- .../src/views/objectViewHeaderChrome.tsx | 6 +- .../src/views/objectViewHeaderLaunchGate.tsx | 70 +++++++ .../src/views/objectViewPageHeader.tsx | 4 +- ui/packages/core/src/actions/ActionRunner.ts | 35 ++++ ui/packages/plugin-grid/src/ObjectGrid.tsx | 184 ++++++++++-------- ui/packages/types/src/ui-action.ts | 8 + 7 files changed, 234 insertions(+), 95 deletions(-) create mode 100644 ui/packages/app-shell/src/views/objectViewHeaderLaunchGate.tsx diff --git a/ui/packages/app-shell/src/views/ObjectView.toolbarScriptSelection-12331.test.tsx b/ui/packages/app-shell/src/views/ObjectView.toolbarScriptSelection-12331.test.tsx index 7df5dc07770..c8c9178789c 100644 --- a/ui/packages/app-shell/src/views/ObjectView.toolbarScriptSelection-12331.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.toolbarScriptSelection-12331.test.tsx @@ -40,6 +40,12 @@ * `refuseBeforeInput`), so the 0- and 2-row cases below assert that no dialog * opened, beside the toast and the absent request. One row still opens it. * + * objectstack#27831 moved it once more, onto the button: the header asks the + * runner for that same early refusal (`ActionRunner.launchRefusal`) and draws a + * launch it would refuse disabled, with the refusal as its reason, so the 0- + * and 2-row cases below read the reason off the disabled button instead of a + * toast after a click. + * * "Record-scoped" is a declaration, not a guess: an action is single-record * when its `locations` include a record placement (`isRecordScopedAction`). An * action declared at `list_toolbar` alone is object-level, so zero rows @@ -221,13 +227,21 @@ async function launchFromHeader(): Promise { } /** - * Click the HEADER toolbar's "Add to Campaign" for a launch that is refused for - * its row count (objectui#13384): the refusal shows, and no dialog opened. + * The HEADER toolbar's "Add to Campaign" for a launch that is refused for its + * row count. objectui#13384 refused the click before any dialog opened; + * objectstack#27831 draws the button disabled with that same refusal as its + * reason (its accessible description), so the refusal shows before any click, + * a click does nothing, and no dialog opens. */ async function launchRefusedFromHeader(refusal: string): Promise { const toolbars = await screen.findAllByRole('toolbar', undefined, { timeout: 5000 }); - fireEvent.click(await within(toolbars[0]).findByRole('button', { name: /Add to Campaign/ }, { timeout: 5000 })); - expect(await screen.findByText(refusal, undefined, { timeout: 5000 })).toBeInTheDocument(); + const button = await within(toolbars[0]).findByRole('button', { name: /Add to Campaign/ }, { timeout: 5000 }); + await waitFor(() => { + expect(button).toBeDisabled(); + const reasonId = button.getAttribute('aria-describedby') ?? ''; + expect(document.getElementById(reasonId)?.textContent).toBe(refusal); + }, { timeout: 5000 }); + fireEvent.click(button); expect(screen.queryByRole('dialog')).toBeNull(); } diff --git a/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx b/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx index 2fab36a245c..cfbdf76aa66 100644 --- a/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx +++ b/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx @@ -10,12 +10,12 @@ import type { Dispatch, SetStateAction } from 'react'; import { Button, Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; import { Plus, Lock } from 'lucide-react'; import type { ActionBarSchema, UIActionSchema } from '@object-ui/types'; -import { SchemaRenderer } from '@object-ui/react'; import type { useObjectTranslation } from '@object-ui/i18n'; import type { useObjectListFavorite } from '../layout/SidebarItemMenu.js'; import type { useMetaItemRefusal } from '../hooks/useMetaItemRefusal.js'; import type { useObjectActions } from '../hooks/useObjectActions.js'; import { declaredPrimaryToolbarAction, MOBILE_FAB_LIST_CLEARANCE } from './objectViewHelpers.js'; +import { ListHeaderActionBar } from './objectViewHeaderLaunchGate.js'; import type { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; import type { useObjectViewIdentity } from './objectViewIdentity.js'; import type { useObjectViewListUrlState } from './objectViewListUrlState.js'; @@ -152,9 +152,9 @@ export function useObjectViewHeaderChrome({ `md` (`mobileMaxVisible: 0`), which would send the primary into a second `⋯` beside the menu's. */} {primaryToolbarAction && ( - + )} - + ) : undefined; diff --git a/ui/packages/app-shell/src/views/objectViewHeaderLaunchGate.tsx b/ui/packages/app-shell/src/views/objectViewHeaderLaunchGate.tsx new file mode 100644 index 00000000000..3c7bbfc1872 --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewHeaderLaunchGate.tsx @@ -0,0 +1,70 @@ +/** + * The list header's launch gate on the Console `ObjectView` (objectstack#27831): + * each schema-driven header action is drawn the way a launch of it would end. + * + * The verdict is the action runner's own, never a rule of this file: + * `ActionRunner.launchRefusal` is the early refusal `execute` gives before the + * confirmation and the param dialog — the dispatch handler's + * `refuseBeforeInput`, which for the console's `script` and `flow` handlers is + * `@object-ui/core`'s `resolveServerActionRecordId` — in the session's + * language. Asked against the runner's live context, it answers for the row + * selection the grid publishes there: + * + * - a refusal draws the action disabled, with the refusal as its reason (the + * `disabled` / `disabledReason` pair the `action:*` renderers already draw, + * tooltip and accessible description included); + * - a refusal where the list says its rows cannot be selected + * (`selectionAvailable: false`) leaves the action out, since no selection + * can ever lift it; + * - no refusal leaves the action exactly as it was. + * + * A host callback (`onClick`, such as Import in the folded menu) never reaches + * the runner, so it is never asked. The runner's early refusal stays the + * backstop for a launch that still reaches `execute` — a `?runAction=` deep + * link, or a surface that does not draw through this gate. + * + * Mount it under the page's `ActionProvider`: the runner and its context are + * that provider's. + */ + +import { useMemo, useSyncExternalStore } from 'react'; +import { SchemaRenderer, useAction } from '@object-ui/react'; +import { useObjectTranslation } from '@object-ui/i18n'; +import type { ActionBarSchema, UIActionSchema } from '@object-ui/types'; + +/** `actions`, each drawn the way a launch of it would end now. */ +export function useListHeaderLaunchGate(actions: readonly UIActionSchema[] | undefined): UIActionSchema[] { + const { runner } = useAction(); + const subscribe = useMemo(() => (listener: () => void) => runner.subscribeContext(listener), [runner]); + // The two context facts the verdict reads, each a stable snapshot (the + // grid's own array, a boolean), so an unrelated context write redraws + // nothing. + const selectedRecords = useSyncExternalStore(subscribe, () => runner.getContext().selectedRecords); + const selectionAvailable = useSyncExternalStore(subscribe, () => runner.getContext().selectionAvailable); + // The refusal is translated by the runner, so a language switch re-asks. + const { language } = useObjectTranslation(); + + return useMemo(() => { + const gated: UIActionSchema[] = []; + for (const action of actions ?? []) { + const refusal = typeof action.onClick === 'function' + ? undefined + : runner.launchRefusal(action as Parameters[0]); + if (refusal === undefined) { + gated.push(action); + } else if (selectionAvailable !== false) { + gated.push({ ...action, disabled: true, disabledReason: refusal }); + } + } + return gated; + // `selectedRecords` and `language` are what `launchRefusal` reads off + // the runner; they are the keys, not values used here. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actions, runner, selectedRecords, selectionAvailable, language]); +} + +/** An `action:bar` of list header actions, drawn through {@link useListHeaderLaunchGate}. */ +export function ListHeaderActionBar({ schema }: { schema: ActionBarSchema }) { + const actions = useListHeaderLaunchGate(schema.actions); + return ; +} diff --git a/ui/packages/app-shell/src/views/objectViewPageHeader.tsx b/ui/packages/app-shell/src/views/objectViewPageHeader.tsx index d10a02b9516..3238ae026b9 100644 --- a/ui/packages/app-shell/src/views/objectViewPageHeader.tsx +++ b/ui/packages/app-shell/src/views/objectViewPageHeader.tsx @@ -17,7 +17,6 @@ import { DropdownMenuTrigger, } from '@object-ui/components'; import { Upload, MoreHorizontal, RotateCcw } from 'lucide-react'; -import { SchemaRenderer } from '@object-ui/react'; import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import { FavoriteMenuItem } from '../layout/SidebarItemMenu.js'; import type { useObjectListFavorite } from '../layout/SidebarItemMenu.js'; @@ -25,6 +24,7 @@ import { getIcon } from '../utils/getIcon.js'; import { PageHeader } from '../layout/PageHeader.js'; import { ManagedByBadge } from '../components/ManagedByBadge.js'; import { EnvironmentListToolbar } from '../environment/EnvironmentListToolbar.js'; +import { ListHeaderActionBar } from './objectViewHeaderLaunchGate.js'; import type { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; import type { useObjectViewHeaderToolbar } from './objectViewHeaderToolbar.js'; import type { useObjectViewListUrlState } from './objectViewListUrlState.js'; @@ -123,7 +123,7 @@ export function ObjectViewPageHeader({ entitlements={environmentEntitlements} /> ) : ( - + ) )} diff --git a/ui/packages/core/src/actions/ActionRunner.ts b/ui/packages/core/src/actions/ActionRunner.ts index f1a66d2ed9e..2c171781ce4 100644 --- a/ui/packages/core/src/actions/ActionRunner.ts +++ b/ui/packages/core/src/actions/ActionRunner.ts @@ -1334,6 +1334,8 @@ export class ActionRunner { private translate: ((key: string, options: { defaultValue: string }) => string) | null; private displayLocale: (() => string | undefined) | null; private tenantCurrency: (() => string | undefined) | null; + /** Who {@link ActionRunner.subscribeContext} tells of a context change. */ + private readonly contextListeners = new Set<() => void>(); /** * Built-in dispatch, one entry per runnable action type. @@ -1834,6 +1836,23 @@ export class ActionRunner { * `undefined` when there is none. An update-operation action never reaches * a registered handler, so it is never asked. */ + /** + * The early refusal {@link ActionRunner.execute} would give `action` if it + * were launched now, against this runner's context — `undefined` when it + * would go on. It is the refusal `execute` asks before the confirmation and + * the param dialog: the dispatch handler's `refuseBeforeInput`, translated + * when it names a key. Asking runs nothing, sends nothing and shows nothing. + * + * A surface that draws a launch control asks it to draw the control the way + * a launch would end — a list header greys out a single-record action while + * the row selection is not exactly one row (objectstack#27831) — so the rule + * stays with the handler that dispatches, and `execute` still refuses a + * launch that reaches it anyway. + */ + launchRefusal(action: ActionDef): string | undefined { + return this.refusalBeforeInput(action, resolveDispatchType(action, (name) => this.handlers.has(name))); + } + private refusalBeforeInput(action: ActionDef, actionType: string): string | undefined { if (isUpdateOperationAction(action)) return undefined; const handler = actionType ? this.handlers.get(actionType) : undefined; @@ -3037,12 +3056,28 @@ export class ActionRunner { // Feed the merged (re-aliased) context so a refreshed `user` also // refreshes the derived `os.user` binding in the evaluator scope. this.evaluator.updateContext(this.context); + for (const listener of [...this.contextListeners]) listener(); } getContext(): ActionContext { return this.context; } + /** + * Call `listener` after every {@link ActionRunner.updateContext}, until the + * returned function is called. The listener is told that the context + * changed, not what changed: it reads {@link ActionRunner.getContext}. This + * is the store half React's `useSyncExternalStore` needs, so a control drawn + * from the context — such as a header action that follows the grid's row + * selection (objectstack#27831) — redraws when a sibling publishes into it. + */ + subscribeContext(listener: () => void): () => void { + this.contextListeners.add(listener); + return () => { + this.contextListeners.delete(listener); + }; + } + /** * A one-shot runner for {@link ActionRunner.execute}'s `callContext` * (objectui#13063): this runner's context with `callContext` merged over it, diff --git a/ui/packages/plugin-grid/src/ObjectGrid.tsx b/ui/packages/plugin-grid/src/ObjectGrid.tsx index ad14e8cf5cf..03bd0767c30 100644 --- a/ui/packages/plugin-grid/src/ObjectGrid.tsx +++ b/ui/packages/plugin-grid/src/ObjectGrid.tsx @@ -1185,6 +1185,104 @@ export const ObjectGrid: React.FC = ({ return () => { updateActionContext({ selectedRecords: [] }); }; }, [selectedRows, updateActionContext]); + // Determine selection mode (support both new and legacy formats) + // Auto-enable 'multiple' selection when bulk actions are defined OR when + // a bulk-delete affordance is implicitly available (canDelete + onBulkDelete + // wired by the consumer). This gives every list a multi-select + delete UX + // out of the box without forcing each view JSON to declare bulkActions. + // [objectstack#3720] Bulk delete is the most destructive affordance on the list, so it + // rides the same object-level `delete` verdict as the row kebab (bucket lock + // ∧ userActions ∧ the server's effective operation set). An author-declared + // `bulkActions: ['delete']` / `bulkActionDefs[].operation === 'delete'` is a + // WIRING declaration, not a permission grant — so the built-in delete is + // filtered out of both when the verdict is off. Custom action ids and + // `custom` operations are untouched: they route through the action runner + // and carry their own gates. (An `update` operation is the edit verdict's — + // see `bulkActionDefs` below.) + const declaredBulkActions = schema.batchActions ?? schema.bulkActions; + const explicitBulkActions = objectCanDelete + ? declaredBulkActions + : declaredBulkActions?.filter((a: unknown) => String(a).toLowerCase() !== 'delete'); + // `bulkActions` carries a bare action NAME, which the runner cannot execute + // on its own — resolve each against `objectDef.actions` so it dispatches as a + // real def. See `resolveBulkActions` (objectui#3002). The + // canonical `'delete'` is held back: it routes to `onBulkDelete` (which owns + // confirm + refresh), not the runner, even if the object declares an action + // that happens to be named `delete`. + const legacyBulkNames = (explicitBulkActions ?? []).filter( + (a: unknown) => String(a).toLowerCase() !== 'delete', + ); + const { defs: resolvedBulkDefs, unresolved: unresolvedBulkActions } = resolveBulkActions({ + bulkActions: legacyBulkNames, + bulkActionDefs: Array.isArray(schema.bulkActionDefs) ? schema.bulkActionDefs : [], + objectActions: (objectSchema as any)?.actions, + localizeLabel: (actionName, fallback) => + resolveActionLabel(schema.objectName, actionName, fallback), + }); + // [objectui#12082] …and a bulk FIELD UPDATE (`operation: 'update'`) is the + // same object's edit verdict, the map's `rowEdit` row the row kebab's Edit + // reads (`rowEditVerdict` above): a def that writes a patch to every selected + // record must not outlive the caller's update grant, the effective operation + // set or the object's policy any more than the bulk delete outlives its + // delete verdict. A withheld def takes its dialog with it, and so the + // executor's per-row update, its undo and its retry, which re-run that write. + // `custom` defs are untouched, as above. + const bulkActionDefs: BulkActionDef[] = resolvedBulkDefs.filter( + (def: BulkActionDef) => + (objectCanDelete || def?.operation !== 'delete') + && (rowEditVerdict.allowed || def?.operation !== 'update'), + ); + // Names still dispatched by string: the unresolved ones, plus `'delete'` + // when the author asked for it — or the implicit bulk-delete affordance. + const keptDeleteAction = (explicitBulkActions ?? []).filter( + (a: unknown) => String(a).toLowerCase() === 'delete', + ); + const effectiveBulkActions: string[] = + explicitBulkActions && explicitBulkActions.length > 0 + ? [...unresolvedBulkActions, ...keptDeleteAction] + : canDelete && onBulkDelete && bulkActionDefs.length === 0 + ? ['delete'] + : []; + const hasBulkActions = effectiveBulkActions.length > 0 || bulkActionDefs.length > 0; + let selectionMode: 'none' | 'single' | 'multiple' | boolean = false; + if (schema.selection !== undefined) { + // "presence enables; an explicit off wins" — the ONE rule both + // object-armed keys on this block obey (objectui#9837, ruling A-prime, batch + // #162 item 2), `paginationEnabled` below being the other half. + // + // Writing the object is how an author asks for selection; `type: 'none'` + // is this key's own explicit off and beats the presence. Before the ruling + // the read was `schema.selection?.type`, so an object with no `type` fell + // through to the legacy arms and the object itself meant NOTHING — the + // exact opposite of what the neighbouring `pagination` key taught, with no + // error and no diagnostic either way. + const authoredType = schema.selection?.type; + selectionMode = + authoredType === 'none' ? false : (authoredType ?? DEFAULT_SELECTION_TYPE); + } else if (schema.selectable !== undefined) { + // Legacy support — read only when `selection` is absent. + selectionMode = schema.selectable; + } else if (hasBulkActions) { + // Auto-enable multi-select when bulk actions exist + selectionMode = 'multiple'; + } + // `selection.type: 'single'` caps the selection at one row (the data-table + // enforces replace-on-select, #2941), so the cross-page "select all N + // matching" escalation must never be offered. + const singleSelection = selectionMode === 'single'; + + // Publish whether these rows can be selected at all beside the selection + // itself, so a control outside the grid that needs a selected row can tell + // "nothing selected yet" from "nothing selectable here" (objectstack#27831: + // the list header leaves out a single-record action no selection can ever + // lift). Declared ahead of the early returns below, so it is published + // while the grid is still loading as well. + const rowsSelectable = !!selectionMode; + React.useEffect(() => { + updateActionContext({ selectionAvailable: rowsSelectable }); + return () => { updateActionContext({ selectionAvailable: undefined }); }; + }, [rowsSelectable, updateActionContext]); + // --- Row color support --- const getRowClassName = useRowColor(schema.rowColor); @@ -1730,92 +1828,6 @@ export const ObjectGrid: React.FC = ({ ? userLeftPinnedCount : (schema.frozenColumns ?? 1); - // Determine selection mode (support both new and legacy formats) - // Auto-enable 'multiple' selection when bulk actions are defined OR when - // a bulk-delete affordance is implicitly available (canDelete + onBulkDelete - // wired by the consumer). This gives every list a multi-select + delete UX - // out of the box without forcing each view JSON to declare bulkActions. - // [objectstack#3720] Bulk delete is the most destructive affordance on the list, so it - // rides the same object-level `delete` verdict as the row kebab (bucket lock - // ∧ userActions ∧ the server's effective operation set). An author-declared - // `bulkActions: ['delete']` / `bulkActionDefs[].operation === 'delete'` is a - // WIRING declaration, not a permission grant — so the built-in delete is - // filtered out of both when the verdict is off. Custom action ids and - // `custom` operations are untouched: they route through the action runner - // and carry their own gates. (An `update` operation is the edit verdict's — - // see `bulkActionDefs` below.) - const declaredBulkActions = schema.batchActions ?? schema.bulkActions; - const explicitBulkActions = objectCanDelete - ? declaredBulkActions - : declaredBulkActions?.filter((a: unknown) => String(a).toLowerCase() !== 'delete'); - // `bulkActions` carries a bare action NAME, which the runner cannot execute - // on its own — resolve each against `objectDef.actions` so it dispatches as a - // real def. See `resolveBulkActions` (objectui#3002). The - // canonical `'delete'` is held back: it routes to `onBulkDelete` (which owns - // confirm + refresh), not the runner, even if the object declares an action - // that happens to be named `delete`. - const legacyBulkNames = (explicitBulkActions ?? []).filter( - (a: unknown) => String(a).toLowerCase() !== 'delete', - ); - const { defs: resolvedBulkDefs, unresolved: unresolvedBulkActions } = resolveBulkActions({ - bulkActions: legacyBulkNames, - bulkActionDefs: Array.isArray(schema.bulkActionDefs) ? schema.bulkActionDefs : [], - objectActions: (objectSchema as any)?.actions, - localizeLabel: (actionName, fallback) => - resolveActionLabel(schema.objectName, actionName, fallback), - }); - // [objectui#12082] …and a bulk FIELD UPDATE (`operation: 'update'`) is the - // same object's edit verdict, the map's `rowEdit` row the row kebab's Edit - // reads (`rowEditVerdict` above): a def that writes a patch to every selected - // record must not outlive the caller's update grant, the effective operation - // set or the object's policy any more than the bulk delete outlives its - // delete verdict. A withheld def takes its dialog with it, and so the - // executor's per-row update, its undo and its retry, which re-run that write. - // `custom` defs are untouched, as above. - const bulkActionDefs: BulkActionDef[] = resolvedBulkDefs.filter( - (def: BulkActionDef) => - (objectCanDelete || def?.operation !== 'delete') - && (rowEditVerdict.allowed || def?.operation !== 'update'), - ); - // Names still dispatched by string: the unresolved ones, plus `'delete'` - // when the author asked for it — or the implicit bulk-delete affordance. - const keptDeleteAction = (explicitBulkActions ?? []).filter( - (a: unknown) => String(a).toLowerCase() === 'delete', - ); - const effectiveBulkActions: string[] = - explicitBulkActions && explicitBulkActions.length > 0 - ? [...unresolvedBulkActions, ...keptDeleteAction] - : canDelete && onBulkDelete && bulkActionDefs.length === 0 - ? ['delete'] - : []; - const hasBulkActions = effectiveBulkActions.length > 0 || bulkActionDefs.length > 0; - let selectionMode: 'none' | 'single' | 'multiple' | boolean = false; - if (schema.selection !== undefined) { - // "presence enables; an explicit off wins" — the ONE rule both - // object-armed keys on this block obey (objectui#9837, ruling A-prime, batch - // #162 item 2), `paginationEnabled` below being the other half. - // - // Writing the object is how an author asks for selection; `type: 'none'` - // is this key's own explicit off and beats the presence. Before the ruling - // the read was `schema.selection?.type`, so an object with no `type` fell - // through to the legacy arms and the object itself meant NOTHING — the - // exact opposite of what the neighbouring `pagination` key taught, with no - // error and no diagnostic either way. - const authoredType = schema.selection?.type; - selectionMode = - authoredType === 'none' ? false : (authoredType ?? DEFAULT_SELECTION_TYPE); - } else if (schema.selectable !== undefined) { - // Legacy support — read only when `selection` is absent. - selectionMode = schema.selectable; - } else if (hasBulkActions) { - // Auto-enable multi-select when bulk actions exist - selectionMode = 'multiple'; - } - // `selection.type: 'single'` caps the selection at one row (the data-table - // enforces replace-on-select, #2941), so the cross-page "select all N - // matching" escalation must never be offered. - const singleSelection = selectionMode === 'single'; - // The floor. With no query to replay there is no honest "all N matching", so // the escalation is not offered — the same answer as a match set that does not // exist. This is what makes an unfiltered fan-out structurally unreachable diff --git a/ui/packages/types/src/ui-action.ts b/ui/packages/types/src/ui-action.ts index 2b6fd9a00c8..cc1eb44e092 100644 --- a/ui/packages/types/src/ui-action.ts +++ b/ui/packages/types/src/ui-action.ts @@ -1057,6 +1057,14 @@ export interface ActionContext { /** Selected records (for list actions) */ selectedRecords?: Record[]; + /** + * Whether the list that publishes `selectedRecords` lets its rows be + * selected at all. `false`: its rows draw no selection control, so + * `selectedRecords` stays empty there and a launch that needs a selected row + * can never be made from it (objectstack#27831). Unset when no list says. + */ + selectionAvailable?: boolean; + /** Live page-variable snapshot (ADR-0049) published by PageVariableActionBridge — * lets a submit action read page-local form state via `{{page.}}` tokens. */ pageVariables?: Record; From 54e02ae0140b1414f306963a999c787082fce80a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:31:06 +0000 Subject: [PATCH 3/6] test(core): pin ActionRunner.launchRefusal and subscribeContext Claude-Session: https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh Co-authored-by: Claude --- .../ActionRunner.launchRefusal-27831.test.ts | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 ui/packages/core/src/actions/__tests__/ActionRunner.launchRefusal-27831.test.ts diff --git a/ui/packages/core/src/actions/__tests__/ActionRunner.launchRefusal-27831.test.ts b/ui/packages/core/src/actions/__tests__/ActionRunner.launchRefusal-27831.test.ts new file mode 100644 index 00000000000..c2043adc68a --- /dev/null +++ b/ui/packages/core/src/actions/__tests__/ActionRunner.launchRefusal-27831.test.ts @@ -0,0 +1,129 @@ +/** + * 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. + */ + +/** + * objectstack#27831 — the two runner members a launch control draws from. + * + * - `launchRefusal(action)` answers the early refusal `execute` would give the + * action against the runner's current context (the dispatch handler's + * `refuseBeforeInput`, translated by its key), and runs, sends and shows + * nothing. A list header asks it to draw a single-record action disabled + * while the selection is not exactly one row. + * - `subscribeContext(listener)` tells a listener that `updateContext` ran, so + * that header redraws when the grid publishes its selection. + * + * Through the real runner and the real `createServerActionHandler` with a + * stubbed transport. + */ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { ActionRunner, type ActionDef, type ToastHandler } from '../ActionRunner'; +import { createServerActionHandler, type ServerActionFetch } from '../serverActionHandler'; + +const MULTI_ROW_REFUSAL = 'This action runs on a single record — select exactly one row.'; +const ZERO_ROW_REFUSAL = 'This action runs on a single record — select a row first.'; + +/** A single-record action: mounted on rows and on the toolbar. */ +const REASSIGN: ActionDef = { + type: 'script', + name: 'reassign', + objectName: 'task', + locations: ['list_item', 'list_toolbar'], +}; + +/** An object-level action: the toolbar alone. */ +const EXPORT_ALL: ActionDef = { + type: 'script', + name: 'export_all', + objectName: 'task', + locations: ['list_toolbar'], +}; + +function runnerWith(rows?: Record[]) { + const fetch = vi.fn() as unknown as ServerActionFetch & Mock; + const toast = vi.fn() as Mock; + const runner = new ActionRunner(rows ? { selectedRecords: rows } : {}); + runner.setToastHandler(toast); + runner.registerHandler('script', createServerActionHandler({ fetch })); + return { runner, fetch, toast }; +} + +describe('objectstack#27831 — ActionRunner.launchRefusal', () => { + it('0 rows: the "select a row first" refusal, and nothing runs, is sent or shown', () => { + const { runner, fetch, toast } = runnerWith([]); + expect(runner.launchRefusal(REASSIGN)).toBe(ZERO_ROW_REFUSAL); + expect(fetch).not.toHaveBeenCalled(); + expect(toast).not.toHaveBeenCalled(); + }); + + it('no selection published at all reads as 0 rows', () => { + expect(runnerWith().runner.launchRefusal(REASSIGN)).toBe(ZERO_ROW_REFUSAL); + }); + + it('1 row: no refusal', () => { + expect(runnerWith([{ id: 't1' }]).runner.launchRefusal(REASSIGN)).toBeUndefined(); + }); + + it('2 rows: the "exactly one row" refusal', () => { + expect(runnerWith([{ id: 't1' }, { id: 't2' }]).runner.launchRefusal(REASSIGN)).toBe(MULTI_ROW_REFUSAL); + }); + + it('answers what execute refuses: the same text, for the same context', async () => { + const { runner, toast, fetch } = runnerWith([]); + const asked = runner.launchRefusal(REASSIGN); + const result = await runner.execute({ ...REASSIGN }); + expect(result).toEqual({ success: false, error: asked }); + expect(toast).toHaveBeenCalledWith(asked, expect.objectContaining({ type: 'error' })); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('follows the context the runner holds now', () => { + const { runner } = runnerWith([]); + expect(runner.launchRefusal(REASSIGN)).toBe(ZERO_ROW_REFUSAL); + runner.updateContext({ selectedRecords: [{ id: 't1' }] }); + expect(runner.launchRefusal(REASSIGN)).toBeUndefined(); + }); + + it('is translated by the refusal\'s own key, with the English source as the default', () => { + const { runner } = runnerWith([]); + const translate = vi.fn((key: string) => `[${key}]`); + runner.setTranslator(translate); + expect(runner.launchRefusal(REASSIGN)).toBe('[actions.selectRowFirst]'); + expect(translate).toHaveBeenCalledWith('actions.selectRowFirst', { defaultValue: ZERO_ROW_REFUSAL }); + }); + + it('a typeless declaration is asked of the handler its spec default type routes to', () => { + const { type: _type, ...typeless } = REASSIGN; + expect(runnerWith([]).runner.launchRefusal(typeless as ActionDef)).toBe(ZERO_ROW_REFUSAL); + }); + + it('CONTROL — an object-level action with 0 rows is not refused', () => { + expect(runnerWith([]).runner.launchRefusal(EXPORT_ALL)).toBeUndefined(); + }); + + it('CONTROL — a handler with no early refusal is never refused', () => { + const runner = new ActionRunner({ selectedRecords: [] }); + runner.registerHandler('script', vi.fn(async () => ({ success: true }))); + expect(runner.launchRefusal(REASSIGN)).toBeUndefined(); + }); +}); + +describe('objectstack#27831 — ActionRunner.subscribeContext', () => { + it('tells a listener after each updateContext, with the new context readable, until it unsubscribes', () => { + const runner = new ActionRunner(); + const seen: unknown[] = []; + const unsubscribe = runner.subscribeContext(() => { seen.push(runner.getContext().selectedRecords); }); + + const rows = [{ id: 't1' }]; + runner.updateContext({ selectedRecords: rows }); + expect(seen).toEqual([rows]); + + unsubscribe(); + runner.updateContext({ selectedRecords: [] }); + expect(seen).toEqual([rows]); + }); +}); From 4d934b4c6d893ae8e93e30e518be69885610143a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:40:36 +0000 Subject: [PATCH 4/6] chore(changeset): list header selection gate Claude-Session: https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh Co-authored-by: Claude --- .../27831-list-header-selection-gate.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/27831-list-header-selection-gate.md diff --git a/.changeset/27831-list-header-selection-gate.md b/.changeset/27831-list-header-selection-gate.md new file mode 100644 index 00000000000..b71d3211019 --- /dev/null +++ b/.changeset/27831-list-header-selection-gate.md @@ -0,0 +1,25 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/core': patch +'@object-ui/plugin-grid': patch +'@object-ui/types': patch +--- + +fix(app-shell): a single-record action in a list's header is disabled, with the reason, unless exactly one row is selected, and is not drawn where the rows cannot be selected + +Clause-②: yes (widening) +Metadata-protocol: none + +On a list, a header action that runs on one record (for example Tasks → **Reassign…**, placed at `list_item` and `list_toolbar`) was drawn enabled with nothing selected, and said "select a row first" only after a click. Where the grid draws no row checkboxes, it could never work. + +- **0 rows or several rows:** the action is drawn disabled. Its tooltip and accessible description are the 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. +- **Exactly one row:** the action is enabled and runs as before. +- **A list whose rows cannot be selected:** the action is not drawn. +- **Object-level actions** (declared at `list_toolbar` only) are unchanged with no rows selected. With several rows selected they are disabled with the "select exactly one row" reason, because a click is refused the same way. +- **The header has no rule of its own.** It asks the action runner for the early refusal a launch would get (the dispatch handler's `refuseBeforeInput`, the same check that refuses a click before any dialog opens). That check stays as the backstop, for example for a `?runAction=` deep link. + +New public members (additive): + +- `@object-ui/core`: `ActionRunner.launchRefusal(action)` returns the early refusal `execute` would give the action against the runner's current context, translated by its key, or `undefined` if there is none. It runs, sends and shows nothing. `ActionRunner.subscribeContext(listener)` calls `listener` after every `updateContext` and returns an unsubscribe function. +- `@object-ui/types`: `ActionContext.selectionAvailable?: boolean` tells whether the list publishing `selectedRecords` lets its rows be selected at all. +- `@object-ui/plugin-grid`: `ObjectGrid` publishes `selectionAvailable` beside `selectedRecords` in the action context, and clears it when it unmounts. From c4878057e002b068b0d1577a47cf7b122ee7cde1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 02:14:33 +0000 Subject: [PATCH 5/6] refactor(app-shell): the header launch gate computes per render, no memo Claude-Session: https://claude.ai/code/session_01Utp6CbDAucNyQ4UtzCJKyh Co-authored-by: Claude --- .../src/views/objectViewHeaderLaunchGate.tsx | 45 +++++++++---------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/ui/packages/app-shell/src/views/objectViewHeaderLaunchGate.tsx b/ui/packages/app-shell/src/views/objectViewHeaderLaunchGate.tsx index 3c7bbfc1872..eae2a671623 100644 --- a/ui/packages/app-shell/src/views/objectViewHeaderLaunchGate.tsx +++ b/ui/packages/app-shell/src/views/objectViewHeaderLaunchGate.tsx @@ -27,40 +27,35 @@ * that provider's. */ -import { useMemo, useSyncExternalStore } from 'react'; +import { useCallback, useSyncExternalStore } from 'react'; import { SchemaRenderer, useAction } from '@object-ui/react'; import { useObjectTranslation } from '@object-ui/i18n'; import type { ActionBarSchema, UIActionSchema } from '@object-ui/types'; /** `actions`, each drawn the way a launch of it would end now. */ -export function useListHeaderLaunchGate(actions: readonly UIActionSchema[] | undefined): UIActionSchema[] { +function useListHeaderLaunchGate(actions: readonly UIActionSchema[] | undefined): UIActionSchema[] { const { runner } = useAction(); - const subscribe = useMemo(() => (listener: () => void) => runner.subscribeContext(listener), [runner]); - // The two context facts the verdict reads, each a stable snapshot (the - // grid's own array, a boolean), so an unrelated context write redraws - // nothing. - const selectedRecords = useSyncExternalStore(subscribe, () => runner.getContext().selectedRecords); + const subscribe = useCallback((listener: () => void) => runner.subscribeContext(listener), [runner]); + // Redraw when the context facts the verdict reads change: the selection + // the grid publishes (its own array, a stable snapshot) and whether its + // rows can be selected at all. An unrelated context write redraws nothing. + useSyncExternalStore(subscribe, () => runner.getContext().selectedRecords); const selectionAvailable = useSyncExternalStore(subscribe, () => runner.getContext().selectionAvailable); - // The refusal is translated by the runner, so a language switch re-asks. - const { language } = useObjectTranslation(); + // The runner translates the refusal, so a language switch redraws too. + useObjectTranslation(); - return useMemo(() => { - const gated: UIActionSchema[] = []; - for (const action of actions ?? []) { - const refusal = typeof action.onClick === 'function' - ? undefined - : runner.launchRefusal(action as Parameters[0]); - if (refusal === undefined) { - gated.push(action); - } else if (selectionAvailable !== false) { - gated.push({ ...action, disabled: true, disabledReason: refusal }); - } + const gated: UIActionSchema[] = []; + for (const action of actions ?? []) { + const refusal = typeof action.onClick === 'function' + ? undefined + : runner.launchRefusal(action as Parameters[0]); + if (refusal === undefined) { + gated.push(action); + } else if (selectionAvailable !== false) { + gated.push({ ...action, disabled: true, disabledReason: refusal }); } - return gated; - // `selectedRecords` and `language` are what `launchRefusal` reads off - // the runner; they are the keys, not values used here. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actions, runner, selectedRecords, selectionAvailable, language]); + } + return gated; } /** An `action:bar` of list header actions, drawn through {@link useListHeaderLaunchGate}. */ From 666e9df6b78abc06745a974dcf3b16926730fce1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 02:56:06 +0000 Subject: [PATCH 6/6] docs(core): keep refusalBeforeInput's docblock on refusalBeforeInput 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 --- ui/packages/core/src/actions/ActionRunner.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/packages/core/src/actions/ActionRunner.ts b/ui/packages/core/src/actions/ActionRunner.ts index 2c171781ce4..0547a7a9a5b 100644 --- a/ui/packages/core/src/actions/ActionRunner.ts +++ b/ui/packages/core/src/actions/ActionRunner.ts @@ -1830,12 +1830,6 @@ export class ActionRunner { return lastResult; } - /** - * The early refusal of the handler {@link dispatchCollected} would route - * this action to, in the translator's language when it names a key — - * `undefined` when there is none. An update-operation action never reaches - * a registered handler, so it is never asked. - */ /** * The early refusal {@link ActionRunner.execute} would give `action` if it * were launched now, against this runner's context — `undefined` when it @@ -1853,6 +1847,12 @@ export class ActionRunner { return this.refusalBeforeInput(action, resolveDispatchType(action, (name) => this.handlers.has(name))); } + /** + * The early refusal of the handler {@link dispatchCollected} would route + * this action to, in the translator's language when it names a key — + * `undefined` when there is none. An update-operation action never reaches + * a registered handler, so it is never asked. + */ private refusalBeforeInput(action: ActionDef, actionType: string): string | undefined { if (isUpdateOperationAction(action)) return undefined; const handler = actionType ? this.handlers.get(actionType) : undefined;