Skip to content

Commit e6b1b69

Browse files
authored
feat(spec,showcase): aggregate bulk dispatch — allow _selectedIds through the param gate (#4461)
objectui 17.1 (objectui#3141) adds an aggregate single-call mode for bulkActionDefs: an execution: 'aggregate' entry dispatches its named object action ONCE for the whole selection, with every selected id injected as params._selectedIds. This is the server-side contract half. - ACTION_PARAM_BUILTIN_KEYS += '_selectedIds' so the ADR-0104 strict gate does not 400 an aggregate dispatch against a param-declaring action — dispatcher-injected, never authorable, pure widening (+ pin test) - bulkActionDefs describe documents the aggregate contract: read params._selectedIds (not recordId), all-or-nothing results, batchSize N/A, maxRecords advised, ${ctx.selection.ids} toolbar interpolation - content/docs/ui/views.mdx documents bulkActionDefs for authors for the first time; references regenerated - liveness view.json evidence now cites the dispatch path - showcase: showcase_recalc_selection specimen next to the per-record fixtures; recalc endpoint gains a _selectedIds batch branch; zh-CN translations for both recalc surfaces
1 parent cdf4d9a commit e6b1b69

13 files changed

Lines changed: 203 additions & 16 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): allow the aggregate bulk dispatch key `_selectedIds` through the action param gate (objectui#3139)
6+
7+
A list view's `bulkActionDefs` entry can now opt into an aggregate single-call
8+
dispatch (`execution: 'aggregate'`, objectui 17.1): the renderer invokes the
9+
named object action ONCE for the whole selection, injecting every selected
10+
record id as `params._selectedIds: string[]`, so a single call can produce one
11+
aggregate artifact (zip of QR codes, merged PDF, batch print job).
12+
13+
`ACTION_PARAM_BUILTIN_KEYS` gains `'_selectedIds'` so the ADR-0104 strict
14+
param gate does not 400 an aggregate dispatch against an action that declares
15+
params — like `recordId`/`objectName`, the key is dispatcher-injected and can
16+
never be authored as a declared param. Pure widening: actions declaring no
17+
params were never validated, and no authored bag legitimately carried this
18+
key. The `bulkActionDefs` describe now documents the aggregate contract
19+
(server reads `params._selectedIds`, results are all-or-nothing, `batchSize`
20+
does not apply, set `maxRecords` for expensive aggregates, and toolbar
21+
url/api actions can interpolate `${ctx.selection.ids}`).
22+
23+
The showcase's Task → Bulk Actions view carries the specimen:
24+
`showcase_recalc_selection` dispatches the recalc endpoint once for the whole
25+
selection via the endpoint's new `_selectedIds` batch branch, next to the
26+
per-record fan-out fixtures.

content/docs/references/ui/view.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ List chart view configuration
399399
| **fieldOrder** | `string[]` | optional | Explicit field display order for this view |
400400
| **rowActions** | `string[]` | optional | Actions available for individual row items |
401401
| **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected |
402-
| **bulkActionDefs** | `Record<string, any>[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog) |
402+
| **bulkActionDefs** | `Record<string, any>[]` | 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}`. |
403403
| **virtualScroll** | `boolean` | optional | Enable virtual scrolling for large datasets |
404404
| **conditionalFormatting** | `{ condition: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; style: Record<string, string> }[]` | optional | Conditional formatting rules for list rows |
405405
| **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view |
@@ -487,7 +487,7 @@ List chart view configuration
487487
| **fieldOrder** | `string[]` | optional | Explicit field display order for this view |
488488
| **rowActions** | `string[]` | optional | Actions available for individual row items |
489489
| **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected |
490-
| **bulkActionDefs** | `Record<string, any>[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog) |
490+
| **bulkActionDefs** | `Record<string, any>[]` | 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}`. |
491491
| **virtualScroll** | `boolean` | optional | Enable virtual scrolling for large datasets |
492492
| **conditionalFormatting** | `{ condition: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; style: Record<string, string> }[]` | optional | Conditional formatting rules for list rows |
493493
| **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view |

content/docs/ui/views.mdx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ A List View controls how a collection of records is presented. It supports multi
110110
| `navigation` | `object` | optional | Row click navigation |
111111
| `rowActions` | `array` | optional | Per-row action buttons, by action namesee [Actions](/docs/ui/actions) |
112112
| `bulkActions` | `array` | optional | Bulk selection actions, by action namesee [Actions](/docs/ui/actions) |
113+
| `bulkActionDefs` | `array` | optional | Rich bulk action definitionsmass edits, and the aggregate single-call mode (below) |
113114
| `inlineEdit` | `boolean` | optional | Enable inline editing |
114115
| `exportOptions` | `string[]` | optional | Enabled export formats (`csv`, `xlsx`, `pdf`, `json`) |
115116

@@ -162,6 +163,44 @@ columns: [
162163
| `align` | `'left' \| 'center' \| 'right'` | Text alignment |
163164
| `link` | `boolean` | Cell functions as the primary navigation link |
164165

166+
### Bulk Actions Over a Selection
167+
168+
Two keys drive the multi-select toolbar. `bulkActions` names actions the object
169+
already declares, and each selected record is dispatched **once**the action
170+
runs N times for N rows. `bulkActionDefs` carries richer entries: a mass edit
171+
through the data API (`operation: 'update'` + a patch), or the **aggregate**
172+
mode, where the action is called **once for the whole selection**.
173+
174+
```typescript
175+
bulkActions: ['mark_done'], // one dispatch per selected record
176+
bulkActionDefs: [
177+
// Mass edit — one bulk write, no action involved.
178+
{ name: 'archive', operation: 'update', patch: { archived: true } },
179+
// Aggregate — ONE call to the declared `export_zip` action, carrying every
180+
// selected id. This is the "N devices → one zip download" shape; also batch
181+
// print, merged-PDF export.
182+
{ name: 'export_zip', operation: 'custom', execution: 'aggregate' },
183+
]
184+
```
185+
186+
An aggregate dispatch delivers the selection to the handler as
187+
`params._selectedIds: string[]`read that, **not** `recordId`. Results are
188+
all-or-nothing: a handler that cannot cover the whole selection must reject,
189+
and per-row retry is replaced by re-running the action. `batchSize` does not
190+
apply (the call is never chunked); set `maxRecords` when the server work is
191+
expensive. `execution` defaults to `'perRecord'`, so existing views are
192+
unchanged, and the bare-string `bulkActions` form always dispatches per record
193+
aggregate requires the def form, which is where the flag lives.
194+
195+
A url or api action rendered on the list **toolbar** can also read the current
196+
selection through target interpolation`${ctx.selection.ids}` (comma-joined)
197+
and `${ctx.selection.count}`without any bulk wiring.
198+
199+
<Callout type="warn">
200+
`action.bulkEnabled` was retired in spec 17. The multi-select toolbar is
201+
driven by these two view keys only.
202+
</Callout>
203+
165204
### Data Source
166205

167206
Views can load data from several sources (`object`, `api`, `value`, and `schema`):

examples/app-showcase/src/docs/showcase_tour_ui.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,15 @@ canonical example of each linked from that page.
5858
- `src/ui/actions/` — the ActionType × location matrix (script / url /
5959
modal / flow / api / form), visible as buttons across Task screens.
6060
- Actions over a SELECTION come in two flavours, one view each: Task's
61-
**Bulk Actions** names declared actions in `bulkActions`, and each selected
62-
record is fanned out through the action runner (a script and a custom
63-
endpoint, neither of them a field patch); Project's `bulkActionDefs` instead
64-
mass-EDITS through the data API. `action.bulkEnabled` is not a third way —
65-
it was retired in spec 17 and its tombstone points at `bulkActions`.
61+
**Bulk Actions** runs declared actions through the action runner — named in
62+
`bulkActions` for the default per-record fan-out (a script and a custom
63+
endpoint, neither of them a field patch), or declared as a
64+
`bulkActionDefs` entry with `execution: 'aggregate'` for ONE dispatch
65+
carrying every selected id as `params._selectedIds` (Recalculate
66+
Selection, objectui#3139 — the single-zip / merged-PDF shape); Project's
67+
`bulkActionDefs` instead mass-EDITS through the data API.
68+
`action.bulkEnabled` is not a third way — it was retired in spec 17 and
69+
its tombstone points at `bulkActions`.
6670

6771
## Themes
6872

examples/app-showcase/src/system/server/recalc-endpoint.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@
1818
* string-target api action, so the body carries `id` + the record fields.
1919
* We recompute `estimate_hours` from the schedule window (working at 8h/day)
2020
* and persist it; `refreshAfter: true` on the action repaints the new value.
21+
*
22+
* Aggregate branch (objectui#3139): an `execution: 'aggregate'` bulk dispatch
23+
* (see `showcase_recalc_selection` + task.view's `bulk_actions`) POSTs ONE
24+
* request whose body carries `_selectedIds: string[]` instead of a single id.
25+
* The handler recomputes every id in that one call and reports the count —
26+
* all-or-nothing, per the aggregate contract: a failure rejects the whole
27+
* request rather than reporting partial success.
2128
*/
2229

2330
interface RecalcHostContext {
@@ -58,6 +65,30 @@ export function registerRecalcEndpoint(ctx: RecalcHostContext): void {
5865
};
5966
try {
6067
const body = ((req as { body?: Record<string, unknown> })?.body) ?? {};
68+
// Aggregate bulk dispatch (objectui#3139): one request, every selected
69+
// id in `_selectedIds`. The single-record shape has the record fields
70+
// in the body; here only ids arrive, so recompute from a flat 8h
71+
// baseline per task — the point of the specimen is the ONE-call shape,
72+
// not the estimation model.
73+
const selectedIds = Array.isArray(body._selectedIds)
74+
? (body._selectedIds as unknown[]).map(String).filter(Boolean)
75+
: undefined;
76+
if (selectedIds) {
77+
if (selectedIds.length === 0) {
78+
r.status(400);
79+
r.json({ success: false, error: '_selectedIds is empty' });
80+
return;
81+
}
82+
for (const sid of selectedIds) {
83+
await ctx.ql.update(
84+
'showcase_task',
85+
{ id: sid, estimate_hours: 8 },
86+
{ where: { id: sid } },
87+
);
88+
}
89+
r.json({ success: true, data: { recalculated: selectedIds.length } });
90+
return;
91+
}
6192
const id = (body.id ?? body.recordId) as string | undefined;
6293
if (!id) {
6394
r.status(400);

examples/app-showcase/src/system/translations/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,18 @@ export const ShowcaseTranslationBundle = {
233233
legacy_row_actions: { label: '旧式行操作' },
234234
bulk_actions: { label: '批量操作' },
235235
},
236+
_actions: {
237+
// The two recalc surfaces of the same endpoint: one dispatch per
238+
// record vs ONE dispatch for the whole selection (objectui#3139).
239+
showcase_recalc_estimate: {
240+
label: '重算工时',
241+
successMessage: '工时已重算。',
242+
},
243+
showcase_recalc_selection: {
244+
label: '重算所选',
245+
successMessage: '已为整个选中集重算工时。',
246+
},
247+
},
236248
},
237249
showcase_account: {
238250
label: '客户',

examples/app-showcase/src/ui/actions/index.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,39 @@ export const RecalcEstimateAction = defineAction({
117117
refreshAfter: true,
118118
});
119119

120+
/**
121+
* api, AGGREGATE-dispatched — the `execution: 'aggregate'` specimen
122+
* (objectui#3139). The action itself is an ordinary api action; what makes it
123+
* aggregate is the VIEW's `bulkActionDefs` entry naming it with
124+
* `execution: 'aggregate'` (see `task.view.ts` → `bulk_actions`). The
125+
* renderer then dispatches it ONCE for the whole selection, with every
126+
* selected id in `params._selectedIds` — the recalc endpoint's batch branch
127+
* recomputes all of them in that single call (the "one zip for N devices"
128+
* shape, minus the zip). Contrast with RecalcEstimateAction above: same
129+
* endpoint, one POST per record.
130+
*
131+
* `locations` still has to be declared, even though the selection bar entry
132+
* comes from the view. Omitting it does NOT mean "nowhere": the action:bar
133+
* renderer treats a missing/empty `locations` as "every location"
134+
* (objectui `action-bar.tsx`), so a locations-less action also lands on the
135+
* LIST TOOLBAR — where there is no selection, so the dispatch posts no
136+
* `_selectedIds` and the endpoint rejects it. Declaring `record_more` keeps
137+
* the single-record entry somewhere it works (the endpoint's per-record
138+
* branch, via `recordIdParam`) and off the toolbar. See objectui#3142.
139+
*/
140+
export const RecalcSelectionAction = defineAction({
141+
name: 'showcase_recalc_selection',
142+
label: 'Recalculate Selection',
143+
icon: 'calculator',
144+
objectName: task,
145+
type: 'api',
146+
target: '/api/v1/showcase/recalc',
147+
successMessage: 'Estimates recalculated for the whole selection.',
148+
locations: ['record_more'],
149+
recordIdParam: 'recordId',
150+
refreshAfter: true,
151+
});
152+
120153
/** form — open a parameter form dialog. */
121154
export const LogTimeAction = defineAction({
122155
name: 'showcase_log_time',
@@ -335,6 +368,7 @@ export const allActions = [
335368
BulkReassignAction,
336369
QuickViewAction,
337370
RecalcEstimateAction,
371+
RecalcSelectionAction,
338372
LogTimeAction,
339373
NewTaskAction,
340374
SubmitForSignoffAction,

examples/app-showcase/src/ui/views/task.view.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,26 @@ export const TaskViews = defineView({
116116
//
117117
// Complements `project.view.ts`'s `bulkActionDefs`, which is the OTHER
118118
// bulk vocabulary: inline defs that mass-EDIT records through the data
119-
// API (`operation: 'update'` + a patch). Here the selected records are
120-
// instead fanned out through the action runner, one dispatch each, so an
121-
// action that is not a field patch at all — a script, a custom endpoint —
122-
// works over a selection. Both names are already declared on the object:
119+
// API (`operation: 'update'` + a patch). Here the selected records go
120+
// through the action runner instead, so an action that is not a field
121+
// patch at all — a script, a custom endpoint — works over a selection.
122+
// Runner-dispatched bulk actions come in TWO execution modes:
123123
//
124+
// Per-record (default) — each selected record is one dispatch:
124125
// `showcase_mark_done` — type `script`; its sandboxed body flips
125126
// `done`/`progress` per record via the platform action route.
126127
// `showcase_recalc_estimate` — type `api`; POSTs the showcase's own
127128
// `/api/v1/showcase/recalc`, with `recordIdParam: 'recordId'` (already
128129
// declared for the row surface) carrying each record's id.
129130
//
130-
// Neither declares `list_toolbar`: a bulk action is not a toolbar action,
131+
// Aggregate (objectui#3139) — the whole selection in ONE dispatch:
132+
// `showcase_recalc_selection` — the `bulkActionDefs` entry below opts in
133+
// with `execution: 'aggregate'`, so the renderer POSTs the SAME recalc
134+
// endpoint once, carrying every selected id in `params._selectedIds`
135+
// (the endpoint's batch branch). This is the "one zip for N devices"
136+
// dispatch shape; results are all-or-nothing, no per-row retry.
137+
//
138+
// None declares `list_toolbar`: a bulk action is not a toolbar action,
131139
// it needs a selection. Naming it here is the whole declaration.
132140
bulk_actions: {
133141
label: 'Bulk Actions',
@@ -141,6 +149,9 @@ export const TaskViews = defineView({
141149
{ field: 'done' },
142150
],
143151
bulkActions: ['showcase_mark_done', 'showcase_recalc_estimate'],
152+
bulkActionDefs: [
153+
{ name: 'showcase_recalc_selection', operation: 'custom', execution: 'aggregate' },
154+
],
144155
},
145156

146157
// 0 ── Tabular ───────────────────────────────────────────────────────

packages/spec/liveness/view.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@
135135
},
136136
"bulkActionDefs": {
137137
"status": "live",
138-
"note": "objectui: ListView.tsx:1343 forwards rich defs to ObjectGrid (BulkActionDialog). Post-audit key, verified objectui@fb35e48."
138+
"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."
139139
},
140140
"virtualScroll": {
141141
"status": "live",

packages/spec/src/ui/action-params.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@ describe('validateActionParams (ADR-0104 D2)', () => {
6565
expect(ACTION_PARAM_BUILTIN_KEYS).toContain('objectName');
6666
});
6767

68+
it('allows the aggregate-dispatch key _selectedIds on a param-declaring action (objectui#3139)', () => {
69+
// The renderer's aggregate bulk dispatch injects `_selectedIds` next to
70+
// the user-collected params; strict mode must not 400 the whole call for
71+
// a key the author can never declare.
72+
const resolved: ResolvedActionParam[] = [{ name: 'format', type: 'text' }];
73+
const issues = validateActionParams(resolved, {
74+
format: 'png',
75+
_selectedIds: ['dev_1', 'dev_2'],
76+
});
77+
expect(issues).toEqual([]);
78+
expect(ACTION_PARAM_BUILTIN_KEYS).toContain('_selectedIds');
79+
});
80+
6881
it('leaves the value shape OPEN when the resolved type is unknown (field-backed param whose field is gone)', () => {
6982
const resolved: ResolvedActionParam[] = [{ name: 'freeform' /* no type */ }];
7083
expect(validateActionParams(resolved, { freeform: { anything: [1, 2, 3] } })).toEqual([]);

0 commit comments

Comments
 (0)