diff --git a/.changeset/aggregate-bulk-dispatch-selected-ids.md b/.changeset/aggregate-bulk-dispatch-selected-ids.md new file mode 100644 index 0000000000..93ab939ab2 --- /dev/null +++ b/.changeset/aggregate-bulk-dispatch-selected-ids.md @@ -0,0 +1,26 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): allow the aggregate bulk dispatch key `_selectedIds` through the action param gate (objectui#3139) + +A list view's `bulkActionDefs` entry can now opt into an aggregate single-call +dispatch (`execution: 'aggregate'`, objectui 17.1): the renderer invokes the +named object action ONCE for the whole selection, injecting every selected +record id as `params._selectedIds: string[]`, so a single call can produce one +aggregate artifact (zip of QR codes, merged PDF, batch print job). + +`ACTION_PARAM_BUILTIN_KEYS` gains `'_selectedIds'` so the ADR-0104 strict +param gate does not 400 an aggregate dispatch against an action that declares +params — like `recordId`/`objectName`, the key is dispatcher-injected and can +never be authored as a declared param. Pure widening: actions declaring no +params were never validated, and no authored bag legitimately carried this +key. The `bulkActionDefs` describe now documents the aggregate contract +(server reads `params._selectedIds`, results are all-or-nothing, `batchSize` +does not apply, set `maxRecords` for expensive aggregates, and toolbar +url/api actions can interpolate `${ctx.selection.ids}`). + +The showcase's Task → Bulk Actions view carries the specimen: +`showcase_recalc_selection` dispatches the recalc endpoint once for the whole +selection via the endpoint's new `_selectedIds` batch branch, next to the +per-record fan-out fixtures. diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index fb3ad05547..bf72324339 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -399,7 +399,7 @@ List chart view configuration | **fieldOrder** | `string[]` | optional | Explicit field display order for this view | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | -| **bulkActionDefs** | `Record[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog) | +| **bulkActionDefs** | `Record[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). A `custom` def dispatches once per selected record by default; set `execution: 'aggregate'` (objectui#3139) to dispatch the named object action ONCE for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply in aggregate mode; set `maxRecords` on defs whose server work is expensive. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | | **virtualScroll** | `boolean` | optional | Enable virtual scrolling for large datasets | | **conditionalFormatting** | `{ condition: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; style: Record }[]` | optional | Conditional formatting rules for list rows | | **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view | @@ -487,7 +487,7 @@ List chart view configuration | **fieldOrder** | `string[]` | optional | Explicit field display order for this view | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | -| **bulkActionDefs** | `Record[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog) | +| **bulkActionDefs** | `Record[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). A `custom` def dispatches once per selected record by default; set `execution: 'aggregate'` (objectui#3139) to dispatch the named object action ONCE for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply in aggregate mode; set `maxRecords` on defs whose server work is expensive. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | | **virtualScroll** | `boolean` | optional | Enable virtual scrolling for large datasets | | **conditionalFormatting** | `{ condition: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; style: Record }[]` | optional | Conditional formatting rules for list rows | | **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view | diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index ee837f9647..5b591ad0a0 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -110,6 +110,7 @@ A List View controls how a collection of records is presented. It supports multi | `navigation` | `object` | optional | Row click navigation | | `rowActions` | `array` | optional | Per-row action buttons, by action name — see [Actions](/docs/ui/actions) | | `bulkActions` | `array` | optional | Bulk selection actions, by action name — see [Actions](/docs/ui/actions) | +| `bulkActionDefs` | `array` | optional | Rich bulk action definitions — mass edits, and the aggregate single-call mode (below) | | `inlineEdit` | `boolean` | optional | Enable inline editing | | `exportOptions` | `string[]` | optional | Enabled export formats (`csv`, `xlsx`, `pdf`, `json`) | @@ -162,6 +163,44 @@ columns: [ | `align` | `'left' \| 'center' \| 'right'` | Text alignment | | `link` | `boolean` | Cell functions as the primary navigation link | +### Bulk Actions Over a Selection + +Two keys drive the multi-select toolbar. `bulkActions` names actions the object +already declares, and each selected record is dispatched **once** — the action +runs N times for N rows. `bulkActionDefs` carries richer entries: a mass edit +through the data API (`operation: 'update'` + a patch), or the **aggregate** +mode, where the action is called **once for the whole selection**. + +```typescript +bulkActions: ['mark_done'], // one dispatch per selected record +bulkActionDefs: [ + // Mass edit — one bulk write, no action involved. + { name: 'archive', operation: 'update', patch: { archived: true } }, + // Aggregate — ONE call to the declared `export_zip` action, carrying every + // selected id. This is the "N devices → one zip download" shape; also batch + // print, merged-PDF export. + { name: 'export_zip', operation: 'custom', execution: 'aggregate' }, +] +``` + +An aggregate dispatch delivers the selection to the handler as +`params._selectedIds: string[]` — read that, **not** `recordId`. Results are +all-or-nothing: a handler that cannot cover the whole selection must reject, +and per-row retry is replaced by re-running the action. `batchSize` does not +apply (the call is never chunked); set `maxRecords` when the server work is +expensive. `execution` defaults to `'perRecord'`, so existing views are +unchanged, and the bare-string `bulkActions` form always dispatches per record +— aggregate requires the def form, which is where the flag lives. + +A url or api action rendered on the list **toolbar** can also read the current +selection through target interpolation — `${ctx.selection.ids}` (comma-joined) +and `${ctx.selection.count}` — without any bulk wiring. + + + `action.bulkEnabled` was retired in spec 17. The multi-select toolbar is + driven by these two view keys only. + + ### Data Source Views can load data from several sources (`object`, `api`, `value`, and `schema`): diff --git a/examples/app-showcase/src/docs/showcase_tour_ui.md b/examples/app-showcase/src/docs/showcase_tour_ui.md index 64bd411a54..38012ee3ab 100644 --- a/examples/app-showcase/src/docs/showcase_tour_ui.md +++ b/examples/app-showcase/src/docs/showcase_tour_ui.md @@ -58,11 +58,15 @@ canonical example of each linked from that page. - `src/ui/actions/` — the ActionType × location matrix (script / url / modal / flow / api / form), visible as buttons across Task screens. - Actions over a SELECTION come in two flavours, one view each: Task's - **Bulk Actions** names declared actions in `bulkActions`, and each selected - record is fanned out through the action runner (a script and a custom - endpoint, neither of them a field patch); Project's `bulkActionDefs` instead - mass-EDITS through the data API. `action.bulkEnabled` is not a third way — - it was retired in spec 17 and its tombstone points at `bulkActions`. + **Bulk Actions** runs declared actions through the action runner — named in + `bulkActions` for the default per-record fan-out (a script and a custom + endpoint, neither of them a field patch), or declared as a + `bulkActionDefs` entry with `execution: 'aggregate'` for ONE dispatch + carrying every selected id as `params._selectedIds` (Recalculate + Selection, objectui#3139 — the single-zip / merged-PDF shape); Project's + `bulkActionDefs` instead mass-EDITS through the data API. + `action.bulkEnabled` is not a third way — it was retired in spec 17 and + its tombstone points at `bulkActions`. ## Themes diff --git a/examples/app-showcase/src/system/server/recalc-endpoint.ts b/examples/app-showcase/src/system/server/recalc-endpoint.ts index e5a8612ada..23ea0d20ee 100644 --- a/examples/app-showcase/src/system/server/recalc-endpoint.ts +++ b/examples/app-showcase/src/system/server/recalc-endpoint.ts @@ -18,6 +18,13 @@ * string-target api action, so the body carries `id` + the record fields. * We recompute `estimate_hours` from the schedule window (working at 8h/day) * and persist it; `refreshAfter: true` on the action repaints the new value. + * + * Aggregate branch (objectui#3139): an `execution: 'aggregate'` bulk dispatch + * (see `showcase_recalc_selection` + task.view's `bulk_actions`) POSTs ONE + * request whose body carries `_selectedIds: string[]` instead of a single id. + * The handler recomputes every id in that one call and reports the count — + * all-or-nothing, per the aggregate contract: a failure rejects the whole + * request rather than reporting partial success. */ interface RecalcHostContext { @@ -58,6 +65,30 @@ export function registerRecalcEndpoint(ctx: RecalcHostContext): void { }; try { const body = ((req as { body?: Record })?.body) ?? {}; + // Aggregate bulk dispatch (objectui#3139): one request, every selected + // id in `_selectedIds`. The single-record shape has the record fields + // in the body; here only ids arrive, so recompute from a flat 8h + // baseline per task — the point of the specimen is the ONE-call shape, + // not the estimation model. + const selectedIds = Array.isArray(body._selectedIds) + ? (body._selectedIds as unknown[]).map(String).filter(Boolean) + : undefined; + if (selectedIds) { + if (selectedIds.length === 0) { + r.status(400); + r.json({ success: false, error: '_selectedIds is empty' }); + return; + } + for (const sid of selectedIds) { + await ctx.ql.update( + 'showcase_task', + { id: sid, estimate_hours: 8 }, + { where: { id: sid } }, + ); + } + r.json({ success: true, data: { recalculated: selectedIds.length } }); + return; + } const id = (body.id ?? body.recordId) as string | undefined; if (!id) { r.status(400); diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index b27965f17c..9e833e7cd7 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -233,6 +233,18 @@ export const ShowcaseTranslationBundle = { legacy_row_actions: { label: '旧式行操作' }, bulk_actions: { label: '批量操作' }, }, + _actions: { + // The two recalc surfaces of the same endpoint: one dispatch per + // record vs ONE dispatch for the whole selection (objectui#3139). + showcase_recalc_estimate: { + label: '重算工时', + successMessage: '工时已重算。', + }, + showcase_recalc_selection: { + label: '重算所选', + successMessage: '已为整个选中集重算工时。', + }, + }, }, showcase_account: { label: '客户', diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index 8d16dcb6fb..622e58dd72 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -117,6 +117,39 @@ export const RecalcEstimateAction = defineAction({ refreshAfter: true, }); +/** + * api, AGGREGATE-dispatched — the `execution: 'aggregate'` specimen + * (objectui#3139). The action itself is an ordinary api action; what makes it + * aggregate is the VIEW's `bulkActionDefs` entry naming it with + * `execution: 'aggregate'` (see `task.view.ts` → `bulk_actions`). The + * renderer then dispatches it ONCE for the whole selection, with every + * selected id in `params._selectedIds` — the recalc endpoint's batch branch + * recomputes all of them in that single call (the "one zip for N devices" + * shape, minus the zip). Contrast with RecalcEstimateAction above: same + * endpoint, one POST per record. + * + * `locations` still has to be declared, even though the selection bar entry + * comes from the view. Omitting it does NOT mean "nowhere": the action:bar + * renderer treats a missing/empty `locations` as "every location" + * (objectui `action-bar.tsx`), so a locations-less action also lands on the + * LIST TOOLBAR — where there is no selection, so the dispatch posts no + * `_selectedIds` and the endpoint rejects it. Declaring `record_more` keeps + * the single-record entry somewhere it works (the endpoint's per-record + * branch, via `recordIdParam`) and off the toolbar. See objectui#3142. + */ +export const RecalcSelectionAction = defineAction({ + name: 'showcase_recalc_selection', + label: 'Recalculate Selection', + icon: 'calculator', + objectName: task, + type: 'api', + target: '/api/v1/showcase/recalc', + successMessage: 'Estimates recalculated for the whole selection.', + locations: ['record_more'], + recordIdParam: 'recordId', + refreshAfter: true, +}); + /** form — open a parameter form dialog. */ export const LogTimeAction = defineAction({ name: 'showcase_log_time', @@ -335,6 +368,7 @@ export const allActions = [ BulkReassignAction, QuickViewAction, RecalcEstimateAction, + RecalcSelectionAction, LogTimeAction, NewTaskAction, SubmitForSignoffAction, diff --git a/examples/app-showcase/src/ui/views/task.view.ts b/examples/app-showcase/src/ui/views/task.view.ts index 66b813db30..934f37c467 100644 --- a/examples/app-showcase/src/ui/views/task.view.ts +++ b/examples/app-showcase/src/ui/views/task.view.ts @@ -116,18 +116,26 @@ export const TaskViews = defineView({ // // Complements `project.view.ts`'s `bulkActionDefs`, which is the OTHER // bulk vocabulary: inline defs that mass-EDIT records through the data - // API (`operation: 'update'` + a patch). Here the selected records are - // instead fanned out through the action runner, one dispatch each, so an - // action that is not a field patch at all — a script, a custom endpoint — - // works over a selection. Both names are already declared on the object: + // API (`operation: 'update'` + a patch). Here the selected records go + // through the action runner instead, so an action that is not a field + // patch at all — a script, a custom endpoint — works over a selection. + // Runner-dispatched bulk actions come in TWO execution modes: // + // Per-record (default) — each selected record is one dispatch: // `showcase_mark_done` — type `script`; its sandboxed body flips // `done`/`progress` per record via the platform action route. // `showcase_recalc_estimate` — type `api`; POSTs the showcase's own // `/api/v1/showcase/recalc`, with `recordIdParam: 'recordId'` (already // declared for the row surface) carrying each record's id. // - // Neither declares `list_toolbar`: a bulk action is not a toolbar action, + // Aggregate (objectui#3139) — the whole selection in ONE dispatch: + // `showcase_recalc_selection` — the `bulkActionDefs` entry below opts in + // with `execution: 'aggregate'`, so the renderer POSTs the SAME recalc + // endpoint once, carrying every selected id in `params._selectedIds` + // (the endpoint's batch branch). This is the "one zip for N devices" + // dispatch shape; results are all-or-nothing, no per-row retry. + // + // None declares `list_toolbar`: a bulk action is not a toolbar action, // it needs a selection. Naming it here is the whole declaration. bulk_actions: { label: 'Bulk Actions', @@ -141,6 +149,9 @@ export const TaskViews = defineView({ { field: 'done' }, ], bulkActions: ['showcase_mark_done', 'showcase_recalc_estimate'], + bulkActionDefs: [ + { name: 'showcase_recalc_selection', operation: 'custom', execution: 'aggregate' }, + ], }, // 0 ── Tabular ─────────────────────────────────────────────────────── diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 9f32c2b11f..9fe7f3350d 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -135,7 +135,7 @@ }, "bulkActionDefs": { "status": "live", - "note": "objectui: ListView.tsx:1343 forwards rich defs to ObjectGrid (BulkActionDialog). Post-audit key, verified objectui@fb35e48." + "note": "objectui: ListView.tsx:1343 forwards rich defs to ObjectGrid (BulkActionDialog). Dispatch: useBulkExecutor.ts run() — per-record fan-out by default; execution:'aggregate' defs go through the ONE-call bulkCall branch injecting params._selectedIds (ObjectGrid.runBulkActionAggregate, objectui#3139). Verified objectui@4bf612c." }, "virtualScroll": { "status": "live", diff --git a/packages/spec/src/ui/action-params.test.ts b/packages/spec/src/ui/action-params.test.ts index 1fd6b25477..f707d2c69f 100644 --- a/packages/spec/src/ui/action-params.test.ts +++ b/packages/spec/src/ui/action-params.test.ts @@ -65,6 +65,19 @@ describe('validateActionParams (ADR-0104 D2)', () => { expect(ACTION_PARAM_BUILTIN_KEYS).toContain('objectName'); }); + it('allows the aggregate-dispatch key _selectedIds on a param-declaring action (objectui#3139)', () => { + // The renderer's aggregate bulk dispatch injects `_selectedIds` next to + // the user-collected params; strict mode must not 400 the whole call for + // a key the author can never declare. + const resolved: ResolvedActionParam[] = [{ name: 'format', type: 'text' }]; + const issues = validateActionParams(resolved, { + format: 'png', + _selectedIds: ['dev_1', 'dev_2'], + }); + expect(issues).toEqual([]); + expect(ACTION_PARAM_BUILTIN_KEYS).toContain('_selectedIds'); + }); + it('leaves the value shape OPEN when the resolved type is unknown (field-backed param whose field is gone)', () => { const resolved: ResolvedActionParam[] = [{ name: 'freeform' /* no type */ }]; expect(validateActionParams(resolved, { freeform: { anything: [1, 2, 3] } })).toEqual([]); diff --git a/packages/spec/src/ui/action-params.zod.ts b/packages/spec/src/ui/action-params.zod.ts index 0ad52c079e..3485a01a99 100644 --- a/packages/spec/src/ui/action-params.zod.ts +++ b/packages/spec/src/ui/action-params.zod.ts @@ -59,8 +59,15 @@ export interface ActionParamIssue { /** * Keys the dispatcher merges into the params bag itself (never authored by the * caller) — always permitted, never flagged as unknown. + * + * `_selectedIds` is injected by the renderer's aggregate bulk dispatch + * (objectui#3139): a `bulkActionDefs` entry with `execution: 'aggregate'` + * calls the action ONCE for the whole selection, carrying every selected + * record id in this key so the handler can produce a single aggregate + * artifact (zip of QR codes, merged PDF…). Server handlers read it from + * `ctx.params._selectedIds`; it is never authored as a declared param. */ -export const ACTION_PARAM_BUILTIN_KEYS: readonly string[] = ['recordId', 'objectName']; +export const ACTION_PARAM_BUILTIN_KEYS: readonly string[] = ['recordId', 'objectName', '_selectedIds']; function isPresent(v: unknown): boolean { return v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === ''); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index b4a5efa348..d891b9a159 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -737,7 +737,17 @@ export const ListViewSchema = lazySchema(() => z.object({ /** Row & Bulk Actions */ rowActions: z.array(z.string()).optional().describe('Actions available for individual row items'), bulkActions: z.array(z.string()).optional().describe('Actions available when multiple rows are selected'), - bulkActionDefs: z.array(z.record(z.string(), z.any())).optional().describe('Rich bulk action definitions (schema-driven, executed via BulkActionDialog)'), + bulkActionDefs: z.array(z.record(z.string(), z.any())).optional().describe( + 'Rich bulk action definitions (schema-driven, executed via BulkActionDialog). ' + + "A `custom` def dispatches once per selected record by default; set `execution: 'aggregate'` " + + '(objectui#3139) to dispatch the named object action ONCE for the whole selection — the renderer ' + + 'injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call ' + + 'can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are ' + + 'all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is ' + + 'replaced by re-running the action. `batchSize` does not apply in aggregate mode; set `maxRecords` on ' + + 'defs whose server work is expensive. Toolbar url/api actions can also interpolate the current ' + + 'selection via `${ctx.selection.ids}` / `${ctx.selection.count}`.', + ), /** Performance */ virtualScroll: z.boolean().optional().describe('Enable virtual scrolling for large datasets'), diff --git a/scripts/i18n-coverage-baseline.json b/scripts/i18n-coverage-baseline.json index 4a6ee9e498..6f0cfbaa2c 100644 --- a/scripts/i18n-coverage-baseline.json +++ b/scripts/i18n-coverage-baseline.json @@ -1,6 +1,6 @@ { "examples/app-crm/objectstack.config.ts": 89, - "examples/app-showcase/objectstack.config.ts": 456, + "examples/app-showcase/objectstack.config.ts": 452, "examples/app-todo/objectstack.config.ts": 120, "packages/platform-objects/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts": 0,