Skip to content

Commit 581ca7d

Browse files
fix(spec): the bulk action param rejection text names the promoted field-backed route (#22780) (#22852)
Fixes #22780 Clause-②: no Text only. Four sentences in `packages/spec/src/ui/bulk-action.zod.ts` said the bulk surface has no field-backed param route. Since objectui `29b99490`, a PROMOTED bulk action's field-backed param resolves through the single-record dialog's resolver, and the console pin now carries that commit. Each sentence now names the promoted route. The authored `bulkActionDefs[].params` refusal stays; only its reason and remedy change. No schema shape, accept set or export moves. Implemented by `os-dev` for the `domain:spec` seat 2, PM session `session_016njDy8ozy9B9Ns5Y8kAWEK`, branch `claude/issue-22780-bulk-promoted-route-text`. ## What changed 1. **`BulkActionParamSchema` `guidance.field`** (shipped in every `field` rejection). The reason is now "an authored `bulkActionDefs[].params` entry has no such route: it reaches the dialog as written". The remedy offers two fixes. Declare the param inline (`name` + `type`, plus `object` / `labelField` for a picker). Or declare the action on the object with the field-backed param (`{ field }`, plus `objectOverride`) and name it in the view's `bulkActions`. It also says the promoted form runs the action once per selected record, because that changes what the button does. 2. **The `BULK_PARAM_WIDGET_CONFIG_KEYS` prescription** (shipped in every widget-config rejection). The "does not reach this dialog either" clause is replaced. A key declared on the FIELD reaches the bulk dialog only through a promoted action. The prescription names what that route carries, and says that of the refused keys only `accept` / `maxSize` arrive that way. 3. **The JSDoc above `BULK_PARAM_WIDGET_CONFIG_KEYS`** says the same, with the measurement it rests on. 4. **The `dependsOn` JSDoc** now reads "one vocabulary, two doors". The FIELD is read by the single-record dialog and by a promoted bulk def. The param is where an authored def writes the key. ## What the promoted route carries, re-measured on objectui `main` `dca25aff2` These are read from source: plugin-grid `resolvePromotedBulkParams` / `toBulkParam`, and `@object-ui/fields` `resolveActionParam`. - **Carried from the field:** `type`, translated `label`, `options` (with translated option labels), `required`, `description` (as the help text; the resolver reads `field.help ?? field.description`), `placeholder`, `defaultValue`, `multiple`, `accept`, `maxSize`. For a reference-bearing field (`EXPANDABLE_FIELD_TYPES`) it also carries `reference` (as `object`), `displayField` (as `labelField`) and `dependsOn`. - **Resolved, then dropped by `toBulkParam`:** `lookupFilters`, `lookupColumns`, `lookupPageSize` and `descriptionField`, plus the snake-only `id_field` / `title_format` reads. - **Never read off the field:** the rest of the widget-config list (`min`, `max`, `step`, `precision`, `scale`, `rows`, `crop`, `capture`, `dimensions`, `defaultName`, `allowCreate`, `picker`, `subtitle`, `avatarField`). - **`idField`** is not named as "dropped" in the shipped text. `FieldSchema` refuses `idField` (`unrecognized_keys`, measured), so no field can declare it. - **Unchanged since `29b99490`:** `git log 29b99490..origin/main` over `resolveActionParams.ts`, `resolveBulkActions.ts` and `bulkParamToField.ts` lists no commit. The two commits since then that touch `ObjectGrid.tsx` (`752504520`, `69bca0af6`) change the write census and the paged footer's fan-out query, not the param path; `BulkActionDialog.tsx` is unchanged. This agrees with the card's list and objectui#12119's contract review. It adds `required`, `description`, `placeholder` and `multiple`, which the resolver also inherits. ## Premise checks - **Console pin:** `.objectui-sha` = `0df67f237c`. `git merge-base --is-ancestor 29b99490 0df67f237c` exits 0 in `../objectui`. An exit 0 proves itself, so no control leg is owed. - **Phrase family:** I grepped `packages/spec`, `content/docs` and `skills`. Two other places carry the "no field-backed route" claim. Neither is edited here (see Acceptance notes). `content/docs` and `skills/**` carry no hit. - **Generated artifacts:** none of the four sentences reaches a generated artifact. `check:generated` reports all 14 up to date, and `check:docs` reports 226 generated files in sync. No reference page moves. ## Pins The two cases in `bulk-action.test.ts` that read these strings are re-pointed at named subjects instead of prose: - the widget-config case asserts `` `bulkActions` `` and `` `accept` / `maxSize` `` (it pinned `'no field-backed param route'`); - the `field` case asserts no rename arrow, plus `` `object` `` and `` `bulkActions ``; it pinned `'FIELD-BACKED'` and `'Declare the param inline'`. **Reverse verification.** The fix was committed first. The base text of `bulk-action.zod.ts` was then restored into the tree with `git restore --source=1eff3224d7` (blob `281e495c`), and the file run went red: `Tests 2 failed | 37 passed (39)`, exactly the two re-pointed cases. Restored with `git checkout HEAD --`: blob `2e798a0b` equals the HEAD blob, and `git status` is clean. At HEAD: `Tests 39 passed (39)`. ## Tests and gates Taken at `cbce5d7831`, which is `origin/main` `31b5a5f7f5` merged in. Since then `main` has moved by two commits (metadata-protocol, `scripts/pm/os-regen-merge.sh`, one docs page). Neither touches `packages/spec` or these files. - `pnpm --filter @objectstack/spec build`: exit 0. - `pnpm --filter @objectstack/spec typecheck`: exit 0, including `check:test-typecheck` OK. - `pnpm --filter @objectstack/spec test`: `Test Files 648 passed (648)`, `Tests 19342 passed | 1 todo`. - `pnpm --filter @objectstack/spec check:generated`: "All 14 generated artifacts are up to date". - **`dispatch-gates --commands --repo objectstack-ai/objectstack`:** 85 commands derived, all run with exit codes recorded. `--ran` reconciliation: "85 derived famil(ies) accounted for — 84 run, 1 NOT-MEASURED". - Two gates first refused on an unbuilt prerequisite and went green once those packages were built: `check:doc-formula-expressions` (`@objectstack/formula` / `@objectstack/lint`) and `check:lean-entry-closure` (`@objectstack/objectql`). - NOT MEASURED: `check:dual-build-cjs-loads`. Reason: it reads every package's `dist/` (88 unbuilt), a whole-repo build left to CI. - **Lint, narrowed to the two changed `.ts` files:** - Population: `eslint.config.mjs` `files: ['**/*.{ts,…}']` and `['packages/**/*.{ts,…}']`; neither file was reported ignored. - Count: `--no-inline-config --format json` gave 2 results, 0 errors, 0 warnings. - Invariance: the config sets no `parserOptions.project` / `projectService`, so no type-aware linting. `--print-config` shows `parserOptions` = `{ ecmaVersion, sourceType }`. This diff cannot change a verdict on an untouched file. Repo-wide `pnpm lint` is CI's. ## Acceptance notes (noted, not filed) - **The same claim in the major-18 migration entry.** `packages/spec/src/migrations/entries/semantic/18.ui-bulk-action-param-unknown-keys-refused.ts` (`reason`, and the `replacement` clause "field-backed ACTION-param contracts the bulk surface does not implement") still says "the bulk surface has no field-backed param route". The text is generated into `registry.ts` and printed by `objectstack migrate meta`. It is not edited here, for two reasons: it is outside this card's claimed file surface, and it brings its own gate family (`gen:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, and the cli's `migrate-meta-engine-guidance` test). It is named for the seat in the report. Its conclusion for the widget-config keys still holds, except `accept` / `maxSize` on a promoted def. - **`packages/spec/CHANGELOG.md` (17.5.0 entry)** carries the same sentence. That file is release-owned, and the sentence was true when it shipped, so nothing is owed there. - **Read from source, not pinned in objectui:** an authored `execution: 'aggregate'` def that names a declared action and authors no `params` gets that action attached, and `resolvePromotedBulkParams` then resolves the action's field-backed params too. The shipped text names only the `bulkActions` route, the one objectui#12119 pins. - **The objectui-side remainder is unchanged:** forwarding the lookup settings on the bulk path. The prescription now states the gap instead of implying it. - **Two other comments in the file are still true and are not edited:** the module header's "converter for the PROMOTED direction (`toBulkParam`)" and the alias comment "`toBulkParam` maps the same three". --- _Generated by [Claude Code](https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0e0678c commit 581ca7d

3 files changed

Lines changed: 79 additions & 21 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the bulk action param's rejection text names the promoted field-backed route instead of saying the bulk surface has none (#22780)
6+
7+
Clause-②: no
8+
9+
`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.
10+
11+
- **`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.
12+
- **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.
13+
14+
Nothing to migrate: the accepted and refused keys are unchanged. Only the text of the two rejection messages, and two source comments, moved.

‎packages/spec/src/ui/bulk-action.test.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,13 @@ describe('BulkActionDefSchema — the def shape is typed, not `z.any()`', () =>
7575
}).join('\n');
7676
expect(issues).toContain('min');
7777
expect(issues).toContain('FieldSchema');
78-
// ⛔ The prescription must NOT send the author to the field-backed route:
79-
// the bulk surface has none, so that answer would be confidently wrong.
80-
expect(issues).toContain('no field-backed param route');
78+
// ⛔ The prescription must NOT send this author to the field: a key
79+
// declared there reaches the bulk dialog only through a PROMOTED action
80+
// (`bulkActions`), and of this family only `accept` / `maxSize` make that
81+
// trip. Both are named by subject, so the rejection says which route and
82+
// which keys rather than implying `min` would follow.
83+
expect(issues).toContain('`bulkActions`');
84+
expect(issues).toContain('`accept` / `maxSize`');
8185
});
8286

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

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

422431
it('sends a param-level `visible` to the DEF, where the predicate is really read', () => {

‎packages/spec/src/ui/bulk-action.zod.ts‎

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,27 @@ export type BulkActionExecution = z.input<typeof BulkActionExecutionSchema>;
147147
* to name it beside min/max/step, and the sweep found no FORM widget reading it
148148
* at all.
149149
*
150-
* ⚠️ The prescription deliberately refuses the obvious-sounding remedy. There
151-
* is no field-backed param route on the bulk surface — `toBulkParam` never
152-
* consults the object's field definitions — so "declare it on the field and let
153-
* the dialog inherit" would be a confidently wrong answer, the shape this
154-
* campaign has already shipped more than once.
150+
* ⚠️ The prescription refuses the obvious-sounding remedy for all but two of
151+
* these keys. An AUTHORED def's params reach the dialog as written: nothing on
152+
* that path consults the object's field definitions. The bulk surface's
153+
* field-backed route is the PROMOTED def, an action the object declares, named
154+
* in the view's `bulkActions`. Its `{ field }` params are resolved when the
155+
* dialog opens, through the single-record dialog's own resolver (objectui
156+
* `29b99490`: plugin-grid's `resolvePromotedBulkParams` calls
157+
* `resolveActionParams` from `@object-ui/fields`, then renames the result onto
158+
* the bulk vocabulary). Measured on objectui `main` `dca25aff2`, that route
159+
* carries the field's `type`, translated `label`, `options`, `required`,
160+
* `description` (as the help text), `placeholder`, `defaultValue`, `multiple`,
161+
* `accept` and `maxSize`, plus a reference-bearing field's `reference`,
162+
* `displayField` and `dependsOn`. From THIS list only `accept` and `maxSize`
163+
* make the trip. The lookup settings `lookupFilters` / `lookupColumns` /
164+
* `lookupPageSize` / `descriptionField` are resolved and then dropped by the
165+
* bulk adapter, and the rest are never read off the field (`idField` has no
166+
* `FieldSchema` spelling to declare; the resolver's snake `id_field` read is
167+
* dropped with the lookup settings). So
168+
* "declare it on the field and let the dialog inherit" is right for `accept` /
169+
* `maxSize` on a promoted def, and a confidently wrong answer for every other
170+
* key here, the shape this campaign has already shipped more than once.
155171
*/
156172
const BULK_PARAM_WIDGET_CONFIG_KEYS = [
157173
'min', 'max', 'step', 'precision', 'scale', 'rows',
@@ -193,10 +209,16 @@ export const BulkActionParamSchema = lazySchema(() => strictObject(
193209
},
194210
guidance: {
195211
field:
196-
'`field` declares a FIELD-BACKED param, and the bulk surface has no such route: '
197-
+ '`resolveActionParams` consults the object\'s field definitions for the single-record '
198-
+ 'dialog, `resolveBulkActions`\'s `toBulkParam` never does. Declare the param inline '
199-
+ 'instead — `name` + `type`, plus `object` (and optionally `labelField`) for a picker.',
212+
'`field` declares a FIELD-BACKED param, and an authored `bulkActionDefs[].params` entry '
213+
+ 'has no such route: it reaches the dialog as written, and nothing on this path resolves '
214+
+ 'it against the object\'s field definitions. Two ways to fix it. Declare the param '
215+
+ 'inline: `name` + `type`, plus `object` (and optionally `labelField`) for a picker. Or '
216+
+ 'declare the action on the object with this field-backed param (`{ field }`, plus '
217+
+ '`objectOverride` for another object\'s field) and name it in the view\'s '
218+
+ '`bulkActions: [\'<action_name>\']`. A promoted action\'s params are resolved through '
219+
+ 'the single-record dialog\'s resolver when the bulk dialog opens, so the param inherits '
220+
+ 'the field\'s type, label, options and picker target. The promoted form runs the action '
221+
+ 'once per selected record.',
200222
objectOverride:
201223
'`objectOverride` belongs to a field-backed ACTION param, which names the object owning '
202224
+ 'the referenced field. A bulk param is always inline; the object a picker searches is '
@@ -232,9 +254,17 @@ export const BulkActionParamSchema = lazySchema(() => strictObject(
232254
+ 'FIELD (`FieldSchema`, `data/field.zod.ts`), not of a bulk action param — this shape '
233255
+ 'declares none of them. Until it was closed they rode through onto the renderer\'s field '
234256
+ 'bag and whichever widget read one honoured it; that door is shut, so the value is '
235-
+ 'refused rather than silently forwarded. ⛔ Declaring the key on the object\'s FIELD does '
236-
+ 'not reach this dialog either: the bulk surface has no field-backed param route. Remove '
237-
+ 'the key, and open an issue if a bulk param genuinely needs it declared here.',
257+
+ 'refused rather than silently forwarded. A key declared on the object\'s FIELD reaches '
258+
+ 'this dialog only through a PROMOTED action: an action the object declares with a '
259+
+ 'field-backed param (`{ field }`), named in the view\'s `bulkActions`. That route '
260+
+ 'carries the field\'s `type`, `label`, `options`, `required`, `description` (as the help '
261+
+ 'text), `placeholder`, `defaultValue`, `multiple`, `accept` and `maxSize`, plus a '
262+
+ 'reference field\'s `reference`, `displayField` and `dependsOn`. ⛔ So of these keys only '
263+
+ '`accept` / `maxSize` arrive that way: the picker settings `lookupFilters` / '
264+
+ '`lookupColumns` / `lookupPageSize` / `descriptionField` are dropped on the bulk path, '
265+
+ 'and the rest are never read off the field. Remove the key, or for `accept` / '
266+
+ '`maxSize` move the param onto a promoted action; open an issue if a bulk param '
267+
+ 'genuinely needs a key declared here.',
238268
}],
239269
history:
240270
'Until this shape was closed, `params[]` was `.passthrough()` — every unknown key rode through '
@@ -265,9 +295,14 @@ export const BulkActionParamSchema = lazySchema(() => strictObject(
265295
* `FieldSchema.dependsOn` (`data/field.zod.ts`), NOT `ActionParamSchema`,
266296
* which declares no `dependsOn` at all: the single-record dialog reaches the
267297
* key through the FIELD-BACKED route (`resolveActionParams` resolves the
268-
* object's field definitions), which is the very route the bulk surface does
269-
* not have. So one vocabulary, two doors — and on this door the key has to be
270-
* written on the param itself.
298+
* object's field definitions). The bulk surface has that route for a PROMOTED
299+
* def only, an action the object declares, named in the view's `bulkActions`:
300+
* since objectui `29b99490` its field-backed params are resolved by the same
301+
* resolver when the dialog opens, so a reference-bearing field's `dependsOn`
302+
* arrives on the bulk param from the field. An AUTHORED def (this shape) has
303+
* no such route. So one vocabulary, two doors: the FIELD, read by the
304+
* single-record dialog and by a promoted bulk def, and this param, which is
305+
* where the key has to be written on an authored def.
271306
*
272307
* Live on BOTH widget families reachable from the bulk dialog, measured on
273308
* the renderer rather than inferred from this schema:

0 commit comments

Comments
 (0)