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
26 changes: 26 additions & 0 deletions .changeset/aggregate-bulk-dispatch-selected-ids.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog) |
| **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}`. |
| **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<string, string> }[]` | optional | Conditional formatting rules for list rows |
| **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view |
Expand Down Expand Up @@ -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<string, any>[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog) |
| **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}`. |
| **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<string, string> }[]` | optional | Conditional formatting rules for list rows |
| **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view |
Expand Down
39 changes: 39 additions & 0 deletions content/docs/ui/views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |

Expand Down Expand Up @@ -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.

<Callout type="warn">
`action.bulkEnabled` was retired in spec 17. The multi-select toolbar is
driven by these two view keys only.
</Callout>

### Data Source

Views can load data from several sources (`object`, `api`, `value`, and `schema`):
Expand Down
14 changes: 9 additions & 5 deletions examples/app-showcase/src/docs/showcase_tour_ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions examples/app-showcase/src/system/server/recalc-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -58,6 +65,30 @@ export function registerRecalcEndpoint(ctx: RecalcHostContext): void {
};
try {
const body = ((req as { body?: Record<string, unknown> })?.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);
Expand Down
12 changes: 12 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '客户',
Expand Down
34 changes: 34 additions & 0 deletions examples/app-showcase/src/ui/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -335,6 +368,7 @@ export const allActions = [
BulkReassignAction,
QuickViewAction,
RecalcEstimateAction,
RecalcSelectionAction,
LogTimeAction,
NewTaskAction,
SubmitForSignoffAction,
Expand Down
21 changes: 16 additions & 5 deletions examples/app-showcase/src/ui/views/task.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 ───────────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/view.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions packages/spec/src/ui/action-params.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
9 changes: 8 additions & 1 deletion packages/spec/src/ui/action-params.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() === '');
Expand Down
Loading
Loading