Skip to content

Commit f273c9f

Browse files
fix(plugin-grid)!: a list's selection bar and row menu draw only actions the object declares (#30169) (#30387)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgMHYZ6ACwf4zMmVF6bRhu81)_ domain:ui#2 · os-bill · session_01Utp6Cb5R6JDdfyvtj3dPR2 Fixes #30169 Clause-②: no (narrowing) — the grid stops drawing a bare `bulkActions` or legacy `rowActions` name that matches no action the object declares. `packages/spec/src/ui/bulk-action.zod.ts` already says the member "names an action the object declares", and lint `action-name-undefined` (ADR-0049) refuses any other at authoring. Owes a `CONTRACT_REVIEW_TIER` review before the queue. Metadata-protocol: narrowed (an undeclared bare `bulkActions` / `rowActions` name no longer draws) Skills-docs: none owed — `skills/**` teaches no handler-only bulk or row action name (searched `skills/` for `bulkActions` / `rowActions` examples and runner-handler text: no hit); `content/docs/ui/**` already names only declared actions. ## 维护者速读 - 改了什么:列表的选择栏和行菜单只画对象声明过的操作。视图里写了对象没声明的裸名字(拼错的,或被禁用模块扣下的操作),不再画按钮或菜单项,开发态在控制台打一行警告点名。 - 为什么改:HotCRM 在 18.3.0 上关掉营销模块后,勾选线索仍出现「Create Campaign」幽灵按钮,点了只弹「不可执行」。按裁决 C,渲染器回到 spec 原文「指对象声明的操作」,不再容忍只靠前端接管的名字。 - 风险与回滚:只动 plugin-grid(两个解析函数、ObjectGrid、行操作列、新增一个警告模块)与其测试、demo、README,不动 spec 和服务端。若有仓外宿主靠前端接管一个未声明的名字,它的按钮会消失(仓内实测无此用法)。回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-grid/src/ObjectGrid.tsx`(`effectiveBulkActions` 不再并入未声明名字、两次警告调用)→ `ui/packages/plugin-grid/src/undeclaredActionNames.ts` → 测试 `objectBulkActionDispatch.test.tsx` 新 describe「a bulk action a disabled package withholds」。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-grid/src/__tests__/objectBulkActionDispatch.test.tsx`,预期全部通过;其中「withheld」用例在旧逻辑下转红(选择栏画出了被扣下的按钮)。 - 需要你做的:无,席位落地。 ## What changes `resolveBulkActions` and `resolveLegacyRowActions` kept a bare name that matched no action in `objectDef.actions`, and `ObjectGrid` drew it by humanized name and dispatched it to the runner by name, on the reasoning that a host might have registered a runner handler under it. That is how a list whose app has a package disabled kept a ghost button: the server withholds the package's actions from the served object, so their names match nothing, and the click toasted `Action "create_campaign" is not executable`. Ruling 6651753071 (option C) retires the tolerance. - **`resolveBulkActions` / `resolveLegacyRowActions`.** A name with no declared match produces no def. It comes back as `undeclared` (renamed from `unresolved`; neither function is a package export), used only for the warning below. Both module headers now state the rule and its source (the spec sentence and `action-name-undefined`), replacing the "that path must keep working" paragraph. - **`ObjectGrid`.** The selection bar's string list is now only the canonical `'delete'` (author-declared or the implicit bulk delete), unchanged in its routing (`onBulkDelete`, or the runner by name when no `onBulkDelete` is wired). The row menu no longer receives a by-name list: `objectGridActionsColumn.tsx` drops the `customRowActions` input, so the Actions column and the kebab read only resolved defs plus the canonical Edit / Delete. - **`undeclaredActionNames.ts` (new).** `describeUndeclaredActionNames` builds one message per fold naming the key, the block address and every dropped name; `useUndeclaredActionNamesWarning` logs it once per distinct list, only when `NODE_ENV` is not `production`, and only once the object's schema has loaded (or the grid names no object), so a list still loading its object is never reported. `ObjectGrid` calls it twice, for `bulkActions` and `rowActions`, in hook order before the early returns. - **Demo / README.** `demo/bulk-actions.tsx` swaps `legacy_only_handler` (a handler-only name) for `not_declared`, drops the handler, and says the bar draws no button for it. `README.md` replaces `bulkActions: ['delete', 'export']` and `rowActions: ['view', …]` (`export` and `view` are handler-only) with a declared `deactivate`, and states the rule under *Row Actions*. Unchanged: a name that matches a declared action is promoted exactly as before (label, icon, params, `visible`, `requiredPermissions`); `bulkActionDefs` are untouched; `delete` keeps its own path (`keptDeleteAction` / `onBulkDelete`). **Consumers measured** (this tree, every non-test `bulkActions` / `batchActions` / `rowActions` list under `examples/`, `ui/apps/`, `ui/packages/`, `packages/`): `examples/app-showcase` (`task.view.ts`, `field-zoo.view.ts`) names only actions declared in `src/ui/actions/index.ts`; `packages/lint` fixtures mirror the showcase. The one handler-only name was the plugin-grid demo, changed here. The Console's runner handlers (`useObjectActions.ts`: `create`, `delete`, `navigate`, `refresh`; `AppContent.tsx`: `crud_success`, `dialog_cancel`, `navigate_create`, `navigate_edit`) are action TYPES for other surfaces, not bulk or row names. No fallback is kept for an out-of-repo host; its remedy is in the changeset's FROM → TO. ## Tests Pins of the old tolerance, each flipped to the new behaviour with the trigger named in the test: - `bulkActionMembers-8071.test.tsx` row 3: `bulkActions: ['approve', 'no_such_action']` draws exactly `bulk-action-approve`, no "No Such Action", and one `ObjectGrid bulkActions` warning naming `'no_such_action'`. - `objectBulkActionDispatch.test.tsx` ("keeps an unresolvable name alongside the promoted defs" → "draws no button for a name the object does not declare, even with a runner handler under it"): a handler registered under `crm_only_handler` neither draws the button nor runs. - `legacyRowActionDispatch.test.tsx` ("leaves a name with no declared action to the by-name handler path" → "draws no entry … and warns once naming it"): the declared `convert_lead` entry draws, `crm_only_handler` does not, one `ObjectGrid rowActions` warning. - `resolveBulkActions.test.ts`, `resolveLegacyRowActions.test.ts`: the by-name cases flip to "no def, reported as undeclared"; a new row pins that a repeated undeclared row name is reported once. Trigger test (new top-level describe in `objectBulkActionDispatch.test.tsx`, the shared harness): the view names `push_down` and `create_campaign`. **Withheld** (the served object lacks `create_campaign`): no `bulk-action-create_campaign`, no "Create Campaign", one warning naming it and not `push_down`. **Served**: the button reads the action's own label "Add to Campaign", and no warning. Fixtures that used an undeclared name as a stand-in, repaired (behaviour under test unchanged): - `bulkActionsSpecKey.test.tsx`, `rowCrudEffectiveOps.test.tsx`, `gridPagerSelectionMembers-8071.test.tsx`, `actionsColumnAndChips-12708.test.tsx`: the object now declares the named action (`approve` / `reject`, `notify`, `approve`, `archive`). - `bulkActionRefresh.test.tsx`: the by-name dispatch path these rows pin (refresh and selection reset after a string action) is now reached by the canonical `'delete'` with no `onBulkDelete`, the handler registered under `delete`. Runs (all `pnpm --dir ui exec vitest run --maxWorkers=2 …` under `scripts/pm/os-verify-lock.sh`): - Base `9afcd6a6`, the five resolver / dispatch files before any edit: `Test Files 5 passed (5)`, `Tests 54 passed (54)` (the old pins green, encoding the tolerance). - Whole `packages/plugin-grid/` after the source change, before the test changes: `Test Files 10 failed | 267 passed (277)`, `Tests 27 failed | 2350 passed (2377)`, the 27 being exactly the pins and fixtures listed above. - The 11 changed / harness files after: `Test Files 11 passed (11)`, `Tests 124 passed (124)`. - Consumers outside plugin-grid that assert bulk or row action test ids or author those keys (app-shell 7, plugin-list 5, plugin-view 5, plugin-detail 2, console 1, ui scripts 1) plus three more plugin-grid harnesses: `Test Files 14 passed (14)`, `Tests 542 passed (542)` and `Test Files 10 passed (10)`, `Tests 237 passed (237)`. Ablation, two legs through `scripts/ablation-replace.mjs` (tests import `src`, no build involved), each landing verified and restored to the HEAD blob with `git diff HEAD` empty: 1. `effectiveBulkActions` re-includes the undeclared names (`? keptDeleteAction` → `? [...undeclaredBulkActions, ...keptDeleteAction]`): `Tests 3 failed | 17 passed (20)` — 8071 row 3, the withheld trigger, the handler-name row. 2. The warning's `console.warn` disabled: `Tests 3 failed | 24 passed (27)` — 8071 row 3, the row-menu warning row, the withheld trigger. Type-check: `pnpm exec turbo run type-check --filter=@object-ui/plugin-grid --concurrency=2` (`tsc --noEmit && tsc -p tsconfig.test.json`): `Tasks: 53 successful, 53 total`. Lint: `eslint` on every touched plugin-grid file, 0 errors (warnings are pre-existing `no-explicit-any` in the touched files; the new module has none). ## Acceptance notes - `ui/packages/plugin-list/src/ListView.tsx` draws its own bulk bar for non-grid views (`currentView !== 'grid'`, the `bulk-actions-bar` region mapping `permittedBulkActions`), and it renders every bare name by label with no resolution against the object's actions. Under this ruling's rule ("the selection bar draws only actions the object declares") that bar still draws an undeclared or withheld name. Read-only inference, not reproduced in a browser; outside this card's file surface, so not touched. - `ui/AGENTS.md` #2 (docs-driven) also names the matching page under `content/docs/frontend/`; that tree is outside the claimed surface. `content/docs/ui/actions.mdx` and `content/docs/ui/views.mdx` already show only declared names. ## Verification <details><summary>Derived gates (dispatch-gates --commands on the final head 2f621a5): 36 run, all exit 0</summary> - `node scripts/check-adr-0087-registration.mjs --base origin/main` :: exit 0 - `node scripts/check-adr-0087-registration.mjs --self-test` :: exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-corpus.mjs` :: exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main` :: exit 0 - `node scripts/check-empty-changeset.mjs --self-test` :: exit 0 - `node scripts/check-sdui-manifest.mjs` :: exit 0 - `node scripts/check-sdui-manifest.mjs --self-test` :: exit 0 - `node scripts/pm/release-rehearsal-clone.mjs --self-test` :: exit 0 - `node scripts/release-pending-publish.mjs --self-test` :: exit 0 - `pnpm --dir ui exec vitest run scripts/` :: exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` :: exit 0 - `pnpm check:changeset-gate-self-tests` :: exit 0 - `pnpm check:closed-ceremony-text` :: exit 0 - `pnpm check:cross-package-test-inputs` :: exit 0 - `pnpm check:dispatcher-error-vocabulary` :: exit 0 - `pnpm check:driver-memory-census` :: exit 0 - `pnpm check:engine-double-contract` :: exit 0 - `pnpm check:error-status-conformance` :: exit 0 - `pnpm check:gitlink-declared` :: exit 0 - `pnpm check:nul-bytes` :: exit 0 - `pnpm check:published-files` :: exit 0 - `pnpm check:query-options-erasure` :: exit 0 - `pnpm check:refd-timer-probe` :: exit 0 - `pnpm check:registry-log-declared` :: exit 0 - `pnpm check:rest-log-spy-declared` :: exit 0 - `pnpm check:skill-identifier-liveness` :: exit 0 - `pnpm check:test-source-alias` :: exit 0 - `pnpm check:tier-file-adoption` :: exit 0 - `pnpm check:type-check-coverage` :: exit 0 - `pnpm check:undeclared-dep-imports` :: exit 0 - `pnpm check:watch-hint-literal` :: exit 0 - `pnpm check:where-matcher` :: exit 0 - `pnpm check:type-check-debt` :: exit 0 `pnpm check:type-check-debt` was re-run alone under the verify lock (`VERDICT command-exit 0`); its first run in the loop exited 3 queued behind this card's own dogfood hold. `node scripts/pm/dispatch-gates.mjs --ran`: `36 derived famil(ies) accounted for — 36 run, 0 NOT-MEASURED`. </details> <details><summary>Standing ui gates</summary> - `node scripts/check-sdui-manifest.mjs` (and `--self-test`): in the derived list above, exit 0. - `pnpm --dir ui check:new-line-citations`: `VERDICT new-cross-file-line-citations: 0 new citation(s), enforcement report-only -> exit 0`. </details> Dogfood battery (final head, `(narrowing)` arm): `pnpm --filter @objectstack/dogfood test` under the verify lock on `899d981b` (closure built first): `Test Files 419 passed | 3 skipped (422)`, `Tests 3492 passed | 36 skipped (3528)`, `VERDICT command-exit 0`. The final head `2f621a58` differs from `899d981b` only in the changeset's remedy paragraph. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZ1rxZPzain2p5bv5x)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d99cd3b commit f273c9f

19 files changed

Lines changed: 654 additions & 133 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/plugin-grid': minor
3+
---
4+
5+
fix(plugin-grid)!: a list's selection bar and row menu draw only actions the object declares; an undeclared bare `bulkActions` / `rowActions` name draws nothing and warns once in development (#30169)
6+
7+
Clause-②: no (narrowing) — the grid stops drawing a bare `bulkActions` or legacy `rowActions` name that matches no action the object declares, which it drew before by humanized name. `packages/spec/src/ui/bulk-action.zod.ts` already says the member "names an action the object declares", and lint `action-name-undefined` (ADR-0049) refuses any other at authoring. Nothing is widened.
8+
Metadata-protocol: narrowed
9+
Skills-docs: no move — `skills/**` teaches no handler-only bulk or row action name (searched for `bulkActions` / `rowActions` examples and runner-handler text); `content/docs/ui/**` already names only declared actions in its `bulkActions` / `rowActions` examples.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema, export or stored row changes shape: `bulkActions` and `rowActions` keep their `string[]` form and meaning. What narrows is the renderer's draw set, back to the spec's own sentence. Whether an undeclared name was a misspelling, an action still to declare or a handler the host meant to keep is a judgement about the view, so no rewrite derives the fix; the development warning names each name. -->
12+
13+
**BREAKING** for a host that registered a runner handler under a bare name the object does not declare (`ActionProvider handlers` / `runner.registerHandler('NAME', …)`) and listed that name in a view's `bulkActions` or `rowActions`.
14+
15+
- **Before.** `resolveBulkActions` and `resolveLegacyRowActions` kept a name that matched no declared action, and `ObjectGrid` drew it by humanized name and dispatched it to the runner by name. A name nothing handled toasted "not executable" on click. This is how a list whose app had a package disabled kept a ghost button: the server withholds that package's actions from the served object, so their names matched nothing.
16+
- **Now.** Such a name gets no selection-bar button and no row-menu entry, and in development the console logs one warning per list naming the key, the block and each name. The resolvers return those names as `undeclared` (renamed from `unresolved`), used only for that warning. The canonical `'delete'` (and the row menu's `'edit'` / `'delete'`) keep their own paths, unchanged.
17+
- **Unchanged.** A name that matches a declared action is promoted to that action's def exactly as before, under the action's own label, icon, params, `visible` and `requiredPermissions`.
18+
19+
**Remedy.** No rewrite is derived, because which fix an undeclared name needs is a judgement about the view: declare the named action on the object, or remove the name from `bulkActions` / `rowActions`. The development warning names each one. An action withheld by a disabled package needs nothing: it draws again when the package is enabled.

‎ui/packages/plugin-grid/README.md‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,8 @@ const schema: ObjectGridBlockNode = {
549549
objectName: 'users',
550550
columns: ['name', 'email'],
551551
selection: { type: 'multiple' },
552-
bulkActions: ['delete', 'export'],
552+
// `deactivate` is an action the `users` object declares.
553+
bulkActions: ['delete', 'deactivate'],
553554
} satisfies ObjectGridProps,
554555
};
555556
```
@@ -757,6 +758,13 @@ const schema: ObjectGridBlockNode = {
757758
themselves live in the object's action set, so the same action behaves identically
758759
wherever it is offered. They are `string[]`, not inline definitions with callbacks.
759760

761+
Each name must name an action the object declares; `'edit'` and `'delete'` are the
762+
built-in exceptions. A name the object does not declare draws nothing: no row-menu
763+
entry and no selection-bar button, and in development the console logs one warning
764+
naming it. That includes a misspelling and an action a disabled package withholds,
765+
which the server leaves out of the object. A runner handler registered under a bare
766+
name is not a declaration and does not bring the entry back.
767+
760768
```typescript
761769
import type { ObjectGridBlockNode } from '@object-ui/types';
762770
import type { ObjectGridProps } from '@objectstack/spec/ui';
@@ -766,9 +774,10 @@ const schema: ObjectGridBlockNode = {
766774
properties: {
767775
objectName: 'users',
768776
columns: ['name', 'email'],
769-
rowActions: ['view', 'edit', 'delete'],
777+
// `deactivate` is an action the `users` object declares.
778+
rowActions: ['deactivate', 'edit', 'delete'],
770779
selection: { type: 'multiple' },
771-
bulkActions: ['delete', 'export'],
780+
bulkActions: ['delete', 'deactivate'],
772781
} satisfies ObjectGridProps,
773782
};
774783
```

‎ui/packages/plugin-grid/demo/bulk-actions.tsx‎

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616
* action (spec 17 retired `action.bulkEnabled` as a tombstone). Both
1717
* names used to dispatch as `{ type: '<name>' }` and never run; the
1818
* buttons also carried none of the def's label / icon / params.
19-
* 2. `legacy_only_handler` resolves to no declared action and stays a
20-
* by-name dispatch — it must still render ALONGSIDE the two defs above.
19+
* 2. `not_declared` names no action the object declares, so the bar draws
20+
* NO button for it (the spec: a bare name "names an action the object
21+
* declares"), and the browser console logs one warning naming it. An
22+
* action a disabled package withholds reads the same way: it leaves the
23+
* served object, so its name draws nothing (objectstack#30169).
2124
* 3. Record `t3` fails server-side (422), proving per-record attribution: the
2225
* result panel reports 4/5 with an error row rather than a blanket success.
2326
* 4. Rich `bulkActionDefs` with picker params (#3064 / ADR-0059): `Set
@@ -237,8 +240,9 @@ const schema: any = {
237240
{ field: 'progress', label: 'Progress' },
238241
],
239242
pagination: { pageSize: 50 },
240-
// Bare names: two resolve to declared actions, one doesn't.
241-
bulkActions: ['bulk_mark_done', 'bulk_recalc_estimate', 'legacy_only_handler'],
243+
// Bare names: two resolve to declared actions; `not_declared` names nothing
244+
// the object declares, so it draws no button and warns once in the console.
245+
bulkActions: ['bulk_mark_done', 'bulk_recalc_estimate', 'not_declared'],
242246
// Rich defs with picker params — the shared-field-widget surface (#3064).
243247
bulkActionDefs: [
244248
// Aggregate mode (#3139): names the declared action, ONE dispatch for the
@@ -285,8 +289,9 @@ function Demo() {
285289
Select rows → the bar shows <b>Mark Done</b> and <b>Recalculate Estimate</b> (names
286290
from the view&apos;s <code>bulkActions</code>, promoted to their declared object
287291
actions), <b>Export Zip</b> (an <code>execution: &apos;aggregate&apos;</code> def —
288-
ONE dispatch carrying every selected id, #3139) and <b>Legacy Only
289-
Handler</b> (unresolvable, dispatched by name).
292+
ONE dispatch carrying every selected id, #3139). The view also names{' '}
293+
<code>not_declared</code>, which the object does not declare: no button, and one
294+
warning in the browser console.
290295
</p>
291296
</header>
292297

@@ -324,16 +329,7 @@ function Demo() {
324329

325330
createRoot(document.getElementById('root')!).render(
326331
<I18nProvider resources={{ en }} language="en">
327-
{/* A handler registered under a bare NAME — the compat path that legacy
328-
`bulkActions` entries must keep working for. */}
329-
<ActionProvider
330-
handlers={{
331-
legacy_only_handler: async () => {
332-
pushLog({ url: '(runner handler)', recordId: 'all-selected', params: '{}', status: 200 });
333-
return { success: true };
334-
},
335-
}}
336-
>
332+
<ActionProvider>
337333
<Demo />
338334
</ActionProvider>
339335
</I18nProvider>,

‎ui/packages/plugin-grid/src/ObjectGrid.tsx‎

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import { resolveRowCrudAffordances, resolveRowRecordCrudAffordance } from './row
7373
import { useRecordCrudVerdicts } from './hooks/useRecordCrudVerdicts';
7474
import { resolveLegacyRowActions } from './resolveLegacyRowActions';
7575
import { resolveBulkActions, resolvePromotedBulkParams } from './resolveBulkActions';
76+
import { useUndeclaredActionNamesWarning } from './undeclaredActionNames';
7677
import { partitionBulkRows } from './bulkEligibility';
7778
import { RowActionMenu, formatActionLabel } from './components/RowActionMenu';
7879
import { BulkActionBar } from './components/BulkActionBar';
@@ -701,6 +702,10 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
701702
// getter, which reads the live cache at call time.
702703
const metadata = useMetadata();
703704
const [objectSchema, setObjectSchema] = useState<any>(null);
705+
// The inline-data schema read failed (the non-inline load says so through
706+
// `error`). Read only by the undeclared-name warning's readiness below, so a
707+
// failed read reports the names instead of dropping them silently.
708+
const [objectSchemaReadFailed, setObjectSchemaReadFailed] = useState(false);
704709
const [useCardView, setUseCardView] = useState(false);
705710
// objectstack#28434 — the record URL a row links to, for its context menu's
706711
// "Open in new tab": the host's own builder, the one `LinkCell` draws the
@@ -997,9 +1002,10 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
9971002
// Legacy `rowActions` carry a bare action NAME, which the runner cannot
9981003
// execute on its own — resolve each against the object's declared actions so
9991004
// it dispatches as a real def (and so a name that duplicates an existing
1000-
// `list_item` def stops rendering a dead twin of it). See
1005+
// `list_item` def stops rendering a dead twin of it). A name the object does
1006+
// not declare draws nothing and is reported below, in development. See
10011007
// `resolveLegacyRowActions` for the full rationale (objectui#2960).
1002-
const { defs: resolvedRowActionDefs, unresolved: customRowActions } = resolveLegacyRowActions({
1008+
const { defs: resolvedRowActionDefs, undeclared: undeclaredRowActions } = resolveLegacyRowActions({
10031009
rowActions: rowActionsList.filter(a => a !== 'edit' && a !== 'delete'),
10041010
rowActionDefs: rowActionDefsList,
10051011
objectActions: (objectSchema as any)?.actions,
@@ -1129,6 +1135,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
11291135
// mask, handled as masked), because it could be a `password` /
11301136
// `secret` field — fail closed (objectui#10657, `objectTypesPending`).
11311137
console.warn(`[ObjectGrid] Failed to fetch objectSchema for inline data (objectName: ${objectName}):`, err);
1138+
if (!cancelled) setObjectSchemaReadFailed(true);
11321139
}
11331140
};
11341141

@@ -1250,14 +1257,16 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
12501257
: declaredBulkActions?.filter((a: unknown) => String(a).toLowerCase() !== 'delete');
12511258
// `bulkActions` carries a bare action NAME, which the runner cannot execute
12521259
// on its own — resolve each against `objectDef.actions` so it dispatches as a
1253-
// real def. See `resolveBulkActions` (objectui#3002). The
1260+
// real def. See `resolveBulkActions` (objectui#3002). A name the object does
1261+
// not declare gets no button (spec: a bare name "names an action the object
1262+
// declares") and is reported below, in development. The
12541263
// canonical `'delete'` is held back: it routes to `onBulkDelete` (which owns
12551264
// confirm + refresh), not the runner, even if the object declares an action
12561265
// that happens to be named `delete`.
12571266
const legacyBulkNames = (explicitBulkActions ?? []).filter(
12581267
(a: unknown) => String(a).toLowerCase() !== 'delete',
12591268
);
1260-
const { defs: resolvedBulkDefs, unresolved: unresolvedBulkActions } = resolveBulkActions({
1269+
const { defs: resolvedBulkDefs, undeclared: undeclaredBulkActions } = resolveBulkActions({
12611270
bulkActions: legacyBulkNames,
12621271
bulkActionDefs: Array.isArray(schema.bulkActionDefs) ? schema.bulkActionDefs : [],
12631272
objectActions: (objectSchema as any)?.actions,
@@ -1277,18 +1286,45 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
12771286
(objectCanDelete || def?.operation !== 'delete')
12781287
&& (rowEditVerdict.allowed || def?.operation !== 'update'),
12791288
);
1280-
// Names still dispatched by string: the unresolved ones, plus `'delete'`
1281-
// when the author asked for it — or the implicit bulk-delete affordance.
1289+
// The one name still dispatched by string: `'delete'` when the author asked
1290+
// for it — or the implicit bulk-delete affordance. An undeclared name is not
1291+
// among them: it draws nothing.
12821292
const keptDeleteAction = (explicitBulkActions ?? []).filter(
12831293
(a: unknown) => String(a).toLowerCase() === 'delete',
12841294
);
12851295
const effectiveBulkActions: string[] =
12861296
explicitBulkActions && explicitBulkActions.length > 0
1287-
? [...unresolvedBulkActions, ...keptDeleteAction]
1297+
? keptDeleteAction
12881298
: canDelete && onBulkDelete && bulkActionDefs.length === 0
12891299
? ['delete']
12901300
: [];
12911301
const hasBulkActions = effectiveBulkActions.length > 0 || bulkActionDefs.length > 0;
1302+
// The names the two folds above did not draw, said once in development, and
1303+
// only once there is nothing left to wait for. Keyed on the RESOLVED
1304+
// `objectName` (a grid may name its object only through
1305+
// `data: { provider: 'object', object }`), and mirroring `objectTypesPending`:
1306+
// a schema is awaited only when there is an object to ask about and a data
1307+
// source that can answer `getObjectSchema`, and a read that failed (inline:
1308+
// `objectSchemaReadFailed`; the row load: `error`) is not awaited either.
1309+
// Reported with no schema in hand, the names are said to be unread, never
1310+
// undeclared.
1311+
const objectSchemaAwaited =
1312+
!!objectName && typeof dataSource?.getObjectSchema === 'function' && objectSchema == null
1313+
&& !objectSchemaReadFailed && error == null;
1314+
const undeclaredNamesBasis = objectName && objectSchema == null ? 'unread' : 'declared';
1315+
const undeclaredNamesAddress = {
1316+
blockType: (schema as { type?: unknown }).type,
1317+
objectName,
1318+
label: schema.label ?? (schema as { title?: unknown }).title,
1319+
};
1320+
useUndeclaredActionNamesWarning({
1321+
key: 'bulkActions', names: undeclaredBulkActions, ready: !objectSchemaAwaited,
1322+
basis: undeclaredNamesBasis, address: undeclaredNamesAddress,
1323+
});
1324+
useUndeclaredActionNamesWarning({
1325+
key: 'rowActions', names: undeclaredRowActions, ready: !objectSchemaAwaited,
1326+
basis: undeclaredNamesBasis, address: undeclaredNamesAddress,
1327+
});
12921328
let selectionMode: 'none' | 'single' | 'multiple' | boolean = false;
12931329
if (schema.selection !== undefined) {
12941330
// "presence enables; an explicit off wins" — the ONE rule both
@@ -1676,7 +1712,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
16761712
columnsWithActions, hasActions, hasPinnedColumns, hasUserPinnedColumns, pinnedLeftCols,
16771713
pinnedRightCols, unpinnedCols, rightPinnedClasses, rightPinnedCellClasses, userLeftPinnedCount,
16781714
} = resolveObjectGridActionsColumn({
1679-
schema, data, objectSchema, persistedColumns, operations, customRowActions, resolvedRowActionDefs,
1715+
schema, data, objectSchema, persistedColumns, operations, resolvedRowActionDefs,
16801716
wantEditAction, wantDeleteAction, mayInvokeRowAction, rowOperations, canEdit, canDelete,
16811717
recordVerdict, predicateScope, onEdit, onDelete, editPredicates, deletePredicates, executeAction,
16821718
setRefreshKey, t,

‎ui/packages/plugin-grid/src/__tests__/actionsColumnAndChips-12708.test.tsx‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ function renderGrid(opts: {
4545
schema?: Record<string, unknown>;
4646
objectFields?: Record<string, unknown>;
4747
wired?: boolean;
48+
/** Serves the object's schema (the rows stay inline), e.g. to declare actions. */
49+
dataSource?: Record<string, unknown>;
4850
} = {}) {
4951
const schema: any = {
5052
type: 'object-grid',
@@ -59,6 +61,7 @@ function renderGrid(opts: {
5961
<ObjectGrid
6062
schema={schema}
6163
objectFields={opts.objectFields}
64+
{...(opts.dataSource ? { dataSource: opts.dataSource as any } : {})}
6265
{...(wired ? { onEdit: vi.fn(), onDelete: vi.fn() } : {})}
6366
{...(opts.rowOperations ? { rowOperations: opts.rowOperations } : {})}
6467
/>
@@ -122,9 +125,21 @@ describe('objectui#12708 · CONTROL — a list whose rows offer actions keeps it
122125
});
123126

124127
it('draws it for a legacy row action that is not Edit or Delete, with nothing else wired', async () => {
125-
renderGrid({ wired: false, schema: { rowActions: ['archive'] } });
128+
// `archive` is declared on the object: a bare `rowActions` name draws only
129+
// when it names an action the object declares (objectstack#30169).
130+
renderGrid({
131+
wired: false,
132+
schema: { rowActions: ['archive'] },
133+
dataSource: {
134+
getObjectSchema: async (name: string) => ({
135+
name,
136+
fields: { name: { type: 'text' }, status: { type: 'text' } },
137+
actions: [{ name: 'archive', label: 'Archive' }],
138+
}),
139+
},
140+
});
126141
await settle();
127-
expect(actionsHeader()).not.toBeNull();
142+
await waitFor(() => expect(actionsHeader()).not.toBeNull());
128143
});
129144
});
130145

‎ui/packages/plugin-grid/src/__tests__/bulkActionMembers-8071.test.tsx‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,13 @@
7676
* pass without ever reaching the bar.
7777
*/
7878

79-
import { describe, it, expect, vi, beforeAll } from 'vitest';
79+
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
8080
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
8181
import '@testing-library/jest-dom';
8282
import React from 'react';
8383

8484
import { ObjectGrid } from '../ObjectGrid';
85+
import { resetUndeclaredActionNamesWarnings } from '../undeclaredActionNames';
8586
import { registerAllFields } from '@object-ui/fields';
8687
import { ActionProvider } from '@object-ui/react';
8788

@@ -177,6 +178,9 @@ function bulkDefWarnings(warn: ConsoleSpy): string[] {
177178
.filter((line: string) => line.includes('bulkActionDefs'));
178179
}
179180

181+
// The warning is said once per message per module; each row starts unwarned.
182+
beforeEach(() => resetUndeclaredActionNamesWarnings());
183+
180184
describe('object-grid `bulkActions` members are BARE ACTION NAMES (objectui#8071)', () => {
181185
it('1. a name member is resolved against `objectDef.actions` and promoted', async () => {
182186
await renderAndSelectAll({ bulkActions: ['approve'] });
@@ -199,16 +203,30 @@ describe('object-grid `bulkActions` members are BARE ACTION NAMES (objectui#8071
199203
}
200204
});
201205

202-
it('3. a name matching no declared action still reaches the bar, by name', async () => {
203-
// Not a hole: `resolveBulkActions` keeps unresolved names so a consumer's
204-
// `runner.registerHandler('<name>', …)` stays reachable. Pinned because it
205-
// is the one case where the member is read as a name AND stays one — which
206-
// is what makes row 1's promotion an observable event rather than the only
207-
// path a string could take.
208-
await renderAndSelectAll({ bulkActions: ['no_such_action'] });
209-
expect(await screen.findByTestId('bulk-action-no_such_action')).toHaveTextContent(
210-
'No Such Action',
211-
);
206+
it('3. a name matching no declared action draws NOTHING, and says so once (objectstack#30169)', async () => {
207+
// Trigger objectstack#30169: an action a disabled package withholds leaves
208+
// the served object, so its bare name matched nothing — and this row used
209+
// to pin that the bar still drew it by humanized name ("No Such Action"),
210+
// a button whose click toasted "not executable". The spec says the member
211+
// "names an action the object declares"; one that does not draws nothing.
212+
// The declared sibling keeps the bar up, so the absence is observed on a
213+
// rendered bar, not on a missing one.
214+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
215+
try {
216+
await renderAndSelectAll({ bulkActions: ['approve', 'no_such_action'] });
217+
await screen.findByTestId('bulk-action-approve');
218+
expect(renderedBulkActionIds()).toEqual(['bulk-action-approve']);
219+
expect(screen.queryByText('No Such Action')).not.toBeInTheDocument();
220+
// The drop is not silent: one development warning, naming the member.
221+
const named = () =>
222+
warn.mock.calls
223+
.map((args: unknown[]) => String(args[0]))
224+
.filter((line: string) => line.includes('ObjectGrid bulkActions'));
225+
await waitFor(() => expect(named()).toHaveLength(1));
226+
expect(named()[0]).toContain("'no_such_action'");
227+
} finally {
228+
warn.mockRestore();
229+
}
212230
});
213231
});
214232

0 commit comments

Comments
 (0)