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
67 changes: 67 additions & 0 deletions .changeset/type-bulk-action-defs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
---

feat(spec,lint)!: give `bulkActionDefs` a shape, and lint the aggregate name it references (#4457)

A selection-bar bulk action was declared as
`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 the def stayed per-record, so the endpoint
written for ONE `_selectedIds` call got N calls instead — the exact defect
objectui#3139 was filed to make expressible. That is ADR-0018's "second
vocabulary" smell (an action surface sharing none of `ActionSchema`'s checks)
crossed with ADR-0078's silently-inert metadata.

`ui/bulk-action.zod.ts` types it, with the same treatment `ActionParamSchema`
got in #3746/#4001: a **strict** def whose unknown-key error names the offending
key and the canonical spelling. Beyond spelling, it refuses the combinations the
executor never reads — `patch` outside an `update`, `execution` outside a
`custom`, `params` on a `delete`, `batchSize` on an aggregate — and refuses a
hand-written `actionDef`, which is attached by the renderer when it resolves the
def's `name` 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 (the aggregate one); 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: `bulkActions:
['<name>']` for per-record (promoted with the action's own label, params and
`visible`), `execution: 'aggregate'` for one call over the whole selection.

Two things are deliberately left open:

- **`params[]` is `.passthrough()`.** objectui's `BulkActionParam` declares a
`[key: string]: unknown` catch-all — widget config (min/max/step/format)
forwarded to the field renderer as-is. Locking it down would reject valid
config, so declared keys are typed and the rest rides through, the same call
`dashboard.zod.ts` makes for a widget's `config`.
- **The bulk-param / action-param spelling divergence** (`help`/`helpText`,
`default`/`defaultValue`, `object`/`reference`, plus `labelField`, which
`ActionParamSchema` has no counterpart for). objectui already owns a converter
for the promoted direction; converging the authored direction is a cross-repo
change with its own migration. Typing them as they are is what makes the
divergence visible rather than undocumented — the prerequisite for closing it.

`label` and the param/option labels are `z.string()`, not `I18nLabelSchema`:
an authored def reaches the grid verbatim (nothing resolves an `{ en, zh }` map
on this path) and the bar renders `def.label` as a React child, so blessing the
map form would trade a parse error for a blank screen. Localize by declaring a
real action and naming it in `bulkActions` — that path runs through the i18n
resolver.

**Lint**: `validate-action-name-refs` now covers `bulkActionDefs`. Only an
`execution: 'aggregate'` entry is a name reference (it is what
`resolveBulkActions` looks up); an `update`/`delete` def's `name` is a button id
and resolving it would be nonsense. The walk also reaches an **object's own
`listViews`** for the first time — an object has no top-level `list`, so that
tier had simply never been visited while the view-level ones were covered. And
the hint no longer tells a bulk-surface author to add a `locations` entry: the
selection bar is the one surface that does not filter on it, so naming the
action there is the whole placement.

Verified zero new findings against `app-showcase` / `app-crm` / `app-todo`.
106 changes: 106 additions & 0 deletions content/docs/references/ui/bulk-action.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
title: Bulk Action
description: Bulk Action protocol schemas
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}

Bulk Action Schemas

The vocabulary of a list view's `bulkActionDefs` — one entry per button in

the multi-select toolbar. Use a def for a mass data-plane mutation that no

action expresses (`operation: 'update'` with a patch, or `'delete'`), or for

an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the

action it NAMES once for the whole selection.

For the per-record dispatch, name the action in the view's

`bulkActions: ['<name>']` instead — the bare-string form, promoted with the

action's own label, params and `visible`.

<Callout type="info">
**Source:** `packages/spec/src/ui/bulk-action.zod.ts`
</Callout>

## TypeScript Usage

```typescript
import { BulkActionDef, BulkActionExecution, BulkActionOperation, BulkActionParam } from '@objectstack/spec/ui';
import type { BulkActionDef, BulkActionExecution, BulkActionOperation, BulkActionParam } from '@objectstack/spec/ui';

// Validate data
const result = BulkActionDef.parse(data);
```

---

## BulkActionDef

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Stable identifier — the audit-log action key, and (for an aggregate def) the name of the object action to dispatch. |
| **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). |
| **icon** | `string` | optional | Lucide icon name (e.g. "user-check", "trash-2"). |
| **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>` | optional | Visual treatment of the button. |
| **operation** | `Enum<'update' \| 'delete' \| 'custom'>` | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). |
| **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. |
| **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. |
| **params** | `Record<string, any>[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. |
| **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. |
| **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). |
| **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. |
| **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. |
| **batchSize** | `integer` | optional | Records per executor batch (default 200). Data-plane operations only — an aggregate run is a single call by definition. |


---

## BulkActionExecution

### Allowed Values

* `perRecord`
* `aggregate`


---

## BulkActionOperation

### Allowed Values

* `update`
* `delete`
* `custom`


---

## BulkActionParam

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Param key — becomes params[name] in the patch / action params bag. |
| **label** | `string` | optional | Field label in the dialog. Plain string: an authored def is not i18n-resolved (see module header). |
| **help** | `string` | optional | Help text under the field. (An ActionParam spells this `helpText` — known divergence, module header.) |
| **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/…). |
| **required** | `boolean` | optional | Blocks the Confirm button until a value is present. |
| **default** | `any` | optional | Value applied when the dialog opens. (An ActionParam spells this `defaultValue`.) |
| **options** | `{ label: string; value: string \| number \| boolean }[]` | optional | Static options for select-style widgets. |
| **object** | `string` | optional | Target object for a `lookup` widget. (An ActionParam spells this `reference`.) |
| **labelField** | `string` | optional | Related-object field used as the option label for a `lookup` widget (defaults to name/full_name/email/id). |
| **multiple** | `boolean` | optional | Allow picking multiple values — the param value becomes an array and is written to the patch as-is. |
| **placeholder** | `string` | optional | Placeholder text. |


---

1 change: 1 addition & 0 deletions content/docs/references/ui/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This section contains all protocol schemas for the ui layer of ObjectStack.
<Card href="/docs/references/ui/action" title="Action" description="Source: packages/spec/src/ui/action.zod.ts" />
<Card href="/docs/references/ui/animation" title="Animation" description="Source: packages/spec/src/ui/animation.zod.ts" />
<Card href="/docs/references/ui/app" title="App" description="Source: packages/spec/src/ui/app.zod.ts" />
<Card href="/docs/references/ui/bulk-action" title="Bulk Action" description="Source: packages/spec/src/ui/bulk-action.zod.ts" />
<Card href="/docs/references/ui/chart" title="Chart" description="Source: packages/spec/src/ui/chart.zod.ts" />
<Card href="/docs/references/ui/component" title="Component" description="Source: packages/spec/src/ui/component.zod.ts" />
<Card href="/docs/references/ui/dashboard" title="Dashboard" description="Source: packages/spec/src/ui/dashboard.zod.ts" />
Expand Down
4 changes: 3 additions & 1 deletion content/docs/references/ui/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
"http",
"i18n",
"notification",
"sharing"
"sharing",
"---More---",
"bulk-action"
]
}
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). 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}`. |
| **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}`. |
| **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). 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}`. |
| **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}`. |
| **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
20 changes: 17 additions & 3 deletions content/docs/ui/views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,15 @@ An aggregate dispatch delivers the selection to the handler as
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.
expensive.

**Pick the right key for the dispatch you mean.** Per-record is
`bulkActions: ['<name>']` — the bare-string form, which has nowhere to carry a
flag and is promoted with the action's own label, params and `visible`.
Aggregate is the def form, which is where `execution` lives. A def that says
`operation: 'custom'` without `execution: 'aggregate'` is rejected at parse
time: the renderer has no action attached to such a def, so it used to render a
button that reported success for every selected record and did nothing.

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

A `bulkActionDefs` entry is a typed shape — see the
[BulkActionDef reference](/docs/references/ui/bulk-action). Unknown keys are
rejected with the canonical spelling named, and so are keys the executor would
never read (`patch` outside an `update`, `execution` outside a `custom`,
`batchSize` on an aggregate). One key is deliberately not authorable:
`actionDef` is attached by the renderer when it resolves the def's `name`, and
writing it by hand would smuggle an action definition past the action registry.

### Data Source

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