Skip to content

Commit 321bd6d

Browse files
committed
feat(spec,lint)!: give bulkActionDefs a shape, and lint the aggregate name it references (#4457)
A selection-bar bulk action was `z.array(z.record(z.string(), z.any()))` — no shape at all. The real contract lived in objectui's `BulkActionDef` interface and in the executor that reads it, so every authoring mistake landed as a silent runtime downgrade: `opeartion` parsed and the executor hit `Unknown operation: undefined` per row; `excution: 'aggregate'` parsed and left the def per-record, so the endpoint written for ONE `_selectedIds` call got N calls instead. `ui/bulk-action.zod.ts` types it with the treatment `ActionParamSchema` got in #3746/#4001: a strict def whose unknown-key error names the offending key and the canonical spelling. It also refuses the combinations the executor never reads (`patch` outside an update, `execution` outside a custom, `params` on a delete, `batchSize` on an aggregate) and a hand-written `actionDef`, which the renderer attaches and which authored by hand would smuggle an action definition past the action registry. One shape that parsed before is now rejected: `operation: 'custom'` without `execution: 'aggregate'`. `resolveBulkActions` attaches a dispatcher for exactly one authored shape; every other custom def falls to `Promise.resolve()` per row — a button that reports success for every selected record and does nothing. The error names both legal forms. `params[]` stays `.passthrough()` (the renderer declares a widget-config catch-all), and the bulk-param/action-param spelling divergence is documented rather than converged — that needs a cross-repo change, and typing them as they are is what makes the divergence visible. Lint: `validate-action-name-refs` now covers `bulkActionDefs`, for the entries that are references rather than button ids (`execution: 'aggregate'`). The walk also reaches an object's own `listViews` for the first time, and the hint no longer tells a bulk-surface author to add a `locations` entry the selection bar does not read. Verified zero new findings against app-showcase / app-crm / app-todo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9aiswZBzoVYsyLKRuGByE
1 parent 0800433 commit 321bd6d

20 files changed

Lines changed: 933 additions & 44 deletions
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
---
5+
6+
feat(spec,lint)!: give `bulkActionDefs` a shape, and lint the aggregate name it references (#4457)
7+
8+
A selection-bar bulk action was declared as
9+
`z.array(z.record(z.string(), z.any()))`**no shape at all**. The real
10+
contract lived in objectui's `BulkActionDef` interface and in the executor that
11+
reads it, so every authoring mistake landed as a silent runtime downgrade:
12+
`opeartion` parsed and the executor hit `Unknown operation: undefined` per row;
13+
`excution: 'aggregate'` parsed and the def stayed per-record, so the endpoint
14+
written for ONE `_selectedIds` call got N calls instead — the exact defect
15+
objectui#3139 was filed to make expressible. That is ADR-0018's "second
16+
vocabulary" smell (an action surface sharing none of `ActionSchema`'s checks)
17+
crossed with ADR-0078's silently-inert metadata.
18+
19+
`ui/bulk-action.zod.ts` types it, with the same treatment `ActionParamSchema`
20+
got in #3746/#4001: a **strict** def whose unknown-key error names the offending
21+
key and the canonical spelling. Beyond spelling, it refuses the combinations the
22+
executor never reads — `patch` outside an `update`, `execution` outside a
23+
`custom`, `params` on a `delete`, `batchSize` on an aggregate — and refuses a
24+
hand-written `actionDef`, which is attached by the renderer when it resolves the
25+
def's `name` and which authored by hand would smuggle an action definition past
26+
the action registry.
27+
28+
**One shape that parsed before is now rejected**: `operation: 'custom'` without
29+
`execution: 'aggregate'`. `resolveBulkActions` attaches a dispatcher for exactly
30+
one authored shape (the aggregate one); every other custom def falls to
31+
`Promise.resolve()` per row — a button that reports success for every selected
32+
record and does nothing. The error names both legal forms: `bulkActions:
33+
['<name>']` for per-record (promoted with the action's own label, params and
34+
`visible`), `execution: 'aggregate'` for one call over the whole selection.
35+
36+
Two things are deliberately left open:
37+
38+
- **`params[]` is `.passthrough()`.** objectui's `BulkActionParam` declares a
39+
`[key: string]: unknown` catch-all — widget config (min/max/step/format)
40+
forwarded to the field renderer as-is. Locking it down would reject valid
41+
config, so declared keys are typed and the rest rides through, the same call
42+
`dashboard.zod.ts` makes for a widget's `config`.
43+
- **The bulk-param / action-param spelling divergence** (`help`/`helpText`,
44+
`default`/`defaultValue`, `object`/`reference`, plus `labelField`, which
45+
`ActionParamSchema` has no counterpart for). objectui already owns a converter
46+
for the promoted direction; converging the authored direction is a cross-repo
47+
change with its own migration. Typing them as they are is what makes the
48+
divergence visible rather than undocumented — the prerequisite for closing it.
49+
50+
`label` and the param/option labels are `z.string()`, not `I18nLabelSchema`:
51+
an authored def reaches the grid verbatim (nothing resolves an `{ en, zh }` map
52+
on this path) and the bar renders `def.label` as a React child, so blessing the
53+
map form would trade a parse error for a blank screen. Localize by declaring a
54+
real action and naming it in `bulkActions` — that path runs through the i18n
55+
resolver.
56+
57+
**Lint**: `validate-action-name-refs` now covers `bulkActionDefs`. Only an
58+
`execution: 'aggregate'` entry is a name reference (it is what
59+
`resolveBulkActions` looks up); an `update`/`delete` def's `name` is a button id
60+
and resolving it would be nonsense. The walk also reaches an **object's own
61+
`listViews`** for the first time — an object has no top-level `list`, so that
62+
tier had simply never been visited while the view-level ones were covered. And
63+
the hint no longer tells a bulk-surface author to add a `locations` entry: the
64+
selection bar is the one surface that does not filter on it, so naming the
65+
action there is the whole placement.
66+
67+
Verified zero new findings against `app-showcase` / `app-crm` / `app-todo`.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
---
2+
title: Bulk Action
3+
description: Bulk Action protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
7+
8+
Bulk Action Schemas
9+
10+
The vocabulary of a list view's `bulkActionDefs` — one entry per button in
11+
12+
the multi-select toolbar. Use a def for a mass data-plane mutation that no
13+
14+
action expresses (`operation: 'update'` with a patch, or `'delete'`), or for
15+
16+
an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the
17+
18+
action it NAMES once for the whole selection.
19+
20+
For the per-record dispatch, name the action in the view's
21+
22+
`bulkActions: ['<name>']` instead — the bare-string form, promoted with the
23+
24+
action's own label, params and `visible`.
25+
26+
<Callout type="info">
27+
**Source:** `packages/spec/src/ui/bulk-action.zod.ts`
28+
</Callout>
29+
30+
## TypeScript Usage
31+
32+
```typescript
33+
import { BulkActionDef, BulkActionExecution, BulkActionOperation, BulkActionParam } from '@objectstack/spec/ui';
34+
import type { BulkActionDef, BulkActionExecution, BulkActionOperation, BulkActionParam } from '@objectstack/spec/ui';
35+
36+
// Validate data
37+
const result = BulkActionDef.parse(data);
38+
```
39+
40+
---
41+
42+
## BulkActionDef
43+
44+
### Properties
45+
46+
| Property | Type | Required | Description |
47+
| :--- | :--- | :--- | :--- |
48+
| **name** | `string` || Stable identifier — the audit-log action key, and (for an aggregate def) the name of the object action to dispatch. |
49+
| **label** | `string` | optional | Button + dialog-header text. Plain string: an authored def is not i18n-resolved (declare a real action and name it in `bulkActions` to get localization). |
50+
| **icon** | `string` | optional | Lucide icon name (e.g. "user-check", "trash-2"). |
51+
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>` | optional | Visual treatment of the button. |
52+
| **operation** | `Enum<'update' \| 'delete' \| 'custom'>` || What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). |
53+
| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds` (objectui#3139). Required on a custom def: the per-record form is declared as `bulkActions: ['<name>']` instead. |
54+
| **patch** | `Record<string, any>` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. |
55+
| **params** | `Record<string, any>[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. |
56+
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
57+
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
58+
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL), same shape as `action.visible`. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. |
59+
| **maxRecords** | `integer` | optional | Selection size above which the run is blocked. Set it on defs whose server work is expensive — an aggregate def carries every selected id in one request. |
60+
| **batchSize** | `integer` | optional | Records per executor batch (default 200). Data-plane operations only — an aggregate run is a single call by definition. |
61+
62+
63+
---
64+
65+
## BulkActionExecution
66+
67+
### Allowed Values
68+
69+
* `perRecord`
70+
* `aggregate`
71+
72+
73+
---
74+
75+
## BulkActionOperation
76+
77+
### Allowed Values
78+
79+
* `update`
80+
* `delete`
81+
* `custom`
82+
83+
84+
---
85+
86+
## BulkActionParam
87+
88+
### Properties
89+
90+
| Property | Type | Required | Description |
91+
| :--- | :--- | :--- | :--- |
92+
| **name** | `string` || Param key — becomes params[name] in the patch / action params bag. |
93+
| **label** | `string` | optional | Field label in the dialog. Plain string: an authored def is not i18n-resolved (see module header). |
94+
| **help** | `string` | optional | Help text under the field. (An ActionParam spells this `helpText` — known divergence, module header.) |
95+
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>` || Field widget to render, from the standard field-type vocabulary (text/number/select/lookup/date/…). |
96+
| **required** | `boolean` | optional | Blocks the Confirm button until a value is present. |
97+
| **default** | `any` | optional | Value applied when the dialog opens. (An ActionParam spells this `defaultValue`.) |
98+
| **options** | `{ label: string; value: string \| number \| boolean }[]` | optional | Static options for select-style widgets. |
99+
| **object** | `string` | optional | Target object for a `lookup` widget. (An ActionParam spells this `reference`.) |
100+
| **labelField** | `string` | optional | Related-object field used as the option label for a `lookup` widget (defaults to name/full_name/email/id). |
101+
| **multiple** | `boolean` | optional | Allow picking multiple values — the param value becomes an array and is written to the patch as-is. |
102+
| **placeholder** | `string` | optional | Placeholder text. |
103+
104+
105+
---
106+

content/docs/references/ui/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This section contains all protocol schemas for the ui layer of ObjectStack.
99
<Card href="/docs/references/ui/action" title="Action" description="Source: packages/spec/src/ui/action.zod.ts" />
1010
<Card href="/docs/references/ui/animation" title="Animation" description="Source: packages/spec/src/ui/animation.zod.ts" />
1111
<Card href="/docs/references/ui/app" title="App" description="Source: packages/spec/src/ui/app.zod.ts" />
12+
<Card href="/docs/references/ui/bulk-action" title="Bulk Action" description="Source: packages/spec/src/ui/bulk-action.zod.ts" />
1213
<Card href="/docs/references/ui/chart" title="Chart" description="Source: packages/spec/src/ui/chart.zod.ts" />
1314
<Card href="/docs/references/ui/component" title="Component" description="Source: packages/spec/src/ui/component.zod.ts" />
1415
<Card href="/docs/references/ui/dashboard" title="Dashboard" description="Source: packages/spec/src/ui/dashboard.zod.ts" />

content/docs/references/ui/meta.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
"http",
2626
"i18n",
2727
"notification",
28-
"sharing"
28+
"sharing",
29+
"---More---",
30+
"bulk-action"
2931
]
3032
}

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). 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}`. |
402+
| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the action it NAMES 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 (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['<name>']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time (#4457). 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). 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}`. |
490+
| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the action it NAMES 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 (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['<name>']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time (#4457). 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: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,15 @@ An aggregate dispatch delivers the selection to the handler as
188188
all-or-nothing: a handler that cannot cover the whole selection must reject,
189189
and per-row retry is replaced by re-running the action. `batchSize` does not
190190
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.
191+
expensive.
192+
193+
**Pick the right key for the dispatch you mean.** Per-record is
194+
`bulkActions: ['<name>']`the bare-string form, which has nowhere to carry a
195+
flag and is promoted with the action's own label, params and `visible`.
196+
Aggregate is the def form, which is where `execution` lives. A def that says
197+
`operation: 'custom'` without `execution: 'aggregate'` is rejected at parse
198+
time: the renderer has no action attached to such a def, so it used to render a
199+
button that reported success for every selected record and did nothing.
194200

195201
A url or api action rendered on the list **toolbar** can also read the current
196202
selection through target interpolation`${ctx.selection.ids}` (comma-joined)
@@ -201,6 +207,14 @@ and `${ctx.selection.count}` — without any bulk wiring.
201207
driven by these two view keys only.
202208
</Callout>
203209

210+
A `bulkActionDefs` entry is a typed shapesee the
211+
[BulkActionDef reference](/docs/references/ui/bulk-action). Unknown keys are
212+
rejected with the canonical spelling named, and so are keys the executor would
213+
never read (`patch` outside an `update`, `execution` outside a `custom`,
214+
`batchSize` on an aggregate). One key is deliberately not authorable:
215+
`actionDef` is attached by the renderer when it resolves the def's `name`, and
216+
writing it by hand would smuggle an action definition past the action registry.
217+
204218
### Data Source
205219

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

0 commit comments

Comments
 (0)