Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/12331-toolbar-action-never-silent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': minor
---

A flow action launched from a list toolbar now resolves the row selection by the same rule as a script action: `@object-ui/core`'s `resolveServerActionRecordId` (objectui#12331). The flow launcher used to carry its own copy of that rule. Three things change for a flow launch:

- Its refusal reads like the script launcher's: "This action runs on a single record — select exactly one row." for several rows, and "… select a row first." for zero rows on a record-scoped action. It used to say "This flow runs on a single record — …".
- A row's id is read through the action's `recordIdField`, as it already is for a script launch. The copy always read `id`.
- An aggregate launch, one that carries the whole selection as `params._selectedIds`, is no longer refused because several rows are ticked. A script launch was already exempt. This widens what the flow launcher runs: a flow action wired as an aggregate bulk action used to be refused whenever more than one row was ticked. It now dispatches once and carries the ids. An aggregate launch never resolves a single `recordId` from the selection, as on the script launcher. With one row ticked, the trigger carries only `_selectedIds`, where it used to carry that row's `recordId` beside them. With zero rows on a record-scoped flow it is no longer refused; the grid starts an aggregate run only from a non-empty selection.

A script action at `list_toolbar` behaves as before. It refuses zero rows on a record-scoped action and several rows on any action, and it sends nothing in either case. With one row it sends one request, and the runner's success toast reports the result. That toast shows the action's `successMessage`, filled from `${result.*}`, or the runner's generic line when the action declares no message. These behaviours are now pinned through the real list page.
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import { toast } from 'sonner';
import { useConsoleActionRuntime, ConsoleActionRuntimeProvider } from '../useConsoleActionRuntime';
import { modalTargetRefusalMessage } from '../../utils/modalTargetDiagnostics';
import { useAction, usePageVariables, PageVariablesProvider, PageVariableActionBridge } from '@object-ui/react';
import { resolveServerActionRecordId } from '@object-ui/core';

beforeEach(() => {
authFetchSpy.mockReset();
Expand Down Expand Up @@ -693,6 +694,64 @@ describe('flowHandler — list_toolbar selection fallback', () => {
expect(JSON.parse(authFetchSpy.mock.calls[0][1].body).recordId).toBe('row_1');
});

// objectui#12331 — the flow launcher and the script launcher resolve a
// toolbar selection by ONE rule, `@object-ui/core`'s
// `resolveServerActionRecordId`; the flow handler used to hold a second copy.
// The same action shape and selection reach both handlers here, and each
// answer must be the rule's own.
it.each([
['several rows', { locations: ['list_toolbar'] }, [{ id: 'a' }, { id: 'b' }]],
['zero rows on a record-scoped action', { locations: ['list_item', 'list_toolbar'] }, []],
])('refuses %s with the same answer as the script launcher (objectui#12331)', async (_label, shape, rows) => {
const { result } = renderHook(() =>
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'inv' }),
);
const context = { selectedRecords: rows } as any;
const rule = resolveServerActionRecordId({ name: 'reassign', ...shape } as any, context);

let flowRes: any;
let scriptRes: any;
await act(async () => {
flowRes = await result.current.flowHandler(
{ type: 'flow', name: 'reassign', target: 'reassign_wizard', ...shape } as any,
context,
);
scriptRes = await result.current.serverActionHandler(
{ type: 'script', name: 'reassign', ...shape } as any,
context,
);
});

expect(rule.error).toBeTruthy();
expect(flowRes).toEqual({ success: false, error: rule.error });
expect(scriptRes).toEqual({ success: false, error: rule.error });
expect(authFetchSpy).not.toHaveBeenCalled();
});

it('an aggregate launch (`_selectedIds`) is not refused for its selection, as on the script launcher (objectui#12331)', async () => {
authFetchSpy.mockResolvedValue({ ok: true, json: async () => ({ success: true, data: {} }) });
const { result } = renderHook(() =>
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'inv' }),
);

let res: any;
await act(async () => {
res = await result.current.flowHandler(
{
type: 'flow', name: 'bulk_close', target: 'bulk_close_flow',
locations: ['list_item', 'list_toolbar'],
params: { _selectedIds: ['a', 'b'] },
} as any,
{ selectedRecords: [{ id: 'a' }, { id: 'b' }] } as any,
);
});

expect(res).toMatchObject({ success: true });
const body = JSON.parse(authFetchSpy.mock.calls[0][1].body);
expect(body.recordId ?? null).toBeNull();
expect(body.params._selectedIds).toEqual(['a', 'b']);
});

it('end-to-end: selection published via updateContext reaches the flow trigger', async () => {
authFetchSpy.mockResolvedValue({ ok: true, json: async () => ({ success: true, data: {} }) });

Expand Down
39 changes: 17 additions & 22 deletions packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import type {
ResultDialogHandler,
ToastHandler,
} from '@object-ui/core';
import { actionErrorDetail, captureUpdateUndoData, isRecordScopedAction, resolveRecordIdParamSeed } from '@object-ui/core';
import { actionErrorDetail, captureUpdateUndoData, resolveRecordIdParamSeed, resolveServerActionRecordId } from '@object-ui/core';
import { useActionModal } from './useActionModal.js';
import { ActionConfirmDialog, type ConfirmDialogState } from '../views/ActionConfirmDialog.js';
import { ActionParamDialog, type ParamDialogState } from '../views/ActionParamDialog.js';
Expand Down Expand Up @@ -619,28 +619,23 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
try {
const baseUrl = import.meta.env.VITE_SERVER_URL || '';
const params = { ...(action.params || {}) } as Record<string, any>;
const rowRecord = params._rowRecord as Record<string, any> | undefined;
delete params._rowRecord;
let recordId = params.recordId ?? rowRecord?.id;
// list_toolbar invocations carry no `_rowRecord` — fall back to the
// grid's checkbox selection, which ObjectGrid publishes into the runner
// context as `selectedRecords`. Flows take a single `recordId` input
// variable, so a multi-row selection is ambiguous: block with a message
// instead of triggering a run that fails at its first record-bound node.
// Zero selection is blocked too when the action is record-scoped (it
// also mounts on list rows) — otherwise the wizard opens, collects
// input, and dies at its first record-bound node ("Update requires an
// ID"). Pure object-level toolbar flows keep triggering with no record.
if (recordId == null) {
const selected = Array.isArray(context?.selectedRecords) ? context!.selectedRecords : [];
if (selected.length === 1) {
recordId = selected[0]?.id;
} else if (selected.length > 1) {
return { success: false, error: 'This flow runs on a single record — select exactly one row.' };
} else if (isRecordScopedAction(action)) {
return { success: false, error: 'This flow runs on a single record — select a row first.' };
}
}
// The record a launch runs on, by the ONE selection rule the `script`
// launcher uses (objectui#12331): `@object-ui/core`'s
// `resolveServerActionRecordId`. An explicit `params.recordId`, then the
// list_item row (`_rowRecord`), then — for a list_toolbar launch, which
// carries no row — the grid's checkbox selection (`selectedRecords`,
// published into the runner context by ObjectGrid). One row supplies the
// record. Several rows are refused: a flow takes a single `recordId`
// input, so the run would fail at its first record-bound node. Zero rows
// are refused when the action is record-scoped by its `locations`, so the
// wizard does not open, collect input and die there ("Update requires an
// ID"); a pure object-level toolbar flow keeps triggering with no record.
// This handler used to hold a second copy of that rule, worded for flows.
// ⛔ Never re-add one here: a rule written twice drifts.
const resolution = resolveServerActionRecordId(action, context);
if (resolution.error) return { success: false, error: resolution.error };
const recordId = resolution.recordId;
if (recordId != null && params.recordId == null) params.recordId = recordId;
// Ask the store for the object type BEFORE the lookup, as the param
// dialog does (`6cc910b6d`): it answers from cache once warm, and it is
Expand Down
Loading
Loading