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
14 changes: 14 additions & 0 deletions .changeset/22780-bulk-param-promoted-route-guidance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/spec': patch
---

fix(spec): the bulk action param's rejection text names the promoted field-backed route instead of saying the bulk surface has none (#22780)

Clause-②: no

`BulkActionParamSchema` still refuses `field`, and the widget-config keys, on an authored `bulkActionDefs[].params` entry. An authored param reaches the bulk dialog as written, and nothing on that path resolves it against the object's field definitions. What changed is the reason and the remedy in two rejection messages, which said the bulk surface has no field-backed param route at all. The console now has one. An action the object declares, named in a list view's `bulkActions`, is promoted with its params, and a field-backed param on it (`{ field }`, plus `objectOverride`) is resolved through the single-record dialog's resolver when the bulk dialog opens.

- **`field`.** The rejection now offers two fixes. Declare the param inline (`name` + `type`, plus `object` and optionally `labelField` for a picker). Or declare the action on the object with the field-backed param and name it in `bulkActions`. The promoted form runs the action once per selected record.
- **Widget-config keys** (`min`, `max`, `step`, `accept`, `lookupFilters` and the rest of that family). The rejection states what the promoted route carries from the field: `type`, `label`, `options`, `required`, `description` (as the help text), `placeholder`, `defaultValue`, `multiple`, `accept` and `maxSize`, plus a reference field's `reference`, `displayField` and `dependsOn`. Of the refused keys, only `accept` and `maxSize` reach the bulk dialog that way. `lookupFilters`, `lookupColumns`, `lookupPageSize` and `descriptionField` are dropped on the bulk path, and the rest are never read off the field.

Nothing to migrate: the accepted and refused keys are unchanged. Only the text of the two rejection messages, and two source comments, moved.
21 changes: 15 additions & 6 deletions packages/spec/src/ui/bulk-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,13 @@ describe('BulkActionDefSchema — the def shape is typed, not `z.any()`', () =>
}).join('\n');
expect(issues).toContain('min');
expect(issues).toContain('FieldSchema');
// ⛔ The prescription must NOT send the author to the field-backed route:
// the bulk surface has none, so that answer would be confidently wrong.
expect(issues).toContain('no field-backed param route');
// ⛔ The prescription must NOT send this author to the field: a key
// declared there reaches the bulk dialog only through a PROMOTED action
// (`bulkActions`), and of this family only `accept` / `maxSize` make that
// trip. Both are named by subject, so the rejection says which route and
// which keys rather than implying `min` would follow.
expect(issues).toContain('`bulkActions`');
expect(issues).toContain('`accept` / `maxSize`');
});

// ── DELIBERATE OPENNESS — DO NOT "FIX" THIS INTO A STRICT SITE ──────────
Expand Down Expand Up @@ -413,10 +417,15 @@ describe('BulkActionDefSchema — the def shape is typed, not `z.any()`', () =>
expect(paramIssues({ displayField: 'name' })).toContain('`displayField` → `labelField`');
});

it('answers `field` with the route that does not exist rather than a spelling hint', () => {
it('answers `field` with the two routes that do exist rather than a spelling hint', () => {
const issues = paramIssues({ field: 'owner' });
expect(issues).toContain('FIELD-BACKED');
expect(issues).toContain('Declare the param inline');
// Not a rename: `field` has no spelling on an authored bulk param.
expect(issues).not.toContain('`field` →');
// Both remedies, each named by its subject: the inline param's picker
// target, and the view key that promotes a declared action whose
// field-backed param IS resolved against the object's fields.
expect(issues).toContain('`object`');
expect(issues).toContain('`bulkActions');
});

it('sends a param-level `visible` to the DEF, where the predicate is really read', () => {
Expand Down
65 changes: 50 additions & 15 deletions packages/spec/src/ui/bulk-action.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,27 @@ export type BulkActionExecution = z.input<typeof BulkActionExecutionSchema>;
* to name it beside min/max/step, and the sweep found no FORM widget reading it
* at all.
*
* ⚠️ The prescription deliberately refuses the obvious-sounding remedy. There
* is no field-backed param route on the bulk surface — `toBulkParam` never
* consults the object's field definitions — so "declare it on the field and let
* the dialog inherit" would be a confidently wrong answer, the shape this
* campaign has already shipped more than once.
* ⚠️ The prescription refuses the obvious-sounding remedy for all but two of
* these keys. An AUTHORED def's params reach the dialog as written: nothing on
* that path consults the object's field definitions. The bulk surface's
* field-backed route is the PROMOTED def, an action the object declares, named
* in the view's `bulkActions`. Its `{ field }` params are resolved when the
* dialog opens, through the single-record dialog's own resolver (objectui
* `29b99490`: plugin-grid's `resolvePromotedBulkParams` calls
* `resolveActionParams` from `@object-ui/fields`, then renames the result onto
* the bulk vocabulary). Measured on objectui `main` `dca25aff2`, that route
* carries the field's `type`, translated `label`, `options`, `required`,
* `description` (as the help text), `placeholder`, `defaultValue`, `multiple`,
* `accept` and `maxSize`, plus a reference-bearing field's `reference`,
* `displayField` and `dependsOn`. From THIS list only `accept` and `maxSize`
* make the trip. The lookup settings `lookupFilters` / `lookupColumns` /
* `lookupPageSize` / `descriptionField` are resolved and then dropped by the
* bulk adapter, and the rest are never read off the field (`idField` has no
* `FieldSchema` spelling to declare; the resolver's snake `id_field` read is
* dropped with the lookup settings). So
* "declare it on the field and let the dialog inherit" is right for `accept` /
* `maxSize` on a promoted def, and a confidently wrong answer for every other
* key here, the shape this campaign has already shipped more than once.
*/
const BULK_PARAM_WIDGET_CONFIG_KEYS = [
'min', 'max', 'step', 'precision', 'scale', 'rows',
Expand Down Expand Up @@ -193,10 +209,16 @@ export const BulkActionParamSchema = lazySchema(() => strictObject(
},
guidance: {
field:
'`field` declares a FIELD-BACKED param, and the bulk surface has no such route: '
+ '`resolveActionParams` consults the object\'s field definitions for the single-record '
+ 'dialog, `resolveBulkActions`\'s `toBulkParam` never does. Declare the param inline '
+ 'instead — `name` + `type`, plus `object` (and optionally `labelField`) for a picker.',
'`field` declares a FIELD-BACKED param, and an authored `bulkActionDefs[].params` entry '
+ 'has no such route: it reaches the dialog as written, and nothing on this path resolves '
+ 'it against the object\'s field definitions. Two ways to fix it. Declare the param '
+ 'inline: `name` + `type`, plus `object` (and optionally `labelField`) for a picker. Or '
+ 'declare the action on the object with this field-backed param (`{ field }`, plus '
+ '`objectOverride` for another object\'s field) and name it in the view\'s '
+ '`bulkActions: [\'<action_name>\']`. A promoted action\'s params are resolved through '
+ 'the single-record dialog\'s resolver when the bulk dialog opens, so the param inherits '
+ 'the field\'s type, label, options and picker target. The promoted form runs the action '
+ 'once per selected record.',
objectOverride:
'`objectOverride` belongs to a field-backed ACTION param, which names the object owning '
+ 'the referenced field. A bulk param is always inline; the object a picker searches is '
Expand Down Expand Up @@ -232,9 +254,17 @@ export const BulkActionParamSchema = lazySchema(() => strictObject(
+ 'FIELD (`FieldSchema`, `data/field.zod.ts`), not of a bulk action param — this shape '
+ 'declares none of them. Until it was closed they rode through onto the renderer\'s field '
+ 'bag and whichever widget read one honoured it; that door is shut, so the value is '
+ 'refused rather than silently forwarded. ⛔ Declaring the key on the object\'s FIELD does '
+ 'not reach this dialog either: the bulk surface has no field-backed param route. Remove '
+ 'the key, and open an issue if a bulk param genuinely needs it declared here.',
+ 'refused rather than silently forwarded. A key declared on the object\'s FIELD reaches '
+ 'this dialog only through a PROMOTED action: an action the object declares with a '
+ 'field-backed param (`{ field }`), named in the view\'s `bulkActions`. That route '
+ 'carries the field\'s `type`, `label`, `options`, `required`, `description` (as the help '
+ 'text), `placeholder`, `defaultValue`, `multiple`, `accept` and `maxSize`, plus a '
+ 'reference field\'s `reference`, `displayField` and `dependsOn`. ⛔ So of these keys only '
+ '`accept` / `maxSize` arrive that way: the picker settings `lookupFilters` / '
+ '`lookupColumns` / `lookupPageSize` / `descriptionField` are dropped on the bulk path, '
+ 'and the rest are never read off the field. Remove the key, or for `accept` / '
+ '`maxSize` move the param onto a promoted action; open an issue if a bulk param '
+ 'genuinely needs a key declared here.',
}],
history:
'Until this shape was closed, `params[]` was `.passthrough()` — every unknown key rode through '
Expand Down Expand Up @@ -265,9 +295,14 @@ export const BulkActionParamSchema = lazySchema(() => strictObject(
* `FieldSchema.dependsOn` (`data/field.zod.ts`), NOT `ActionParamSchema`,
* which declares no `dependsOn` at all: the single-record dialog reaches the
* key through the FIELD-BACKED route (`resolveActionParams` resolves the
* object's field definitions), which is the very route the bulk surface does
* not have. So one vocabulary, two doors — and on this door the key has to be
* written on the param itself.
* object's field definitions). The bulk surface has that route for a PROMOTED
* def only, an action the object declares, named in the view's `bulkActions`:
* since objectui `29b99490` its field-backed params are resolved by the same
* resolver when the dialog opens, so a reference-bearing field's `dependsOn`
* arrives on the bulk param from the field. An AUTHORED def (this shape) has
* no such route. So one vocabulary, two doors: the FIELD, read by the
* single-record dialog and by a promoted bulk def, and this param, which is
* where the key has to be written on an authored def.
*
* Live on BOTH widget families reachable from the bulk dialog, measured on
* the renderer rather than inferred from this schema:
Expand Down
Loading