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
36 changes: 36 additions & 0 deletions .changeset/bulk-action-aggregate-execution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@object-ui/types": minor
"@object-ui/plugin-grid": minor
"@object-ui/core": minor
"@object-ui/app-shell": minor
---

Aggregate single-call mode for bulk actions: `execution: 'aggregate'` (objectui#3139).

A `bulkActionDefs` entry with `operation: 'custom'` used to have exactly one
dispatch shape: one action-runner call per selected record (`_rowRecord`
attached). "Select N rows → ONE call that receives every selected id" — the
zip-of-QR-codes / merged-PDF / batch-print shape — could not be expressed, so
downstream projects fell back to per-row `window.open` storms or gave up.

`BulkActionDef` now carries `execution?: 'perRecord' | 'aggregate'` (default
`'perRecord'`, existing views untouched). An aggregate def dispatches its
action exactly once for the whole selection with `params._selectedIds:
string[]` injected and the full records published as
`context.selectedRecords`. The authored form usually just names a declared
object action — `{ name, operation: 'custom', execution: 'aggregate' }` —
and `resolveBulkActions` attaches the declaration. Results are
all-or-nothing: a failure is attributed to every id with the real error and
per-row Retry is hidden (re-running the action is the retry; a total failure
keeps the selection). `batchSize` does not apply; `maxRecords` still gates.

The executor rides the existing `executeBulkBatch` bulk-first decision tree —
the aggregate call is its `bulkCall`, and the per-row "fallback" only
re-throws the captured error for attribution, never fans out N dispatches
against an endpoint written for one `_selectedIds` call.

Also: url/api target interpolation now exposes `${ctx.selection.ids}` (comma
-joined) and `${ctx.selection.count}` from the grid's checkbox selection, so
a plain `list_toolbar` action can carry the selection without bulk plumbing;
the console's server-action handler recognizes `_selectedIds` and skips the
single-record multi-select guard for aggregate dispatches.
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,37 @@ describe('serverActionHandler — list_toolbar selection fallback', () => {
expect(res).toMatchObject({ success: true });
expect(String(authFetchSpy.mock.calls[0][0])).toContain('/api/v1/actions/inv/export_all');
});

it('an aggregate dispatch (_selectedIds) passes the multi-select guard and posts no recordId (#3139)', 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.serverActionHandler(
{
type: 'script',
name: 'generate_qr_zip',
locations: ['list_item', 'list_toolbar'],
params: { _selectedIds: ['a', 'b'], format: 'png' },
} as any,
// Multi-select would block a single-record dispatch — the injected
// `_selectedIds` marks this as the aggregate shape instead.
{ selectedRecords: [{ id: 'a' }, { id: 'b' }] } as any,
);
});

expect(res).toMatchObject({ success: true });
const [url, init] = authFetchSpy.mock.calls[0];
expect(String(url)).toContain('/api/v1/actions/inv/generate_qr_zip');
const body = JSON.parse(init.body);
// The server reads the id array, never a synthesized single recordId.
expect(body.recordId).toBeUndefined();
expect(body.params._selectedIds).toEqual(['a', 'b']);
expect(body.params.format).toBe('png');
});
});

describe('ConsoleActionRuntimeProvider — page-level action execution', () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -535,12 +535,18 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
delete (params as any)._rowRecord;
const recordIdField = (action as any).recordIdField || 'id';
let resolvedRecordId = (params as any).recordId ?? _rowRecord?.[recordIdField];
// An AGGREGATE bulk dispatch (objectui#3139) carries the whole selection
// as `params._selectedIds` — the server reads that array, not `recordId`.
// The single-record fallback below must not touch it: resolving a
// recordId would mislabel the run as record-scoped, and the multi-select
// guard would block exactly the selection this dispatch exists to carry.
const isAggregateDispatch = Array.isArray((params as any)._selectedIds);
// Same list_toolbar fallback as flowHandler: no `_rowRecord` means the
// action came from the toolbar — resolve the recordId from the grid's
// checkbox selection (published as `selectedRecords`). Multi-select is
// ambiguous for a single-record action, so block with a message; so is
// zero selection when the action is record-scoped (see isRecordScoped).
if (resolvedRecordId == null) {
if (resolvedRecordId == null && !isAggregateDispatch) {
const selected = Array.isArray(context?.selectedRecords) ? context!.selectedRecords : [];
if (selected.length === 1) {
resolvedRecordId = selected[0]?.[recordIdField];
Expand Down
28 changes: 25 additions & 3 deletions packages/core/src/actions/ActionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1162,9 +1162,15 @@ export class ActionRunner {
* fragment.
*
* `ctx.origin`, `ctx.user.*`, `ctx.org.*`, `ctx.recordId` are surfaced
* implicitly from the runner's ActionContext + window. Consumers can
* extend by stuffing extra keys under `context.ctx = {...}` before
* calling `runner.execute()`.
* implicitly from the runner's ActionContext + window, and
* `ctx.selection.ids` / `ctx.selection.count` reflect the grid's current
* checkbox selection (`context.selectedRecords`) — `${ctx.selection.ids}`
* comma-joins the ids via the standard `String(array)` behaviour, so a
* list_toolbar url/api action can carry the selection without any bulk
* plumbing (objectui#3139). In an aggregate bulk dispatch,
* `${param._selectedIds}` interpolates the same ids from the injected
* param. Consumers can extend by stuffing extra keys under
* `context.ctx = {...}` before calling `runner.execute()`.
*/
private interpolateTarget(target: string, action: ActionDef): string {
if (typeof target !== 'string' || target.indexOf('${') === -1) return target;
Expand Down Expand Up @@ -1198,6 +1204,22 @@ export class ActionRunner {
user: this.context.user ?? {},
org: this.context.org ?? this.context.organization ?? {},
recordId: this.context.record?.id ?? this.context.recordId,
// Current list selection (published by the grid as `selectedRecords`).
// `${ctx.selection.ids}` in a url/api target comma-joins the ids —
// String(array) — giving toolbar actions selection access without any
// bulk-executor involvement (objectui#3139). Empty when nothing is
// selected, so authors can treat "" as "no selection".
selection: {
ids: Array.isArray(this.context.selectedRecords)
? this.context.selectedRecords
.map((r: Record<string, unknown> | null | undefined) => r?.id)
.filter((v: unknown) => v != null)
.map(String)
: [],
count: Array.isArray(this.context.selectedRecords)
? this.context.selectedRecords.length
: 0,
},
};
if (this.context.ctx && typeof this.context.ctx === 'object') {
Object.assign(ctx, this.context.ctx);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,52 @@ describe('ActionRunner - target interpolation', () => {
expect(navHandler.mock.calls[0][0]).toBe('/u/u_42');
});

it('exposes ctx.selection.ids / ctx.selection.count from selectedRecords (#3139)', async () => {
const navHandler = vi.fn();
const runner = new ActionRunner({
selectedRecords: [{ id: 'd1' }, { id: 'd2' }, { name: 'no-id' }],
});
runner.setNavigationHandler(navHandler);

await runner.execute({
type: 'url',
target: '/qr/zip?ids=${ctx.selection.ids}&n=${ctx.selection.count}',
});

// String(array) comma-joins; the comma arrives percent-encoded in query
// position (standard encodeURIComponent behaviour, servers decode it).
// Rows without an id are dropped from ids but still counted — count
// reflects the selection size, ids only the addressable rows.
expect(navHandler.mock.calls[0][0]).toBe('/qr/zip?ids=d1%2Cd2&n=3');
});

it('resolves ctx.selection to empty ids and zero count when nothing is selected', async () => {
const navHandler = vi.fn();
const runner = new ActionRunner({});
runner.setNavigationHandler(navHandler);

await runner.execute({
type: 'url',
target: '/qr/zip?ids=${ctx.selection.ids}&n=${ctx.selection.count}',
});

expect(navHandler.mock.calls[0][0]).toBe('/qr/zip?ids=&n=0');
});

it('interpolates ${param._selectedIds} injected by an aggregate bulk dispatch', async () => {
const navHandler = vi.fn();
const runner = new ActionRunner({});
runner.setNavigationHandler(navHandler);

await runner.execute({
type: 'url',
target: '/qr/zip?ids=${param._selectedIds}',
params: { _selectedIds: ['d1', 'd2'] },
});

expect(navHandler.mock.calls[0][0]).toBe('/qr/zip?ids=d1%2Cd2');
});

it('substitutes ${param.X} into api endpoints (fetch URL)', async () => {
const fetchSpy = vi
.spyOn(globalThis as any, 'fetch')
Expand Down
30 changes: 26 additions & 4 deletions packages/plugin-grid/demo/bulk-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@ const RECALC = {
],
};

/**
* Aggregate counterpart (#3139): the view opts in with `execution:
* 'aggregate'` below, so the whole selection arrives in ONE dispatch carrying
* `params._selectedIds` — the "zip of QR codes for N devices" shape.
*/
const EXPORT_ZIP = {
name: 'bulk_export_zip',
label: 'Export Zip',
icon: 'archive',
type: 'api',
target: '/api/demo/export-zip',
};

/** Candidate directories for the picker-param defs (#3064). */
const USERS = [
{ id: 'u1', name: '现场工人-测试', email: 'worker@demo.dev' },
Expand Down Expand Up @@ -158,7 +171,7 @@ const dataSource: any = {
progress: { type: 'percent', label: 'Progress' },
},
// The single source the grid folds into the selection bar.
actions: [MARK_DONE, RECALC],
actions: [MARK_DONE, RECALC, EXPORT_ZIP],
};
},
};
Expand Down Expand Up @@ -195,11 +208,14 @@ window.fetch = (async (input: any, init: any) => {
const url = String(input);
if (!url.startsWith('/api/demo/')) return realFetch(input, init);
const body = init?.body ? JSON.parse(init.body) : {};
const recordId = body._rowRecord?.id ?? '(none)';
// An aggregate dispatch (#3139) carries the whole selection in one call.
const recordId = Array.isArray(body._selectedIds)
? `[${body._selectedIds.join(',')}]`
: body._rowRecord?.id ?? '(none)';
// t3 fails — proves the result panel attributes failures per record instead
// of reporting a blanket success.
const status = recordId === 't3' ? 422 : 200;
const { _rowRecord, ...rest } = body;
const { _rowRecord, _selectedIds, ...rest } = body;
pushLog({ url, recordId, params: JSON.stringify(rest), status });
return {
ok: status === 200,
Expand All @@ -225,6 +241,10 @@ const schema: any = {
bulkActions: ['bulk_mark_done', 'bulk_recalc_estimate', 'legacy_only_handler'],
// Rich defs with picker params — the shared-field-widget surface (#3064).
bulkActionDefs: [
// Aggregate mode (#3139): names the declared action, ONE dispatch for the
// whole selection with `params._selectedIds` — contrast with Mark Done's
// one-line-per-record log output.
{ name: 'bulk_export_zip', operation: 'custom', execution: 'aggregate' },
{
name: 'set_manager',
label: 'Set Manager',
Expand Down Expand Up @@ -264,7 +284,9 @@ function Demo() {
<p style={{ fontSize: 12, color: 'hsl(var(--muted-foreground))', margin: '4px 0 0' }}>
Select rows → the bar shows <b>Mark Done</b> and <b>Recalculate Estimate</b> (names
from the view&apos;s <code>bulkActions</code>, promoted to their declared object
actions) and <b>Legacy Only Handler</b> (unresolvable, dispatched by name).
actions), <b>Export Zip</b> (an <code>execution: &apos;aggregate&apos;</code> def —
ONE dispatch carrying every selected id, #3139) and <b>Legacy Only
Handler</b> (unresolvable, dispatched by name).
</p>
</header>

Expand Down
56 changes: 51 additions & 5 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1871,6 +1871,22 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
// (Plain function for the same reason as `resolveBulkRows` above: this point
// in the body is past render paths that short-circuit, so a hook here would
// tripwire the rules-of-hooks balance.)
// Shared def-key strip for BOTH bulk dispatchers below. The dialog already
// collected params and took the confirmation, so the dispatch must remove
// them from the source action — leaving them on would make the runner
// re-prompt (once per record on the per-record path). Kept in one place so
// the two dispatchers cannot drift on what gets stripped.
const stripCollectedActionKeys = (source: Record<string, any>) => {
const {
params: _declaredParams,
actionParams: _actionParams,
confirmText: _confirmText,
confirm: _confirm,
...rest
} = source;
return rest;
};

const runBulkActionRecord = async (
def: BulkActionDef,
row: Record<string, unknown>,
Expand All @@ -1879,16 +1895,12 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
const source = def.actionDef as Record<string, any> | undefined;
if (!source) return;
const {
params: _declaredParams,
actionParams: _actionParams,
confirmText: _confirmText,
confirm: _confirm,
// A one-shot reveal (2FA code, fresh OAuth secret) is a single-record
// affordance by construction, and the runner AWAITS acknowledgement —
// one modal per record would stall the run behind N dialogs.
resultDialog: _resultDialog,
...rest
} = source;
} = stripCollectedActionKeys(source);
const res = await executeAction({
...rest,
// `_rowRecord` is the same row-context key the list_item path attaches,
Expand All @@ -1902,6 +1914,39 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
throw new Error(res?.error || `Action ${def.name} failed`);
}
};

// Whole-selection dispatcher for a def that opted into
// `execution: 'aggregate'` (objectui#3139): ONE runner dispatch carrying
// every selected id as `params._selectedIds`, so the target endpoint can
// produce a single aggregate artifact (zip of QR codes, merged PDF…).
// Unlike the per-record path, `resultDialog` is NOT stripped — the
// per-record rationale (one modal per record stalls the run) does not
// apply to a single call, and a one-shot reveal of the aggregate result
// (download URL, generated codes) is exactly this mode's use case.
const runBulkActionAggregate = async (
def: BulkActionDef,
rows: Array<Record<string, unknown>>,
params: Record<string, unknown>,
): Promise<void> => {
const source = def.actionDef as Record<string, any> | undefined;
if (!source) return;
const rest = stripCollectedActionKeys(source);
const ids = rows.map((r) => (r.id != null ? String(r.id) : '')).filter(Boolean);
// Publish the EXPANDED, eligibility-filtered rows the run actually covers
// — the standing selection effect only carries the current page's ticks,
// while "select all N matching" and the `visible` partition can both
// change the real set. Handlers and `${ctx.selection.*}` interpolation
// read this. The next selection change re-publishes, so no cleanup here.
updateActionContext({ selectedRecords: rows });
const res = await executeAction({
...rest,
params: { ...params, _selectedIds: ids },
toast: { showOnSuccess: false, showOnError: false },
} as any);
if (!res?.success) {
throw new Error(res?.error || `Action ${def.name} failed`);
}
};
const handleBulkDialogClose = (result?: BulkResult | null) => {
setActiveBulkDef(null);
setActiveBulkRows([]);
Expand Down Expand Up @@ -2729,6 +2774,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
resource={schema.objectName ?? ''}
objectFields={objectSchema?.fields}
runAction={runBulkActionRecord}
runAggregate={runBulkActionAggregate}
/>
);

Expand Down
Loading
Loading