From 9be9d444d567daf8c6dcddeef1ff9e0a4ad9dc26 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 08:17:01 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(spec,showcase):=20aggregate=20bulk=20d?= =?UTF-8?q?ispatch=20=E2=80=94=20allow=20=5FselectedIds=20through=20the=20?= =?UTF-8?q?param=20gate,=20document=20execution:=20'aggregate'=20(objectui?= =?UTF-8?q?#3139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui 17.1 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. The capability lives on the VIEW def (per the spec-17 action.bulkEnabled tombstone); 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 - liveness view.json: bulkActionDefs evidence now cites the dispatch path (useBulkExecutor aggregate branch), pinned to objectui@636ac2f - showcase: showcase_recalc_selection specimen — the recalc endpoint gains a _selectedIds batch branch, task.view's bulk_actions declares the aggregate def next to the per-record fixtures, tour doc updated - content/docs/references regenerated (check:generated green) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01S9aiswZBzoVYsyLKRuGByE --- .../aggregate-bulk-dispatch-selected-ids.md | 26 ++++++++++++++++ content/docs/references/ui/view.mdx | 4 +-- .../app-showcase/src/docs/showcase_tour_ui.md | 14 ++++++--- .../src/system/server/recalc-endpoint.ts | 31 +++++++++++++++++++ examples/app-showcase/src/ui/actions/index.ts | 26 ++++++++++++++++ .../app-showcase/src/ui/views/task.view.ts | 21 ++++++++++--- packages/spec/liveness/view.json | 2 +- packages/spec/src/ui/action-params.test.ts | 13 ++++++++ packages/spec/src/ui/action-params.zod.ts | 9 +++++- packages/spec/src/ui/view.zod.ts | 12 ++++++- 10 files changed, 143 insertions(+), 15 deletions(-) create mode 100644 .changeset/aggregate-bulk-dispatch-selected-ids.md 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/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/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index 8d16dcb6fb..fb5c4d5f74 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -117,6 +117,31 @@ 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. + * + * No `locations`: like a bulk action named in `bulkActions`, an aggregate + * def needs a selection — the view naming it is the whole declaration. + */ +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.', + refreshAfter: true, +}); + /** form — open a parameter form dialog. */ export const LogTimeAction = defineAction({ name: 'showcase_log_time', @@ -335,6 +360,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..d6f1dd6186 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@636ac2f." }, "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'), From de5e9d56b93110ca92d9117725ecf2c6f81c808f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 08:28:00 +0000 Subject: [PATCH 2/5] docs(ui): document bulkActionDefs and the aggregate single-call mode (objectui#3139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bulkActionDefs` was absent from the List View properties table entirely — the Zod `.describe()` was its only author-facing documentation, so the aggregate mode had nowhere an author would look. Adds the table row plus a "Bulk Actions Over a Selection" section contrasting the two keys and both execution modes, and states the aggregate contract authors need: read `params._selectedIds` (not `recordId`), all-or-nothing results, `batchSize` N/A, `maxRecords` for expensive work, and the `${ctx.selection.ids}` toolbar interpolation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01S9aiswZBzoVYsyLKRuGByE --- content/docs/ui/views.mdx | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) 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`): From 3045ecdc6b4bace3c66245b3abc27ebaf30618eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 08:35:53 +0000 Subject: [PATCH 3/5] chore(spec): pin the bulkActionDefs liveness evidence to the merged objectui commit objectui#3141 squash-merged as 4bf612c; the branch sha the note carried is not in objectui's main history. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01S9aiswZBzoVYsyLKRuGByE --- packages/spec/liveness/view.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index d6f1dd6186..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). 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@636ac2f." + "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", From 03659ec9a0c1ae18504e3ee07822314d2d95acae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 08:44:45 +0000 Subject: [PATCH 4/5] fix(showcase): keep the aggregate specimen off the list toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser-verifying the aggregate specimen surfaced a dead affordance: 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 carries no `_selectedIds` and the endpoint rejects it (HTTP 400). Declaring `record_more` + `recordIdParam` puts the single-record entry where the endpoint's per-record branch handles it and keeps it off the toolbar. The renderer's lenient rule is filed as objectui#3142 — it contradicts DeclaredActionsBar's strict one, and an aggregate-only action is the shape that makes the difference visible. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01S9aiswZBzoVYsyLKRuGByE --- examples/app-showcase/src/ui/actions/index.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index fb5c4d5f74..622e58dd72 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -128,8 +128,14 @@ export const RecalcEstimateAction = defineAction({ * shape, minus the zip). Contrast with RecalcEstimateAction above: same * endpoint, one POST per record. * - * No `locations`: like a bulk action named in `bulkActions`, an aggregate - * def needs a selection — the view naming it is the whole declaration. + * `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', @@ -139,6 +145,8 @@ export const RecalcSelectionAction = defineAction({ type: 'api', target: '/api/v1/showcase/recalc', successMessage: 'Estimates recalculated for the whole selection.', + locations: ['record_more'], + recordIdParam: 'recordId', refreshAfter: true, }); From d62fa83ceae162e6662d230578ce3423b5c19361 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 08:58:40 +0000 Subject: [PATCH 5/5] i18n(showcase): translate the recalc actions for zh-CN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aggregate specimen declared a label + successMessage with no zh-CN translation, which the coverage ratchet caught (456 → 460). Translating both recalc surfaces — the per-record one was already untranslated — brings the showcase to 452; baseline updated per the ratchet-down prompt. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01S9aiswZBzoVYsyLKRuGByE --- .../app-showcase/src/system/translations/index.ts | 12 ++++++++++++ scripts/i18n-coverage-baseline.json | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) 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/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,