diff --git a/.changeset/type-bulk-action-defs.md b/.changeset/type-bulk-action-defs.md new file mode 100644 index 0000000000..212be16c2a --- /dev/null +++ b/.changeset/type-bulk-action-defs.md @@ -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: +['']` 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`. diff --git a/content/docs/references/ui/bulk-action.mdx b/content/docs/references/ui/bulk-action.mdx new file mode 100644 index 0000000000..ac02268e99 --- /dev/null +++ b/content/docs/references/ui/bulk-action.mdx @@ -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: ['']` instead — the bare-string form, promoted with the + +action's own label, params and `visible`. + + +**Source:** `packages/spec/src/ui/bulk-action.zod.ts` + + +## 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: ['']` instead. | +| **patch** | `Record` | 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[]` | 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. | + + +--- + diff --git a/content/docs/references/ui/index.mdx b/content/docs/references/ui/index.mdx index f48b8ad1c6..bb696a8110 100644 --- a/content/docs/references/ui/index.mdx +++ b/content/docs/references/ui/index.mdx @@ -9,6 +9,7 @@ This section contains all protocol schemas for the ui layer of ObjectStack. + diff --git a/content/docs/references/ui/meta.json b/content/docs/references/ui/meta.json index 9fbd8f7c70..6b286829f4 100644 --- a/content/docs/references/ui/meta.json +++ b/content/docs/references/ui/meta.json @@ -25,6 +25,8 @@ "http", "i18n", "notification", - "sharing" + "sharing", + "---More---", + "bulk-action" ] } \ No newline at end of file diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index bf72324339..72e8b22023 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). 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: ['']` 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 }[]` | 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). 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: ['']` 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 }[]` | optional | Conditional formatting rules for list rows | | **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view | diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 5b591ad0a0..8d4f6826cd 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -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: ['']` — 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) @@ -201,6 +207,14 @@ and `${ctx.selection.count}` — without any bulk wiring. driven by these two view keys only. +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`): diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index e08d8f5558..0f8ea7c246 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -156,12 +156,13 @@ Classification is per the rule above; **(p)** marks a provisional call made from the file's exports/JSDoc rather than a full read — verify before tightening (the #4001 "sharing-rule lesson": candidates, not verdicts). -### `ui/` — 197 sites +### `ui/` — 200 sites | File | Sites | Class | Note / next action | |---|---|---|---| | `action.zod.ts` | 9 | authorable | param schema strict (#3746); remaining blocks ride later steps | -| `view.zod.ts` | 50 | authorable | partially strict (ADR-0089); long tail of sub-blocks | +| `view.zod.ts` | 50 | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below | +| `bulk-action.zod.ts` | 3 | authorable | **strict as of #4457** — `BulkActionDefSchema` (the def itself). It was `z.array(z.record(z.string(), z.any()))` inline in `view.zod.ts`: a selection-bar button with **no shape at all**, so `opeartion` / `excution: 'aggregate'` parsed and shipped as a button that ran the default behaviour. Its two other sites are `BulkActionParamSchema` and that param's `options` entry, both deliberately **open**: objectui's `BulkActionParam` declares a `[key: string]: unknown` catch-all for widget config (min/max/step/format), so `.passthrough()` is the honest mirror and strictness there would reject valid config — same call as `dashboard.zod.ts`'s widget `config`. The def also refuses the combinations the executor never reads (`patch` outside an update, `execution` outside a custom, `batchSize` on an aggregate) and a hand-written `actionDef`, which is renderer-attached | | `component.zod.ts` | 29 | authorable | **next candidate** — SDUI component defs; check React-prop open slots first (p) | | `theme.zod.ts` | 14 | authorable (p) | authored themes | | `app.zod.ts` | 18 | authorable | **strict as of #4001 PR B** — `AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first | diff --git a/packages/lint/src/validate-action-name-refs.test.ts b/packages/lint/src/validate-action-name-refs.test.ts index ed54fdb001..88a215d6da 100644 --- a/packages/lint/src/validate-action-name-refs.test.ts +++ b/packages/lint/src/validate-action-name-refs.test.ts @@ -227,6 +227,135 @@ describe('validateActionNameRefs — navigation action items', () => { }); }); +describe('validateActionNameRefs — bulkActionDefs (#4457)', () => { + it('errors on an aggregate def naming nothing', () => { + // `resolveBulkActions` resolves an aggregate def's `name` against the + // object's actions to get the dispatcher it calls once for the selection. + // No match → no dispatcher → the dialog opens and the run reports "has no + // dispatcher wired". Same dead affordance as a bulkActions name. + const findings = validateActionNameRefs({ + ...withActions(), + views: [ + { + name: 'crm_lead', + list: { bulkActionDefs: [{ name: 'export_zip', operation: 'custom', execution: 'aggregate' }] }, + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('error'); + expect(findings[0].path).toBe('views[0].list.bulkActionDefs[0].name'); + expect(findings[0].where).toContain('bulkActionDefs[0]'); + }); + + it('accepts an aggregate def that resolves', () => { + const findings = validateActionNameRefs({ + ...withActions(), + views: [ + { + name: 'crm_lead', + list: { bulkActionDefs: [{ name: 'crm_convert_lead', operation: 'custom', execution: 'aggregate' }] }, + }, + ], + }); + expect(findings).toEqual([]); + }); + + it("leaves an update/delete def alone — its `name` is a button id, not a reference", () => { + // Resolving `archive` against `stack.actions` would be nonsense: the + // executor writes fields through the data API and never looks the name up. + const findings = validateActionNameRefs({ + ...withActions(), + views: [ + { + name: 'crm_lead', + list: { + bulkActionDefs: [ + { name: 'archive', operation: 'update', patch: { archived: true } }, + { name: 'purge', operation: 'delete' }, + ], + }, + }, + ], + }); + expect(findings).toEqual([]); + }); + + it('skips a def carrying an inlined `actionDef` — it brings its own dispatcher', () => { + const findings = validateActionNameRefs({ + ...withActions(), + views: [ + { + name: 'crm_lead', + list: { + bulkActionDefs: [ + { name: 'export_zip', operation: 'custom', execution: 'aggregate', actionDef: { type: 'api' } }, + ], + }, + }, + ], + }); + expect(findings).toEqual([]); + }); + + it('tells the author no `locations` entry is needed for a selection-bar action', () => { + // The selection bar is the ONE surface that does not filter on + // `locations` — the generic "with the location this surface needs" hint + // would send an author to add a placement that changes nothing. + const findings = validateActionNameRefs({ + ...withActions(), + views: [{ name: 'crm_lead', list: { bulkActions: ['mass_update'] } }], + }); + expect(findings[0].hint).toContain('the selection bar places it by name'); + expect(findings[0].hint).not.toContain('the location this surface needs'); + }); + + it('still says "location" for a row-action menu, which DOES filter', () => { + const findings = validateActionNameRefs({ + ...withActions(), + views: [{ name: 'crm_lead', list: { rowActions: ['complete_task'] } }], + }); + expect(findings[0].hint).toContain('the location this surface needs'); + }); +}); + +describe('validateActionNameRefs — object-embedded list views (#4457)', () => { + it('walks an object’s own listViews, which have no top-level `list`', () => { + const findings = validateActionNameRefs({ + objects: [ + { + name: 'crm_lead', + listViews: { + all: { + rowActions: ['ghost_row'], + bulkActionDefs: [{ name: 'ghost_zip', operation: 'custom', execution: 'aggregate' }], + }, + }, + }, + ], + actions: [{ name: 'crm_convert_lead', type: 'script' }], + }); + expect(findings.map((f) => f.path)).toEqual([ + 'objects[0].listViews.all.rowActions[0]', + 'objects[0].listViews.all.bulkActionDefs[0].name', + ]); + expect(findings[0].where).toContain('object "crm_lead"'); + }); + + it('resolves against the object’s OWN actions, not just stack.actions', () => { + const findings = validateActionNameRefs({ + objects: [ + { + name: 'crm_lead', + actions: [{ name: 'crm_score', type: 'script' }], + listViews: { all: { bulkActions: ['crm_score'] } }, + }, + ], + }); + expect(findings).toEqual([]); + }); +}); + describe('validateActionNameRefs — floor', () => { it('is silent on a clean stack and tolerates empty input', () => { expect(validateActionNameRefs(withActions())).toEqual([]); diff --git a/packages/lint/src/validate-action-name-refs.ts b/packages/lint/src/validate-action-name-refs.ts index 69ecf69a07..77605de41c 100644 --- a/packages/lint/src/validate-action-name-refs.ts +++ b/packages/lint/src/validate-action-name-refs.ts @@ -10,8 +10,12 @@ * `z.array(z.string())` / `z.string()`, so a name that matches no defined action * parses and ships: * - * - list views — `rowActions[]` / `bulkActions[]` (both the default `list` - * container and each `listViews.` entry) + * - list views — `rowActions[]` / `bulkActions[]`, plus each + * `bulkActionDefs[]` entry that is a reference rather than a button id + * (`execution: 'aggregate'` — see the walk). Across all three tiers: the + * default `list` container, each `listViews.` entry, and an OBJECT's + * own `listViews.` (added in #4457; an object has no top-level `list`, + * so that tier had simply never been walked) * - page components — `record:quick_actions` → `properties.actionNames[]` * - app navigation — `{ type: 'action', actionDef: { actionName } }` * @@ -133,7 +137,21 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] { const known = collectActionNames(stack); - const check = (name: string, where: string, path: string, surface: string) => { + const check = ( + name: string, + where: string, + path: string, + surface: string, + /** + * What the newly-defined action still needs to be reachable from THIS + * surface. A row/quick-action menu filters on `locations`; the selection + * bar does not — naming the action in the view is its whole declaration + * (the `action.bulkEnabled` tombstone says so, and `content/docs/ui/ + * actions.mdx` names it as the one exception to location filtering). One + * hint for both would have to be wrong for one of them. + */ + placement = 'with the location this surface needs', + ) => { if (known.has(name)) return; findings.push({ severity: 'error', @@ -147,44 +165,108 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] { suggest(name, known), hint: `Define an action named "${name}" (in \`stack.actions\` or the object's \`actions\`) ` + - `with the location this surface needs, remove the reference, or ignore this if the ` + + `${placement}, remove the reference, or ignore this if the ` + `action is contributed by another installed package.` + (known.size > 0 ? ` Defined actions: ${[...known].sort().join(', ')}.` : ''), }); }; - // ── List views: rowActions / bulkActions on `list` + each `listViews.` ── + /** Naming an action in the selection bar IS its placement — see `check`. */ + const SELECTION_BAR_PLACEMENT = + '(no `locations` entry needed — the selection bar places it by name)'; + + /** + * One list container: the default `list`, a `listViews.` entry, or an + * object-embedded one. Shared so the three tiers cannot drift into checking + * different keys — an object has no top-level `list`, and its `listViews` + * went unchecked until #4457 while the view-level ones were covered. + */ + const checkListContainer = ( + container: unknown, + owner: string, + label: string, + path: string, + ) => { + if (!container || typeof container !== 'object') return; + const list = container as AnyRec; + for (const key of ['rowActions', 'bulkActions'] as const) { + const names = strList(list[key]); + for (let ai = 0; ai < names.length; ai++) { + check( + names[ai], + `${owner} · ${label} · ${key}`, + `${path}.${key}[${ai}]`, + key === 'bulkActions' ? 'Bulk-action menu' : 'Row-action menu', + key === 'bulkActions' ? SELECTION_BAR_PLACEMENT : undefined, + ); + } + } + + // `bulkActionDefs` — only SOME entries are name references (#4457). + // + // An `update`/`delete` def is a data-plane mass mutation: its `name` is a + // button id and resolving it against `stack.actions` would be nonsense. + // The one entry that IS a reference is `execution: 'aggregate'`, which is + // exactly what objectui's `resolveBulkActions` looks up by name to attach + // the action it dispatches — a name that hits nothing leaves the def with + // no dispatcher, so the button opens its dialog and the run resolves to + // "no dispatcher wired". Same dead affordance, same severity. + // + // (Spec's `BulkActionDefSchema` rejects a hand-written `actionDef`, but a + // stack can reach lint through paths that never parsed — a raw JSON fixture, + // an older package — so an inlined definition is skipped rather than + // assumed impossible: it carries its own dispatcher and resolves nothing.) + const defs = Array.isArray(list.bulkActionDefs) ? (list.bulkActionDefs as AnyRec[]) : []; + for (let di = 0; di < defs.length; di++) { + const def = defs[di]; + if (!def || typeof def !== 'object') continue; + if (def.execution !== 'aggregate') continue; + if (def.actionDef !== undefined) continue; + const name = strName(def.name); + if (!name) continue; + check( + name, + `${owner} · ${label} · bulkActionDefs[${di}]`, + `${path}.bulkActionDefs[${di}].name`, + 'Aggregate bulk action', + SELECTION_BAR_PLACEMENT, + ); + } + }; + + // ── List views: `list` + each `listViews.`, on views AND on objects ── const views = asArray(stack.views); for (let vi = 0; vi < views.length; vi++) { const view = views[vi]; if (!view || typeof view !== 'object') continue; const viewName = strName(view.name) ?? strName(view.object) ?? `#${vi}`; + const owner = `view "${viewName}"`; - const checkListContainer = (container: unknown, label: string, path: string) => { - if (!container || typeof container !== 'object') return; - const list = container as AnyRec; - for (const key of ['rowActions', 'bulkActions'] as const) { - const names = strList(list[key]); - for (let ai = 0; ai < names.length; ai++) { - check( - names[ai], - `view "${viewName}" · ${label} · ${key}`, - `${path}.${key}[${ai}]`, - key === 'bulkActions' ? 'Bulk-action menu' : 'Row-action menu', - ); - } - } - }; - - checkListContainer(view.list, 'list', `views[${vi}].list`); + checkListContainer(view.list, owner, 'list', `views[${vi}].list`); const listViews = view.listViews; if (listViews && typeof listViews === 'object' && !Array.isArray(listViews)) { for (const [key, lv] of Object.entries(listViews as AnyRec)) { - checkListContainer(lv, `listViews.${key}`, `views[${vi}].listViews.${key}`); + checkListContainer(lv, owner, `listViews.${key}`, `views[${vi}].listViews.${key}`); } } } + // An object carries its own `listViews` (it has no top-level `list`), and a + // reference there is as dead as one in a standalone view — it was simply + // never walked. Object-EMBEDDED actions were already collected as + // definitions above; this is the consuming half. + const objects = asArray(stack.objects); + for (let oi = 0; oi < objects.length; oi++) { + const obj = objects[oi]; + if (!obj || typeof obj !== 'object') continue; + const objListViews = obj.listViews; + if (!objListViews || typeof objListViews !== 'object' || Array.isArray(objListViews)) continue; + const owner = `object "${strName(obj.name) ?? `#${oi}`}"`; + for (const [key, lv] of Object.entries(objListViews as AnyRec)) { + checkListContainer(lv, owner, `listViews.${key}`, `objects[${oi}].listViews.${key}`); + } + } + // ── Page components: record:quick_actions → properties.actionNames ── const pages = asArray(stack.pages); for (let pi = 0; pi < pages.length; pi++) { diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index bf7ff0b0f2..2efecb761e 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -133,6 +133,17 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ 'system/settings-manifest.zod.ts:visible', ], }, + { + id: 'cel-bulk-action-visible', + summary: "selection-bar bulk action per-record eligibility (bulkActionDefs[].visible, objectui#3067)", + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-closed', + enforcement: 'console (objectui) partitionBulkRows (plugin-grid/bulkEligibility.ts) → evalRowPredicate → @objectstack/formula celEngine (interpret), evaluated ONCE PER SELECTED RECORD with that record bound: the button is offered when at least one selected record passes, and the run covers only those — the rest are reported as skipped in the dialog. Faults hide the record (fallback:false, warnOnError) rather than acting on one the predicate was written to exclude; UI gating only, write enforcement stays with permissions/hooks', + // Reached the ledger in #4457, not #3067: the key existed and was evaluated + // all along, but it lived inside `bulkActionDefs: z.array(z.record(z.any()))`, + // so the conformance walk had no declared surface to see. Typing the def is + // what made it visible — which is the argument for typing it in one line. + covers: ['ui/bulk-action.zod.ts:visible'], + }, { id: 'cel-row-crud-visible', summary: 'built-in row Edit/Delete per-record visibility (userActions.{edit,delete}.visibleWhen, objectui#2614)', diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 4e78b190ca..04cf07f8d9 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3264,6 +3264,14 @@ "BreakpointColumnMapSchema (const)", "BreakpointName (type)", "BreakpointOrderMapSchema (const)", + "BulkActionDef (type)", + "BulkActionDefSchema (const)", + "BulkActionExecution (type)", + "BulkActionExecutionSchema (const)", + "BulkActionOperation (type)", + "BulkActionOperationSchema (const)", + "BulkActionParam (type)", + "BulkActionParamSchema (const)", "CHART_AGGREGATE_COMPARISON_SUFFIX (const)", "CalendarConfigSchema (const)", "ChartAggregate (type)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index c8f296470a..9fe3e15894 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -1,5 +1,5 @@ { - "description": "Ratchet of every AUTHORABLE key in the spec \u2014 what a metadata author may write, which for this platform IS the third-party API. Auto-updated on additions (commit the change). A key that disappears without a tombstone fails gen:schema, because these schemas are not .strict() and Zod would silently strip it. \"[RETIRED]\" marks a tombstoned key that still rejects with an upgrade prescription. See #3855, ADR-0059 \u00a75.", + "description": "Ratchet of every AUTHORABLE key in the spec — what a metadata author may write, which for this platform IS the third-party API. Auto-updated on additions (commit the change). A key that disappears without a tombstone fails gen:schema, because these schemas are not .strict() and Zod would silently strip it. \"[RETIRED]\" marks a tombstoned key that still rejects with an upgrade prescription. See #3855, ADR-0059 §5.", "keys": [ "ai/AIModelConfig:maxTokens", "ai/AIModelConfig:model", @@ -7215,6 +7215,30 @@ "ui/BreakpointOrderMap:sm", "ui/BreakpointOrderMap:xl", "ui/BreakpointOrderMap:xs", + "ui/BulkActionDef:batchSize", + "ui/BulkActionDef:confirmLabel", + "ui/BulkActionDef:confirmText", + "ui/BulkActionDef:execution", + "ui/BulkActionDef:icon", + "ui/BulkActionDef:label", + "ui/BulkActionDef:maxRecords", + "ui/BulkActionDef:name", + "ui/BulkActionDef:operation", + "ui/BulkActionDef:params", + "ui/BulkActionDef:patch", + "ui/BulkActionDef:variant", + "ui/BulkActionDef:visible", + "ui/BulkActionParam:default", + "ui/BulkActionParam:help", + "ui/BulkActionParam:label", + "ui/BulkActionParam:labelField", + "ui/BulkActionParam:multiple", + "ui/BulkActionParam:name", + "ui/BulkActionParam:object", + "ui/BulkActionParam:options", + "ui/BulkActionParam:placeholder", + "ui/BulkActionParam:required", + "ui/BulkActionParam:type", "ui/CalendarConfig:colorField", "ui/CalendarConfig:endDateField", "ui/CalendarConfig:startDateField", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 5f3fbb5f60..165c00cfe3 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1,5 +1,5 @@ { - "description": "Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. Auto-appended when new schemas are added (commit the change). A listed schema that a build no longer emits fails gen:schema \u2014 remove a key ONLY for a deliberate retirement. See #2978.", + "description": "Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. Auto-appended when new schemas are added (commit the change). A listed schema that a build no longer emits fails gen:schema — remove a key ONLY for a deliberate retirement. See #2978.", "schemas": [ "ai/AIModelConfig", "ai/AIUsageRecord", @@ -1538,6 +1538,10 @@ "ui/BreakpointColumnMap", "ui/BreakpointName", "ui/BreakpointOrderMap", + "ui/BulkActionDef", + "ui/BulkActionExecution", + "ui/BulkActionOperation", + "ui/BulkActionParam", "ui/CalendarConfig", "ui/ChartAggregate", "ui/ChartAggregateFunction", diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 9fe7f3350d..6dc75b8766 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -135,7 +135,8 @@ }, "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@4bf612c." + "evidence": "packages/spec/src/ui/bulk-action.zod.ts", + "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. #4457 gave the def a SHAPE (it was z.record(z.any())): the entry schema is BulkActionDefSchema, strict, and it refuses the combinations that parse but the executor never reads — so this row's liveness now covers the keys inside a def, not just the array." }, "virtualScroll": { "status": "live", diff --git a/packages/spec/src/ui/bulk-action.test.ts b/packages/spec/src/ui/bulk-action.test.ts new file mode 100644 index 0000000000..4cf473ca3a --- /dev/null +++ b/packages/spec/src/ui/bulk-action.test.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { BulkActionDefSchema } from './bulk-action.zod'; + +/** Parse and return the flattened issue messages (empty = clean). */ +const reject = (input: unknown): string[] => { + const r = BulkActionDefSchema.safeParse(input); + return r.success ? [] : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`); +}; + +const ok = (input: unknown) => { + const r = BulkActionDefSchema.safeParse(input); + if (!r.success) throw new Error(`expected parse to succeed:\n${r.error.issues.map((i) => i.message).join('\n')}`); + return r.data; +}; + +describe('BulkActionDefSchema (#4457)', () => { + describe('— the shapes real views author today keep parsing', () => { + // Lifted verbatim from `examples/app-showcase/src/ui/views/project.view.ts` + // and `task.view.ts`. Typing a key that was `z.record(z.any())` is only + // safe if the surface it governed still fits, so the specimens are the + // regression guard, not a hand-written approximation. + it('accepts the showcase aggregate def', () => { + expect(ok({ name: 'showcase_recalc_selection', operation: 'custom', execution: 'aggregate' })) + .toMatchObject({ name: 'showcase_recalc_selection', execution: 'aggregate' }); + }); + + it('accepts a mass update with a select param', () => { + const def = ok({ + name: 'set_labels', + label: 'Set Labels', + operation: 'update', + confirmText: 'Set these labels on every selected project?', + params: [{ + name: 'labels', + label: 'Labels', + type: 'select', + multiple: true, + required: true, + options: [{ label: 'Frontend', value: 'frontend' }, { label: 'QA', value: 'qa' }], + }], + }); + expect(def.params?.[0]).toMatchObject({ name: 'labels', type: 'select', multiple: true }); + }); + + it('accepts a lookup param with `object` / `labelField`', () => { + // The bulk-dialog spelling of what an ActionParam calls `reference`. + // Documented divergence — see the module header; the point of typing it + // is that the divergence is now written down instead of implied. + const def = ok({ + name: 'assign_team', + operation: 'update', + params: [{ name: 'team_members', type: 'lookup', object: 'sys_user', labelField: 'name', multiple: true }], + }); + expect(def.params?.[0]).toMatchObject({ object: 'sys_user', labelField: 'name' }); + }); + + it('forwards unknown WIDGET config on a param — the renderer declares a catch-all', () => { + const def = ok({ + name: 'reschedule', + operation: 'update', + params: [{ name: 'shift_days', type: 'number', min: 1, max: 90, step: 1 }], + }); + expect(def.params?.[0]).toMatchObject({ min: 1, max: 90, step: 1 }); + }); + }); + + describe('— a mis-spelled key is an error, not a silent default', () => { + it('names the offending key and the canonical one', () => { + const issues = reject({ name: 'set_labels', opeartion: 'update' }); + expect(issues.join('\n')).toContain('`opeartion` → `operation`'); + }); + + it('catches the aggregate typo that silently costs N requests', () => { + // `excution: 'aggregate'` parsed before #4457, leaving the def in + // per-record mode: the endpoint written for ONE `_selectedIds` call gets + // N calls instead — the exact defect objectui#3139 existed to fix. + const issues = reject({ name: 'recalc_selection', operation: 'custom', excution: 'aggregate' }); + expect(issues.join('\n')).toContain('`excution` → `execution`'); + }); + + it('refuses a hand-written `actionDef` with the reason, not a spelling hint', () => { + const issues = reject({ + name: 'recalc_selection', + operation: 'custom', + execution: 'aggregate', + actionDef: { type: 'api', endpoint: '/whatever' }, + }); + expect(issues.join('\n')).toContain('attached by the renderer, not authored'); + expect(issues.join('\n')).toContain('past the action registry'); + }); + + it('prescribes the view-side replacement for the retired `bulkEnabled`', () => { + const issues = reject({ name: 'mark_done', operation: 'update', bulkEnabled: true }); + expect(issues.join('\n')).toContain('retired in spec 17'); + }); + }); + + describe("— `operation: 'custom'` must say which dispatch it means", () => { + it('rejects a custom def with no execution mode', () => { + // Without `execution: 'aggregate'` the renderer attaches no `actionDef`, + // and the executor's custom branch resolves to `Promise.resolve()` per + // row: N green ticks, zero work (ADR-0078 — reports success, does + // nothing). + const issues = reject({ name: 'generate_qr_zip', operation: 'custom' }); + expect(issues.join('\n')).toContain('treats as a no-op'); + // Both legal forms are named, so the fix does not need the source. + expect(issues.join('\n')).toContain("`bulkActions: ['generate_qr_zip']`"); + expect(issues.join('\n')).toContain("`execution: 'aggregate'`"); + }); + + it("rejects an explicit `execution: 'perRecord'` for the same reason", () => { + // The default spelled out loud is still the inert shape — the per-record + // form is `bulkActions`, not a def. + expect(reject({ name: 'generate_qr_zip', operation: 'custom', execution: 'perRecord' }).join('\n')) + .toContain('treats as a no-op'); + }); + }); + + describe('— keys the executor would never read are refused, not dropped', () => { + it('rejects `execution` on a data-plane operation', () => { + expect(reject({ name: 'set_labels', operation: 'update', execution: 'aggregate' }).join('\n')) + .toContain("only applies to `operation: 'custom'`"); + }); + + it('rejects `patch` outside an update', () => { + expect(reject({ name: 'purge', operation: 'delete', patch: { archived: true } }).join('\n')) + .toContain("only applies to `operation: 'update'`"); + }); + + it('rejects `params` on a delete — a bulk delete takes ids only', () => { + expect(reject({ name: 'purge', operation: 'delete', params: [{ name: 'why', type: 'text' }] }).join('\n')) + .toContain('the executor never reads'); + }); + + it('rejects `batchSize` on an aggregate def and points at `maxRecords`', () => { + const issues = reject({ name: 'gen_zip', operation: 'custom', execution: 'aggregate', batchSize: 25 }); + expect(issues.join('\n')).toContain('ONE call by definition'); + expect(issues.join('\n')).toContain('`maxRecords`'); + }); + + it('keeps `batchSize` legal on the data-plane operations it governs', () => { + expect(ok({ name: 'set_labels', operation: 'update', batchSize: 25 }).batchSize).toBe(25); + expect(ok({ name: 'purge', operation: 'delete', batchSize: 25 }).batchSize).toBe(25); + }); + }); + + describe('— floor', () => { + it('requires `name` and `operation`', () => { + expect(reject({}).length).toBeGreaterThan(0); + expect(reject({ name: 'set_labels' }).join('\n')).toContain('operation'); + expect(reject({ operation: 'update' }).join('\n')).toContain('name'); + }); + + it('holds `name` to the same identifier rule as an action', () => { + // An aggregate def's `name` IS an action name — a different spelling + // rule here would let a def name something no action could be called. + expect(reject({ name: 'Set Labels', operation: 'update' }).length).toBeGreaterThan(0); + }); + + it('accepts a bare `visible` CEL string and the `{ dialect, source }` envelope', () => { + expect(ok({ name: 'purge', operation: 'delete', visible: "'admin' in current_user.positions" }).visible) + .toBeDefined(); + expect(ok({ name: 'purge', operation: 'delete', visible: { dialect: 'cel', source: 'true' } }).visible) + .toBeDefined(); + }); + }); +}); diff --git a/packages/spec/src/ui/bulk-action.zod.ts b/packages/spec/src/ui/bulk-action.zod.ts new file mode 100644 index 0000000000..616e09d205 --- /dev/null +++ b/packages/spec/src/ui/bulk-action.zod.ts @@ -0,0 +1,262 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; +import { strictUnknownKeyError } from '../shared/suggestions.zod'; +import { ExpressionInputSchema } from '../shared/expression.zod'; +import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; +import { FieldType } from '../data/field.zod'; + +// ───────────────────────────────────────────────────────────────────────────── +// WHY THIS FILE EXISTS (#4457) — engineering rationale; the author-facing +// description is the JSDoc below, which is what the generated reference page +// renders. +// +// Until #4457 `bulkActionDefs` was `z.array(z.record(z.string(), z.any()))` — a +// selection-bar button with NO SHAPE AT ALL. The real contract lived only in +// objectui's `BulkActionDef` interface (`packages/types/src/objectql.ts`) and in +// the executor that reads it, so every authoring mistake landed as a silent +// runtime downgrade instead of a parse error: +// +// - `opeartion: 'update'` → no `operation` at all → the executor's exhaustive +// switch falls through to `Unknown operation: undefined`, PER ROW. +// - `excution: 'aggregate'` → the def stays per-record, so the endpoint +// written for ONE `_selectedIds` call gets N calls instead — the exact +// defect objectui#3139 existed to make expressible. +// - `actionDef: {...}` → a renderer-INTERNAL key (attached by +// `resolveBulkActions` when it resolves a name) authored by hand: it looks +// like it should work, and the executor will dispatch whatever is inside it, +// bypassing the action registry, its permission gate and its param contract. +// +// 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. +// The def gets the same treatment `ActionParamSchema` got in #3746/#4001: a +// strict shape whose unknown-key error names the offending key and the +// canonical spelling. +// +// THE RENDERER IS THE SOURCE OF TRUTH, AND THIS MIRRORS IT DELIBERATELY. +// Every key exists because objectui reads it, and each one's shape is the shape +// objectui's type declares — including the two places that is narrower or wider +// than the platform default: +// +// - `label` and the param/option labels are `z.string()`, not +// `I18nLabelSchema`. An authored def reaches the grid VERBATIM +// (`app-shell/ObjectView.tsx` passes `bulkActionDefs` straight through; +// `resolveBulkActions` documents that authored defs are "left as-authored"), +// so 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. Localizing means declaring a real action and +// naming it in `bulkActions`: THAT path runs through the i18n resolver +// (`toBulkActionDef`'s `localize`). +// - `params[]` is `.passthrough()`. objectui's `BulkActionParam` declares an +// explicit `[key: string]: unknown` catch-all — widget config forwarded to +// the field renderer as-is (min/max/step/format). 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`. +// +// KNOWN DIVERGENCE, DELIBERATELY NOT FIXED HERE. A bulk param and an action +// param are the same idea under different spellings (`help`/`helpText`, +// `default`/`defaultValue`, `object`/`reference`, plus `labelField`, which +// `ActionParamSchema` has no counterpart for). objectui already owns a converter +// for the PROMOTED direction (`toBulkParam` in `resolveBulkActions.ts`); +// converging the AUTHORED direction means teaching the renderer to run authored +// params through it and giving `ActionParamSchema` a `labelField` — a cross-repo +// change with its own migration, not a rider on typing the def. Typing them as +// they are is what makes the divergence visible instead of implied, which is the +// prerequisite for closing it. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 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: ['']` instead — the bare-string form, promoted with the + * action's own label, params and `visible`. + */ + +/** How the executor mutates the selected records. */ +export const BulkActionOperationSchema = z.enum(['update', 'delete', 'custom']); +export type BulkActionOperation = z.infer; + +/** How many dispatches a `custom` def makes for a selection of N records. */ +export const BulkActionExecutionSchema = z.enum(['perRecord', 'aggregate']); +export type BulkActionExecution = z.infer; + +/** + * One input collected ONCE by the bulk dialog before the run (never re-prompted + * per record). For `operation: 'update'` the collected values ARE the patch + * (merged over the def's static `patch`); for an aggregate `custom` def they + * ride along as the action's params. + * + * `.passthrough()` — see the module header: the renderer's own type declares a + * catch-all for widget config, so the declared keys are typed and extras are + * forwarded. That means a typo'd key here still ships silently; the def LEVEL + * is where strictness buys something, and this level is where it would lie. + */ +export const BulkActionParamSchema = lazySchema(() => z.object({ + name: z.string().min(1).describe('Param key — becomes params[name] in the patch / action params bag.'), + label: z.string().optional().describe('Field label in the dialog. Plain string: an authored def is not i18n-resolved (see module header).'), + help: z.string().optional().describe('Help text under the field. (An ActionParam spells this `helpText` — known divergence, module header.)'), + type: FieldType.describe('Field widget to render, from the standard field-type vocabulary (text/number/select/lookup/date/…).'), + required: z.boolean().optional().describe('Blocks the Confirm button until a value is present.'), + default: z.unknown().optional().describe('Value applied when the dialog opens. (An ActionParam spells this `defaultValue`.)'), + options: z.array(z.object({ + label: z.string().describe('Option label (plain string — not i18n-resolved on this path).'), + value: z.union([z.string(), z.number(), z.boolean()]).describe('Stored value.'), + })).optional().describe('Static options for select-style widgets.'), + object: SnakeCaseIdentifierSchema.optional().describe("Target object for a `lookup` widget. (An ActionParam spells this `reference`.)"), + labelField: z.string().optional().describe('Related-object field used as the option label for a `lookup` widget (defaults to name/full_name/email/id).'), + multiple: z.boolean().optional().describe('Allow picking multiple values — the param value becomes an array and is written to the patch as-is.'), + placeholder: z.string().optional().describe('Placeholder text.'), +}).passthrough()); +export type BulkActionParam = z.infer; + +/** Declared keys of a bulk-action def — the "did you mean" pool. */ +const BULK_ACTION_DEF_KEYS = [ + 'name', 'label', 'icon', 'variant', 'operation', 'execution', 'patch', + 'params', 'confirmText', 'confirmLabel', 'visible', 'maxRecords', 'batchSize', +] as const; + +const bulkActionDefUnknownKeyError = strictUnknownKeyError({ + surface: 'this bulk action definition', + knownKeys: BULK_ACTION_DEF_KEYS, + aliases: { + action: 'name', + actionname: 'name', + title: 'label', + op: 'operation', + mode: 'execution', + confirm: 'confirmText', + confirmmessage: 'confirmText', + limit: 'maxRecords', + max: 'maxRecords', + batch: 'batchSize', + }, + guidance: { + // Not a typo — a real key the RENDERER attaches, which is exactly why an + // author reaching for it needs more than "did you mean". + actionDef: + '`actionDef` is attached by the renderer, not authored: `resolveBulkActions` looks the ' + + 'action up by `name` and inlines it. Writing it by hand smuggles an action definition ' + + 'past the action registry — no permission gate, no param contract, no lint. Declare the ' + + 'action normally and let this def name it.', + bulkEnabled: + '`action.bulkEnabled` was retired in spec 17: the selection bar is driven by the LIST ' + + "VIEW's `bulkActions` / `bulkActionDefs`, which is this array. There is nothing to set.", + recordIdParam: + '`recordIdParam` belongs on the ACTION, not on the def that names it — a per-record bulk ' + + "run reuses the action's own declaration, and an `execution: 'aggregate'` run carries " + + 'the whole selection in `params._selectedIds` instead of a single record id.', + }, + history: + 'Until #4457 the whole array was `z.array(z.record(z.string(), z.any()))` — every key parsed, ' + + 'so a mis-spelled one shipped as a button that silently ran the DEFAULT behaviour (or none ' + + 'at all).', +}); + +/** + * Rich, schema-driven definition of one button in the multi-select bar. + * + * Two vocabularies reach that bar and they are not interchangeable: + * + * - **`bulkActions: ['']`** — names an action the object declares. + * The renderer promotes it to a def carrying the action's label, icon, + * `visible`, confirm text and params, and dispatches it ONCE PER selected + * record. This is the right form for "run this action on each of them". + * - **`bulkActionDefs: [{...}]`** (this schema) — a def authored in the view. + * Use it for a mass data-plane mutation (`update` / `delete`) that no action + * expresses, or for an `execution: 'aggregate'` custom action that must see + * the whole selection in ONE call. + * + * The refinements below reject the combinations the executor cannot honour. + * Each one parsed before #4457 and produced a button that reports success while + * doing nothing, or a key the executor silently drops (ADR-0078) — failure + * modes invisible from the authoring side, which is why they are caught here + * rather than written down and hoped for. + */ +export const BulkActionDefSchema = lazySchema(() => z.object({ + name: SnakeCaseIdentifierSchema.describe('Stable identifier — the audit-log action key, and (for an aggregate def) the name of the object action to dispatch.'), + label: z.string().optional().describe('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: z.string().optional().describe('Lucide icon name (e.g. "user-check", "trash-2").'), + variant: z.enum(['primary', 'secondary', 'danger', 'ghost', 'outline']).optional().describe('Visual treatment of the button.'), + operation: BulkActionOperationSchema.describe("What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`)."), + execution: BulkActionExecutionSchema.optional().describe("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: ['']` instead."), + patch: z.record(z.string(), z.unknown()).optional().describe("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: z.array(BulkActionParamSchema).optional().describe('Inputs collected once before the run. Omit to skip the params step and go straight to confirm.'), + confirmText: z.string().optional().describe('Confirmation text shown above the affected-record summary.'), + confirmLabel: z.string().optional().describe('Custom Confirm button label (default: "Run").'), + visible: ExpressionInputSchema.optional().describe('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: z.number().int().positive().optional().describe('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: z.number().int().positive().optional().describe('Records per executor batch (default 200). Data-plane operations only — an aggregate run is a single call by definition.'), +}, { error: bulkActionDefUnknownKeyError }).strict() + .superRefine((def, ctx) => { + // ── `custom` without `aggregate` is the historical no-op ────────────── + // `useBulkExecutor`'s custom branch dispatches only when the def carries a + // renderer-attached `actionDef`, and `resolveBulkActions` attaches one for + // exactly ONE authored shape: `execution: 'aggregate'`. Every other custom + // def resolves to `Promise.resolve()` per row — N green ticks, zero work. + if (def.operation === 'custom' && def.execution !== 'aggregate') { + ctx.addIssue({ + code: 'custom', + path: ['execution'], + message: + `Bulk action "${def.name}" declares \`operation: 'custom'\` without ` + + `\`execution: 'aggregate'\`, which the renderer treats as a no-op — the button runs, ` + + `reports success for every selected record, and does nothing. Pick the form you meant: ` + + `to run the action ONCE PER record, drop this def and name the action in the view's ` + + `\`bulkActions: ['${def.name}']\` (it is promoted with the action's label, params and ` + + `\`visible\`); to run it ONCE for the whole selection, add \`execution: 'aggregate'\` ` + + `(the handler reads \`params._selectedIds\`). For a mass field update or delete, use ` + + `\`operation: 'update'\` / \`'delete'\` instead.`, + }); + } + + // ── The rest: keys that parse but the executor never reads ──────────── + if (def.execution !== undefined && def.operation !== 'custom') { + ctx.addIssue({ + code: 'custom', + path: ['execution'], + message: + `\`execution\` only applies to \`operation: 'custom'\` — a '${def.operation}' def is a ` + + `data-plane mass mutation, always batched per record. Remove it, or switch the def to ` + + `\`operation: 'custom'\` if you meant to dispatch an action.`, + }); + } + if (def.patch !== undefined && def.operation !== 'update') { + ctx.addIssue({ + code: 'custom', + path: ['patch'], + message: + `\`patch\` only applies to \`operation: 'update'\` — a '${def.operation}' def never ` + + `writes fields, so these values are silently dropped. Use \`operation: 'update'\`, or ` + + `move the constant into the action's own \`bodyExtra\`/\`params\` if this is a custom def.`, + }); + } + if (def.params !== undefined && def.operation === 'delete') { + ctx.addIssue({ + code: 'custom', + path: ['params'], + message: + `\`params\` on a \`delete\` def collects values the executor never reads — a bulk delete ` + + `takes ids only. Remove them, or use \`operation: 'update'\` if the dialog is meant to ` + + `write something.`, + }); + } + if (def.batchSize !== undefined && def.execution === 'aggregate') { + ctx.addIssue({ + code: 'custom', + path: ['batchSize'], + message: + `\`batchSize\` does not apply to an aggregate def — the whole selection goes out in ONE ` + + `call by definition, which is the point of \`execution: 'aggregate'\`. To bound how ` + + `much a single call may carry, use \`maxRecords\`.`, + }); + } + })); +export type BulkActionDef = z.infer; diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index cb3b3052c5..95a63578c7 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -15,6 +15,7 @@ export * from './chart-aggregate'; export * from './i18n.zod'; export * from './responsive.zod'; export * from './app.zod'; +export * from './bulk-action.zod'; export * from './view.zod'; export * from './dashboard.zod'; export * from './report.zod'; diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index d891b9a159..0a50209e4a 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -11,6 +11,7 @@ import { ChartTypeSchema } from './chart.zod'; import { SharingConfigSchema } from './sharing.zod'; import { retiredKey } from '../shared/retired-key'; import { FieldType, SelectOptionSchema } from '../data/field.zod'; +import { BulkActionDefSchema } from './bulk-action.zod'; /** * HTTP Method Enum & HTTP Request Schema @@ -737,16 +738,20 @@ 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). ' - + "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: z.array(BulkActionDefSchema).optional().describe( + '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: ['']` " + + '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}`.', ), /** Performance */ diff --git a/skills/objectstack-data/references/_index.md b/skills/objectstack-data/references/_index.md index 3127aca64e..a2e76fe975 100644 --- a/skills/objectstack-data/references/_index.md +++ b/skills/objectstack-data/references/_index.md @@ -36,6 +36,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) - `node_modules/@objectstack/spec/src/shared/suggestions.zod.ts` — "Did you mean?" Suggestion Utilities - `node_modules/@objectstack/spec/src/ui/action.zod.ts` — Action Parameter Schema +- `node_modules/@objectstack/spec/src/ui/bulk-action.zod.ts` — Bulk Action Schemas - `node_modules/@objectstack/spec/src/ui/chart.zod.ts` — Unified Chart Type Taxonomy - `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Object Schema - `node_modules/@objectstack/spec/src/ui/sharing.zod.ts` — Sharing & Embedding Protocol diff --git a/skills/objectstack-ui/references/_index.md b/skills/objectstack-ui/references/_index.md index 6d6fbd9d3a..f33063235d 100644 --- a/skills/objectstack-ui/references/_index.md +++ b/skills/objectstack-ui/references/_index.md @@ -35,6 +35,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — System Identifier Schema - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) - `node_modules/@objectstack/spec/src/shared/suggestions.zod.ts` — "Did you mean?" Suggestion Utilities +- `node_modules/@objectstack/spec/src/ui/bulk-action.zod.ts` — Bulk Action Schemas - `node_modules/@objectstack/spec/src/ui/i18n.zod.ts` — I18n Object Schema - `node_modules/@objectstack/spec/src/ui/responsive.zod.ts` — Breakpoint Name Enum - `node_modules/@objectstack/spec/src/ui/sharing.zod.ts` — Sharing & Embedding Protocol