From 636ac2fbd3fd88c261e6651df35d6473e7463a9c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 08:11:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(grid):=20aggregate=20single-call=20mode=20?= =?UTF-8?q?for=20bulk=20actions=20=E2=80=94=20execution:=20'aggregate'=20(?= =?UTF-8?q?#3139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom bulkActionDefs entry could only fan out one action-runner dispatch per selected record; "select N rows → ONE call with every id" (single zip download, merged PDF, batch print) was inexpressible. BulkActionDef now carries execution?: 'perRecord' | 'aggregate' (default 'perRecord'). An aggregate def dispatches its action exactly once for the whole eligible selection, injecting params._selectedIds: string[] and publishing the rows as context.selectedRecords. The authored form names a declared object action ({ name, operation: 'custom', execution: 'aggregate' }) and resolveBulkActions attaches the declaration. - executor: the aggregate call rides executeBulkBatch's existing bulkCall slot (one batch, batchSize ignored, single-row selections still aggregate); the perRow fallback only re-throws the captured error for per-id attribution — never N re-dispatches against a single-call endpoint, and a def with no wired dispatcher fails loudly instead of degrading to fan-out - results are all-or-nothing; per-row Retry is hidden (total failure keeps the selection, re-running the action is the retry) - console serverActionHandler recognizes _selectedIds and skips the single-record multi-select guard; posts no synthesized recordId - url/api target interpolation exposes ${ctx.selection.ids} / ${ctx.selection.count} so plain list_toolbar actions can carry the selection; ${param._selectedIds} works in aggregate dispatches - pins for both semantics side by side (one-call aggregate vs one-request-per-record), resolver merge scoping, guard skip, interpolation Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01S9aiswZBzoVYsyLKRuGByE --- .changeset/bulk-action-aggregate-execution.md | 36 +++++ .../useConsoleActionRuntime.test.tsx | 31 ++++ .../src/hooks/useConsoleActionRuntime.tsx | 8 +- packages/core/src/actions/ActionRunner.ts | 28 +++- .../ActionRunner.resultDialog.test.ts | 46 ++++++ packages/plugin-grid/demo/bulk-actions.tsx | 30 +++- packages/plugin-grid/src/ObjectGrid.tsx | 56 ++++++- .../objectBulkActionDispatch.test.tsx | 79 ++++++++++ .../src/__tests__/resolveBulkActions.test.ts | 116 +++++++++++++++ .../src/__tests__/useBulkExecutor.test.ts | 138 ++++++++++++++++++ .../src/components/BulkActionDialog.tsx | 20 ++- .../plugin-grid/src/hooks/useBulkExecutor.ts | 109 ++++++++++++-- .../plugin-grid/src/resolveBulkActions.ts | 42 +++++- packages/types/src/objectql.ts | 30 +++- 14 files changed, 732 insertions(+), 37 deletions(-) create mode 100644 .changeset/bulk-action-aggregate-execution.md diff --git a/.changeset/bulk-action-aggregate-execution.md b/.changeset/bulk-action-aggregate-execution.md new file mode 100644 index 0000000000..d8d6936a64 --- /dev/null +++ b/.changeset/bulk-action-aggregate-execution.md @@ -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. diff --git a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx index 3167fca57d..473f593047 100644 --- a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx @@ -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', () => { diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 6c6b8e979a..03138d7633 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -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]; diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index 0a075f4896..b73e07c6d4 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -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; @@ -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 | 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); diff --git a/packages/core/src/actions/__tests__/ActionRunner.resultDialog.test.ts b/packages/core/src/actions/__tests__/ActionRunner.resultDialog.test.ts index c1c93eb49e..408badf789 100644 --- a/packages/core/src/actions/__tests__/ActionRunner.resultDialog.test.ts +++ b/packages/core/src/actions/__tests__/ActionRunner.resultDialog.test.ts @@ -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') diff --git a/packages/plugin-grid/demo/bulk-actions.tsx b/packages/plugin-grid/demo/bulk-actions.tsx index 156a85a8a4..53b8801533 100644 --- a/packages/plugin-grid/demo/bulk-actions.tsx +++ b/packages/plugin-grid/demo/bulk-actions.tsx @@ -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' }, @@ -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], }; }, }; @@ -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, @@ -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', @@ -264,7 +284,9 @@ function Demo() {

Select rows → the bar shows Mark Done and Recalculate Estimate (names from the view's bulkActions, promoted to their declared object - actions) and Legacy Only Handler (unresolvable, dispatched by name). + actions), Export Zip (an execution: 'aggregate' def — + ONE dispatch carrying every selected id, #3139) and Legacy Only + Handler (unresolvable, dispatched by name).

diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index fd4541f26f..48d3b02c82 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -1871,6 +1871,22 @@ export const ObjectGrid: React.FC = ({ // (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) => { + const { + params: _declaredParams, + actionParams: _actionParams, + confirmText: _confirmText, + confirm: _confirm, + ...rest + } = source; + return rest; + }; + const runBulkActionRecord = async ( def: BulkActionDef, row: Record, @@ -1879,16 +1895,12 @@ export const ObjectGrid: React.FC = ({ const source = def.actionDef as Record | 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, @@ -1902,6 +1914,39 @@ export const ObjectGrid: React.FC = ({ 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>, + params: Record, + ): Promise => { + const source = def.actionDef as Record | 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([]); @@ -2729,6 +2774,7 @@ export const ObjectGrid: React.FC = ({ resource={schema.objectName ?? ''} objectFields={objectSchema?.fields} runAction={runBulkActionRecord} + runAggregate={runBulkActionAggregate} /> ); diff --git a/packages/plugin-grid/src/__tests__/objectBulkActionDispatch.test.tsx b/packages/plugin-grid/src/__tests__/objectBulkActionDispatch.test.tsx index a8ec5bc88c..a6d6d49c09 100644 --- a/packages/plugin-grid/src/__tests__/objectBulkActionDispatch.test.tsx +++ b/packages/plugin-grid/src/__tests__/objectBulkActionDispatch.test.tsx @@ -224,6 +224,85 @@ describe('view-declared bulk actions (objectui#3002)', () => { }); }); +/** + * `execution: 'aggregate'` (objectui#3139): the whole selection in ONE + * dispatch, pinned side by side with the per-record semantics above. The + * authored def names the object action; the dispatch carries every selected + * id under `params._selectedIds` so the server can produce a single + * aggregate artifact (zip of QR codes, merged PDF…). + */ +describe('aggregate bulk actions (objectui#3139)', () => { + const AGGREGATE_VIEW = { + bulkActionDefs: [{ name: 'push_down', operation: 'custom', execution: 'aggregate' }], + }; + + it('issues exactly ONE request carrying every selected id under _selectedIds', async () => { + await renderAndSelectAll({ + objectActions: [PUSH_DOWN], + schema: AGGREGATE_VIEW, + }); + + const button = await screen.findByTestId('bulk-action-push_down'); + // The authored def resolved the object action, so it carries its label. + expect(button).toHaveTextContent('下推'); + fireEvent.click(button); + fireEvent.click(await screen.findByRole('button', { name: 'Run' })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/plans/push'); + const body = JSON.parse((fetchMock.mock.calls[0][1] as any).body); + expect(body._selectedIds).toEqual(['r1', 'r2']); + // The per-record row-context key must NOT ride along — this is the + // aggregate shape, not a per-record dispatch that happens to be first. + expect(body._rowRecord).toBeUndefined(); + await waitFor(() => expect(screen.getByText(/Succeeded 2 \/ 2/)).toBeInTheDocument()); + }); + + it('attributes a failed aggregate call to every record, with no per-row Retry', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: { get: () => 'application/json' }, + json: async () => ({ error: 'zip generation failed' }), + }); + await renderAndSelectAll({ + objectActions: [PUSH_DOWN], + schema: AGGREGATE_VIEW, + }); + + fireEvent.click(await screen.findByTestId('bulk-action-push_down')); + fireEvent.click(await screen.findByRole('button', { name: 'Run' })); + + // ONE call — a failed aggregate run must not fan out per-record dispatches + // against an endpoint written for a single `_selectedIds` call. + await waitFor(() => expect(screen.getByText(/Succeeded 0 \/ 2/)).toBeInTheDocument()); + expect(fetchMock).toHaveBeenCalledTimes(1); + // Each record shows the one real error (the builtin api executor + // formats a non-2xx response as `HTTP `, same as the + // per-record pin above)… + expect(screen.getByTestId('bulk-error-row-r1')).toHaveTextContent('HTTP 500'); + expect(screen.getByTestId('bulk-error-row-r2')).toHaveTextContent('HTTP 500'); + // …but there is no per-row slice to re-attempt: the whole-run re-run is + // the retry (a total failure keeps the selection for exactly that). + expect(screen.queryByTestId('bulk-error-retry-r1')).not.toBeInTheDocument(); + }); + + it('a per-record def with the same shape still fans out — mode lives on the def', async () => { + await renderAndSelectAll({ + objectActions: [PUSH_DOWN], + schema: { bulkActions: ['push_down'] }, + }); + + fireEvent.click(await screen.findByTestId('bulk-action-push_down')); + fireEvent.click(await screen.findByRole('button', { name: 'Run' })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + const bodies = fetchMock.mock.calls.map(c => JSON.parse((c[1] as any).body)); + expect(bodies.every(b => b._selectedIds === undefined)).toBe(true); + }); +}); + /** * `visible` is evaluated per selected record, with that record in scope * (objectui#3067). Before this the bar evaluated it with NO record bound, and diff --git a/packages/plugin-grid/src/__tests__/resolveBulkActions.test.ts b/packages/plugin-grid/src/__tests__/resolveBulkActions.test.ts index 3861732341..80a018f2c2 100644 --- a/packages/plugin-grid/src/__tests__/resolveBulkActions.test.ts +++ b/packages/plugin-grid/src/__tests__/resolveBulkActions.test.ts @@ -186,4 +186,120 @@ describe('resolveBulkActions', () => { expect(defs[0].label).toBe('Push Down'); }); + + // [#3139] `execution: 'aggregate'` authored defs — the ONE case where an + // authored def gets the object action resolved onto it. + describe('aggregate authored defs', () => { + it('attaches the named object action as actionDef, without the promoted batchSize', () => { + const authored: BulkActionDef = { + name: 'push_down', + operation: 'custom', + execution: 'aggregate', + }; + const { defs, unresolved } = resolveBulkActions({ + bulkActionDefs: [authored], + objectActions: [PUSH_DOWN], + }); + + expect(unresolved).toEqual([]); + expect(defs).toHaveLength(1); + expect(defs[0]).toMatchObject({ + name: 'push_down', + operation: 'custom', + execution: 'aggregate', + label: '下推', + actionDef: PUSH_DOWN, + }); + // One call by contract — never chunked, so the per-record fan-out + // concurrency cap must not ride along. + expect(defs[0].batchSize).toBeUndefined(); + }); + + it('authored keys win over the resolved action', () => { + const authored: BulkActionDef = { + name: 'push_down', + label: 'Batch push', + variant: 'danger', + operation: 'custom', + execution: 'aggregate', + maxRecords: 100, + }; + const { defs } = resolveBulkActions({ + bulkActionDefs: [authored], + objectActions: [PUSH_DOWN], + }); + + expect(defs[0]).toMatchObject({ + label: 'Batch push', + variant: 'danger', + maxRecords: 100, + actionDef: PUSH_DOWN, + }); + }); + + it('leaves an aggregate def naming no declared action untouched', () => { + const authored: BulkActionDef = { + name: 'ghost_action', + operation: 'custom', + execution: 'aggregate', + }; + const { defs } = resolveBulkActions({ + bulkActionDefs: [authored], + objectActions: [PUSH_DOWN], + }); + + // No actionDef → the executor has nothing to dispatch (no-op), same as + // any other plain custom def. + expect(defs).toEqual([authored]); + expect(defs[0].actionDef).toBeUndefined(); + }); + + it('does NOT touch a plain custom def that shares a declared action name', () => { + // Regression guard for the merge's scoping: without an explicit + // `execution: 'aggregate'`, an authored custom def keeps its historical + // no-op/callout meaning even when an object action shares its name. + const authored: BulkActionDef = { name: 'push_down', operation: 'custom' }; + const { defs } = resolveBulkActions({ + bulkActionDefs: [authored], + objectActions: [PUSH_DOWN], + }); + + expect(defs).toEqual([authored]); + expect(defs[0].actionDef).toBeUndefined(); + }); + + it('an aggregate def with an inline actionDef is left as authored', () => { + const inline = { name: 'push_down', type: 'api', target: '/custom' }; + const authored: BulkActionDef = { + name: 'push_down', + operation: 'custom', + execution: 'aggregate', + actionDef: inline, + }; + const { defs } = resolveBulkActions({ + bulkActionDefs: [authored], + objectActions: [PUSH_DOWN], + }); + + expect(defs).toEqual([authored]); + expect(defs[0].actionDef).toBe(inline); + }); + + it('still claims its name against bulkActions duplicates', () => { + const authored: BulkActionDef = { + name: 'push_down', + operation: 'custom', + execution: 'aggregate', + }; + const { defs } = resolveBulkActions({ + bulkActions: ['push_down'], + bulkActionDefs: [authored], + objectActions: [PUSH_DOWN], + }); + + // One button, the authored aggregate one — not a promoted twin. + expect(defs).toHaveLength(1); + expect(defs[0].execution).toBe('aggregate'); + }); + }); }); diff --git a/packages/plugin-grid/src/__tests__/useBulkExecutor.test.ts b/packages/plugin-grid/src/__tests__/useBulkExecutor.test.ts index 0156737357..f69be2a809 100644 --- a/packages/plugin-grid/src/__tests__/useBulkExecutor.test.ts +++ b/packages/plugin-grid/src/__tests__/useBulkExecutor.test.ts @@ -340,4 +340,142 @@ describe('useBulkExecutor', () => { expect(update).toHaveBeenCalledWith('task', '2', { priority: 'medium' }); }); }); + + // [#3139] `execution: 'aggregate'` — ONE dispatch for the whole selection, + // pinned side by side with the per-record semantics above so neither can + // drift into the other. + describe('aggregate execution', () => { + const agg = (op: Partial = {}): BulkActionDef => ({ + name: 'generate_qr_zip', + operation: 'custom', + execution: 'aggregate', + actionDef: { name: 'generate_qr_zip', type: 'api', target: '/api/v1/qr/zip' }, + ...op, + } as BulkActionDef); + const ds = () => ({ update: vi.fn(), delete: vi.fn() }); + + it('dispatches runAggregate exactly once with every row and the params', async () => { + const runAggregate = vi.fn(async () => undefined); + const runAction = vi.fn(async () => undefined); + const rows = [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }]; + const { result } = renderHook(() => + useBulkExecutor({ resource: 'device', dataSource: ds(), runAction, runAggregate })); + + await act(async () => { + await result.current.run(agg(), rows, { format: 'png' }); + }); + + expect(runAggregate).toHaveBeenCalledTimes(1); + const [defArg, rowsArg, paramsArg] = runAggregate.mock.calls[0] as unknown as [ + BulkActionDef, Array>, Record, + ]; + expect(defArg.name).toBe('generate_qr_zip'); + expect(rowsArg.map(r => r.id)).toEqual(['r1', 'r2', 'r3']); + expect(paramsArg).toEqual({ format: 'png' }); + // The per-record dispatcher must never fire in aggregate mode. + expect(runAction).not.toHaveBeenCalled(); + expect(result.current.result).toMatchObject({ total: 3, succeeded: 3, failed: 0 }); + }); + + it('a single-row selection still goes through the ONE aggregate call, never per-record', async () => { + const runAggregate = vi.fn(async () => undefined); + const runAction = vi.fn(async () => undefined); + const { result } = renderHook(() => + useBulkExecutor({ resource: 'device', dataSource: ds(), runAction, runAggregate })); + + await act(async () => { + await result.current.run(agg(), [{ id: 'only' }], {}); + }); + + expect(runAggregate).toHaveBeenCalledTimes(1); + expect(runAction).not.toHaveBeenCalled(); + expect(result.current.result?.succeeded).toBe(1); + }); + + it('ignores batchSize — 5 rows with batchSize 2 is still one call', async () => { + const runAggregate = vi.fn(async () => undefined); + const rows = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }, { id: '5' }]; + const { result } = renderHook(() => + useBulkExecutor({ resource: 'device', dataSource: ds(), runAggregate })); + + await act(async () => { + await result.current.run(agg({ batchSize: 2 }), rows, {}); + }); + + expect(runAggregate).toHaveBeenCalledTimes(1); + expect((runAggregate.mock.calls[0][1] as unknown[]).length).toBe(5); + expect(result.current.result?.succeeded).toBe(5); + }); + + it('attributes a failed aggregate call to every id with the real error — no per-record re-dispatch', async () => { + const runAggregate = vi.fn(async () => { + throw new Error('zip generation failed'); + }); + const runAction = vi.fn(async () => undefined); + const { result } = renderHook(() => + useBulkExecutor({ resource: 'device', dataSource: ds(), runAction, runAggregate })); + + await act(async () => { + await result.current.run(agg(), [{ id: 'a' }, { id: 'b' }], {}); + }); + + expect(runAggregate).toHaveBeenCalledTimes(1); + expect(runAction).not.toHaveBeenCalled(); + expect(result.current.result?.succeeded).toBe(0); + expect(result.current.result?.failed).toBe(2); + expect(result.current.result?.errors).toEqual([ + { id: 'a', error: 'zip generation failed' }, + { id: 'b', error: 'zip generation failed' }, + ]); + }); + + it('fails loudly when no runAggregate is wired instead of degrading to per-record fan-out', async () => { + const runAction = vi.fn(async () => undefined); + const { result } = renderHook(() => + useBulkExecutor({ resource: 'device', dataSource: ds(), runAction })); + + await act(async () => { + await result.current.run(agg(), [{ id: 'a' }, { id: 'b' }], {}); + }); + + expect(runAction).not.toHaveBeenCalled(); + expect(result.current.result?.failed).toBe(2); + expect(result.current.result?.errors[0].error).toContain('no dispatcher wired'); + }); + + it('retry() refuses aggregate rows — the whole-run re-run is the retry', async () => { + const runAggregate = vi.fn(async () => { + throw new Error('boom'); + }); + const { result } = renderHook(() => + useBulkExecutor({ resource: 'device', dataSource: ds(), runAggregate })); + + await act(async () => { + await result.current.run(agg(), [{ id: 'a' }], {}); + }); + let ok: boolean | undefined; + await act(async () => { + ok = await result.current.retry('a'); + }); + expect(ok).toBe(false); + expect(runAggregate).toHaveBeenCalledTimes(1); + }); + + it('a def WITHOUT execution: aggregate keeps per-record dispatch even when runAggregate is wired', async () => { + const runAggregate = vi.fn(async () => undefined); + const runAction = vi.fn(async () => undefined); + const rows = [{ id: '1' }, { id: '2' }]; + const { result } = renderHook(() => + useBulkExecutor({ resource: 'device', dataSource: ds(), runAction, runAggregate })); + + await act(async () => { + await result.current.run(agg({ execution: undefined }), rows, {}); + }); + + // Mode selection lives on the def, not on which capabilities are wired. + expect(runAggregate).not.toHaveBeenCalled(); + expect(runAction).toHaveBeenCalledTimes(2); + expect(result.current.result?.succeeded).toBe(2); + }); + }); }); diff --git a/packages/plugin-grid/src/components/BulkActionDialog.tsx b/packages/plugin-grid/src/components/BulkActionDialog.tsx index 63bef3f76f..808958f787 100644 --- a/packages/plugin-grid/src/components/BulkActionDialog.tsx +++ b/packages/plugin-grid/src/components/BulkActionDialog.tsx @@ -76,6 +76,13 @@ export interface BulkActionDialogProps { * the runner re-prompting per record. */ runAction?: BulkExecutorOptions['runAction']; + /** + * Whole-selection dispatcher for a def with `execution: 'aggregate'` + * (objectui#3139) — see {@link BulkExecutorOptions.runAggregate}. Same + * params/confirm collection as `runAction`, but the executor calls this + * exactly once with every eligible row instead of once per record. + */ + runAggregate?: BulkExecutorOptions['runAggregate']; /** * Selected records the def's `visible` predicate excluded (objectui#3067). * `rows` already has them removed; this is what lets the confirm step say @@ -107,6 +114,7 @@ export const BulkActionDialog: React.FC = ({ labelKey = 'name', objectFields, runAction, + runAggregate, skippedCount = 0, }) => { const { t } = useObjectTranslation(); @@ -139,7 +147,7 @@ export const BulkActionDialog: React.FC = ({ // confirm step — the params step needs no preloaded option list because the // picker widgets fetch their own candidates (#3064). const [lookupLabels, setLookupLabels] = useState>>({}); - const { run, undo, retry, progress, result, reset } = useBulkExecutor({ resource, dataSource, objectFields, runAction }); + const { run, undo, retry, progress, result, reset } = useBulkExecutor({ resource, dataSource, objectFields, runAction, runAggregate }); const [retrying, setRetrying] = useState(null); const [undoing, setUndoing] = useState(false); const [undoneAt, setUndoneAt] = useState(null); @@ -466,8 +474,14 @@ export const BulkActionDialog: React.FC = ({ {/* A plain `custom` callout has nothing to re-run, but a PROMOTED def (#3002) re-dispatches its object - action for that one record — same as update/delete. */} - {(def.operation !== 'custom' || !!def.actionDef) && ( + action for that one record — same as update/delete. + An aggregate def (#3139) has no per-row slice to + re-attempt: the whole-run re-run is the retry (a + total failure keeps the selection for exactly that), + so the button is hidden — the executor's retry() + refuses it anyway. */} + {(def.operation !== 'custom' || !!def.actionDef) + && def.execution !== 'aggregate' && (