From 85a03128b043395b12478fc39ca53b685b4a6d35 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 13:08:59 +0000 Subject: [PATCH 1/4] fix(plugin-list): a non-grid view's selection bar draws only actions the object declares The bar ListView draws itself for kanban, calendar, gallery and the other non-grid views mapped every bare `bulkActions` name to a button. It now folds the names through plugin-grid's `resolveBulkActions`, the grid's own fold: a declared name draws under its declared label, the built-in `delete` keeps its own path, and any other name draws nothing and is named once in development by plugin-grid's `useUndeclaredActionNamesWarning`. plugin-grid exports the two (and their types) for this, and the warning's address gains an optional `component`, defaulting to `ObjectGrid` so the grid's message is unchanged. plugin-list declares plugin-grid as a peer and dev dependency. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVzvtWN68zb2r9z2zy --- ...0394-non-grid-bulk-bar-declared-actions.md | 14 ++ pnpm-lock.yaml | 3 + .../__tests__/undeclaredActionNames.test.ts | 10 ++ ui/packages/plugin-grid/src/index.tsx | 11 ++ .../plugin-grid/src/undeclaredActionNames.ts | 24 ++- ui/packages/plugin-list/package.json | 2 + ui/packages/plugin-list/src/ListView.tsx | 56 +++++- .../ListView.bulkDeleteVisibleWhen.test.tsx | 3 + ...istView.nonGridBulkDeclared-30394.test.tsx | 162 ++++++++++++++++++ .../__tests__/ListView.permissions.test.tsx | 19 +- 10 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 .changeset/30394-non-grid-bulk-bar-declared-actions.md create mode 100644 ui/packages/plugin-list/src/__tests__/ListView.nonGridBulkDeclared-30394.test.tsx diff --git a/.changeset/30394-non-grid-bulk-bar-declared-actions.md b/.changeset/30394-non-grid-bulk-bar-declared-actions.md new file mode 100644 index 00000000000..0eb13da21ff --- /dev/null +++ b/.changeset/30394-non-grid-bulk-bar-declared-actions.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-list': minor +'@object-ui/plugin-grid': minor +--- + +fix(plugin-list): a list's selection bar over a non-grid view (kanban, calendar, gallery, …) draws only actions the object declares, by the grid's own rule; an undeclared bare `bulkActions` name draws nothing and warns once in development (#30394) + +Clause-②: yes (widening) — `@object-ui/plugin-grid` exports `resolveBulkActions` and `useUndeclaredActionNamesWarning` (with the `ActionLabelResolver`, `NamedActionDef`, `UndeclaredActionNameKey`, `UndeclaredActionNamesAddress` and `UndeclaredActionNamesBasis` types) so `@object-ui/plugin-list` reuses the grid's fold and warning instead of a second copy; the warning's address gains an optional `component`, defaulting to `ObjectGrid`, so the grid's message is byte-identical. +Metadata-protocol: narrowed +Skills-docs: no move — `skills/**` teaches no handler-only bulk action name (searched for `bulkActions` examples); `content/docs/ui/**` already names only declared actions. + +- **Before.** The bar ListView draws itself for every view but the grid mapped each `bulkActions` member straight to a button, labelled by the humanized name, whether or not the object declared it. A name the object does not declare (a misspelling, or an action a disabled package withholds from the served object) showed a button that toasted "not executable" on click. The grid stopped drawing such names in #30169. +- **Now.** The bar folds the names through the grid's `resolveBulkActions`: a name the object declares draws under that action's label (through the app's i18n bundle), the built-in `delete` keeps its own path and gate, and any other name draws nothing. In development the console logs one warning per list naming the key, the view and each name (`[ObjectUI] ListView bulkActions: kanban (objectName: …) — …`), the grid's own message. A bar with nothing left to draw is not shown. +- **Remedy.** Declare the named action on the object, or remove the name from `bulkActions`. An action withheld by a disabled package needs nothing: it draws again when the package is enabled. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9da1e2dd6f2..4f49196a461 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5508,6 +5508,9 @@ importers: '@object-ui/plugin-detail': specifier: workspace:* version: link:../plugin-detail + '@object-ui/plugin-grid': + specifier: workspace:* + version: link:../plugin-grid '@object-ui/react': specifier: workspace:* version: link:../react diff --git a/ui/packages/plugin-grid/src/__tests__/undeclaredActionNames.test.ts b/ui/packages/plugin-grid/src/__tests__/undeclaredActionNames.test.ts index 35da6e636ad..df1012b27eb 100644 --- a/ui/packages/plugin-grid/src/__tests__/undeclaredActionNames.test.ts +++ b/ui/packages/plugin-grid/src/__tests__/undeclaredActionNames.test.ts @@ -59,6 +59,16 @@ describe('describeUndeclaredActionNames', () => { expect(line).not.toContain('matches no action'); }); + it('heads the message with ObjectGrid unless the address names another component (objectstack#30394)', () => { + const grid = describeUndeclaredActionNames('bulkActions', ['create_campaign'], ADDRESS); + expect(grid?.startsWith('[ObjectUI] ObjectGrid bulkActions: object-grid (')).toBe(true); + const list = describeUndeclaredActionNames('bulkActions', ['create_campaign'], { + component: 'ListView', blockType: 'kanban', objectName: 'lead', + }); + expect(list?.startsWith("[ObjectUI] ListView bulkActions: kanban (objectName: 'lead')")).toBe(true); + expect(list).toContain('the selection bar draws no button'); + }); + it('says nothing for no names', () => { expect(describeUndeclaredActionNames('bulkActions', [], ADDRESS)).toBeNull(); }); diff --git a/ui/packages/plugin-grid/src/index.tsx b/ui/packages/plugin-grid/src/index.tsx index 3938209b142..25262ef21be 100644 --- a/ui/packages/plugin-grid/src/index.tsx +++ b/ui/packages/plugin-grid/src/index.tsx @@ -32,6 +32,11 @@ export { useGroupedData } from './useGroupedData'; export { GroupRow } from './GroupRow'; export { RowActionMenu, formatActionLabel } from './components/RowActionMenu'; export { BulkActionBar } from './components/BulkActionBar'; +// The bare-name fold and its development warning, for the one other surface +// that draws a selection bar of its own: plugin-list's ListView over a +// non-grid view (objectstack#30394). One rule, one copy. +export { resolveBulkActions } from './resolveBulkActions'; +export { useUndeclaredActionNamesWarning } from './undeclaredActionNames'; export { useCellClipboard } from './useCellClipboard'; export { useGradientColor } from './useGradientColor'; export { useGroupReorder } from './useGroupReorder'; @@ -55,6 +60,12 @@ export type { GroupEntry, UseGroupedDataResult, AggregationType, GroupAggregatio export type { GroupRowProps } from './GroupRow'; export type { RowActionMenuProps } from './components/RowActionMenu'; export type { BulkActionBarProps } from './components/BulkActionBar'; +export type { ActionLabelResolver, NamedActionDef } from './resolveBulkActions'; +export type { + UndeclaredActionNameKey, + UndeclaredActionNamesAddress, + UndeclaredActionNamesBasis, +} from './undeclaredActionNames'; export type { CellRange, UseCellClipboardOptions, UseCellClipboardResult } from './useCellClipboard'; export type { GradientStop, UseGradientColorOptions } from './useGradientColor'; export type { UseGroupReorderOptions, UseGroupReorderResult } from './useGroupReorder'; diff --git a/ui/packages/plugin-grid/src/undeclaredActionNames.ts b/ui/packages/plugin-grid/src/undeclaredActionNames.ts index 20bd63af6a3..3602646c8b8 100644 --- a/ui/packages/plugin-grid/src/undeclaredActionNames.ts +++ b/ui/packages/plugin-grid/src/undeclaredActionNames.ts @@ -46,7 +46,16 @@ export type UndeclaredActionNameKey = 'bulkActions' | 'rowActions'; /** Where the offending block lives, for the first line of the message. */ export interface UndeclaredActionNamesAddress { - /** The schema node's `type`: `object-grid`, or the `view:grid` alias. */ + /** + * The component whose bar or menu left the names undrawn, for the message's + * head. Omitted: `ObjectGrid`. `@object-ui/plugin-list`'s ListView passes + * `ListView` for the selection bar it draws itself over a non-grid view. + */ + component?: unknown; + /** + * The schema node's `type`: `object-grid`, or the `view:grid` alias; for + * ListView, the view on screen (`kanban`, `calendar`, …). + */ blockType?: unknown; /** The object the grid queries, when it names one. */ objectName?: unknown; @@ -67,6 +76,10 @@ const SURFACE: Record = { rowActions: 'the row menu draws no entry', }; +function describeComponent(component: unknown): string { + return typeof component === 'string' && component.length > 0 ? component : 'ObjectGrid'; +} + function describeAddress({ blockType, objectName, label }: UndeclaredActionNamesAddress): string { const block = typeof blockType === 'string' && blockType.length > 0 ? blockType : 'object-grid'; const parts: string[] = []; @@ -92,13 +105,13 @@ export function describeUndeclaredActionNames( const rule = ` A bare \`${key}\` name must name an action declared on the object.`; if (basis === 'unread') { const count = names.length === 1 ? '1 name' : `${names.length} names`; - return `[ObjectUI] ObjectGrid ${key}: ${describeAddress(address)} — the object's schema could not ` + return `[ObjectUI] ${describeComponent(address.component)} ${key}: ${describeAddress(address)} — the object's schema could not ` + `be read (no getObjectSchema on the data source, or the read failed), so its declared actions ` + `are unknown and ${SURFACE[key]} for ${count}: ${listed}.\n` + `${rule} Give the grid a data source that answers getObjectSchema, or check why the read failed.`; } const count = names.length === 1 ? '1 name matches' : `${names.length} names match`; - return `[ObjectUI] ObjectGrid ${key}: ${describeAddress(address)} — ${count} no action the object ` + return `[ObjectUI] ${describeComponent(address.component)} ${key}: ${describeAddress(address)} — ${count} no action the object ` + `declares, so ${SURFACE[key]} for ${them}: ${listed}.\n` + `${rule} Declare the action on the ` + 'object, or remove the name. An action that a disabled package withholds is left out of the ' @@ -141,10 +154,11 @@ export function useUndeclaredActionNamesWarning(input: { // Keyed on the names' VALUES, never on the array the fold returns, which is // a fresh one on every render. const namesKey = ready ? JSON.stringify(input.names) : ''; - const { blockType, objectName, label } = input.address; + const { component, blockType, objectName, label } = input.address; useEffect(() => { if (!namesKey || !isDevelopment()) return; const message = describeUndeclaredActionNames(key, JSON.parse(namesKey) as string[], { + component, blockType, objectName, label, @@ -152,5 +166,5 @@ export function useUndeclaredActionNamesWarning(input: { if (!message || warnedMessages.has(message)) return; warnedMessages.add(message); console.warn(message); - }, [key, namesKey, basis, blockType, objectName, label]); + }, [key, namesKey, basis, component, blockType, objectName, label]); } diff --git a/ui/packages/plugin-list/package.json b/ui/packages/plugin-list/package.json index 10ef8524209..9be39467bcf 100644 --- a/ui/packages/plugin-list/package.json +++ b/ui/packages/plugin-list/package.json @@ -48,6 +48,7 @@ "@object-ui/mobile": "workspace:^", "@object-ui/permissions": "workspace:^", "@object-ui/plugin-detail": "workspace:^", + "@object-ui/plugin-grid": "workspace:^", "@object-ui/react": "workspace:^", "@object-ui/types": "workspace:^", "@objectstack/spec": "workspace:^", @@ -62,6 +63,7 @@ "@object-ui/mobile": "workspace:*", "@object-ui/permissions": "workspace:*", "@object-ui/plugin-detail": "workspace:*", + "@object-ui/plugin-grid": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/test-support": "workspace:*", "@object-ui/types": "workspace:*", diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index b273b81f895..c5b3c47fb59 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -54,6 +54,7 @@ import { resolveInjectedSystemColumns, resolveSearchFieldResolution, type Search import { SystemFieldName } from '@objectstack/spec/system'; import { usePermissions } from '@object-ui/permissions'; import { RecordDetailPanel } from '@object-ui/plugin-detail'; +import { resolveBulkActions, useUndeclaredActionNamesWarning } from '@object-ui/plugin-grid'; /** * The `case 'map'` branch below builds an `object-map` schema by flattening @@ -2535,6 +2536,51 @@ export const ListView = React.forwardRef(({ return declared.filter((a: unknown) => String(a).toLowerCase() !== 'delete'); }, [schema.bulkActions, bulkDeleteVerdict]); + /** + * [objectstack#30394] What the non-grid bar DRAWS out of `permittedBulkActions`: + * the grid's own rule (objectstack#30169, ruling C), folded by the grid's own + * `resolveBulkActions` so the two bars cannot drift. + * + * A bare name draws only when the object declares an action of that name, + * labelled from that declaration (through the app's i18n bundle, as the grid + * labels it). The canonical `delete` keeps its own path: it is not an object + * action but the built-in bulk delete, gated above by `bulkDeleteVerdict`, + * so it is held out of the fold exactly as the grid holds it out. Any other + * name draws nothing; `undeclared` exists only for the development warning + * below. Before this fold every member drew a button, so a name the object + * never declared, an action a disabled package withholds included, showed a + * button that only toasted "not executable" when pressed. + */ + const nonGridBulkBar = React.useMemo(() => { + const names = (permittedBulkActions ?? []).filter((a: unknown) => typeof a === 'string' && a !== '') as string[]; + const { defs, undeclared } = resolveBulkActions({ + bulkActions: names.filter((a) => a.toLowerCase() !== 'delete'), + objectActions: objectDef?.actions, + localizeLabel: (actionName, fallback) => + schema.objectName && typeof resolveActionLabel === 'function' + ? resolveActionLabel(schema.objectName, actionName, fallback) + : fallback, + }); + const labels = new Map(defs.map((def) => [def.name, def.label])); + // Authored order, the built-in `delete` where the author put it. + const drawn = names + .filter((a) => a.toLowerCase() === 'delete' || labels.has(a)) + .map((name) => ({ name, label: labels.get(name) })); + return { drawn, undeclared }; + }, [permittedBulkActions, objectDef, schema.objectName, resolveActionLabel]); + // The grid says its own (ObjectGrid warns for the names it leaves undrawn), + // so this one speaks only while a non-grid view is on screen, and only once + // the object's schema read has settled. A list with an object but no schema + // in hand (no `getObjectSchema`, or the read failed) reports the names as + // unread, never as undeclared: the grid's own reading. + useUndeclaredActionNamesWarning({ + key: 'bulkActions', + names: nonGridBulkBar.undeclared, + ready: objectDefLoaded && currentView !== 'grid', + basis: schema.objectName && objectDef == null ? 'unread' : 'declared', + address: { component: 'ListView', blockType: currentView, objectName: schema.objectName, label: schema.label }, + }); + /** * [objectui#4420] The PER-RECORD half of the same key, for the same bar. * @@ -7352,7 +7398,7 @@ export const ListView = React.forwardRef(({ )} {/* Bulk Actions Bar — skip for grid view since ObjectGrid renders its own BulkActionBar */} - {permittedBulkActions && permittedBulkActions.length > 0 && selectedRows.length > 0 && currentView !== 'grid' && ( + {nonGridBulkBar.drawn.length > 0 && selectedRows.length > 0 && currentView !== 'grid' && (
(({ {selectedRows.length} {selectedRows.length === 1 ? 'item' : 'items'} selected
- {permittedBulkActions.map((action: any) => { - const actionStr = String(action).toLowerCase(); + {nonGridBulkBar.drawn.map(({ name: action, label }) => { + const actionStr = action.toLowerCase(); const isDestructive = actionStr.includes('delete') || actionStr.includes('remove') || actionStr.includes('destroy'); const Icon = isDestructive ? Trash2 : null; // [objectui#4420] The built-in `delete` runs over the ALLOWED @@ -7388,7 +7434,7 @@ export const ListView = React.forwardRef(({ data-testid={`bulk-action-${action}`} > {Icon && } - {tActionLabel(action)} + {label ?? tActionLabel(action)} ); })} @@ -7402,7 +7448,7 @@ export const ListView = React.forwardRef(({ the dialog's slot: one name for one fact, whichever surface says it. */} {bulkDeleteEligibility.skipped > 0 - && permittedBulkActions.some((a: any) => String(a).toLowerCase() === 'delete') && ( + && nonGridBulkBar.drawn.some(({ name }) => name.toLowerCase() === 'delete') && ( = { + find: vi.fn().mockResolvedValue(ROWS.map(r => ({ ...r }))), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + if (!opts.noSchema) { + ds.getObjectSchema = vi.fn().mockResolvedValue({ + name: objectName, + fields: { id: { type: 'text' }, name: { type: 'text' } }, + ...(opts.actions ? { actions: opts.actions } : {}), + }); + } + return ds; +} + +/** + * Stand in for the gallery renderer and select every row as soon as it mounts: + * the bar only renders with a non-empty selection (the device + * `ListView.bulkDeleteVisibleWhen.test.tsx` uses). + */ +function registerSelectingGallery() { + ComponentRegistry.register('object-gallery', (props: any) => { + const onRowSelect = props.onRowSelect; + React.useEffect(() => { + onRowSelect?.(ROWS); + }, [onRowSelect]); + return
; + }); +} + +function renderGallery(objectName: string, bulkActions: string[], ds: Record) { + const onBulkAction = vi.fn(); + const schema: ListViewSchema = { + type: 'list-view', + objectName, + viewType: 'gallery', + fields: ['name'], + bulkActions, + }; + render( + + + , + ); + return { onBulkAction }; +} + +/** The ListView warnings logged so far (the grid's own say `ObjectGrid`). */ +function listViewWarnings(warn: ReturnType): string[] { + return warn.mock.calls + .map(c => String(c[0])) + .filter(m => m.startsWith('[ObjectUI] ListView bulkActions:')); +} + +describe('ListView non-grid bulk bar draws only actions the object declares (objectstack#30394)', () => { + let prevGallery: ReturnType; + let warn: ReturnType; + + beforeEach(() => { + prevGallery = ComponentRegistry.get('object-gallery'); + registerSelectingGallery(); + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + cleanup(); + warn.mockRestore(); + if (prevGallery) ComponentRegistry.register('object-gallery', prevGallery); + else ComponentRegistry.unregister('object-gallery'); + }); + + it('draws the declared name with its declared label, keeps delete, and drops the undeclared name', async () => { + const ds = makeDataSource('lead_a', { actions: [{ name: 'qualify_lead', label: 'Qualify' }] }); + const { onBulkAction } = renderGallery('lead_a', ['qualify_lead', 'create_campaign', 'delete'], ds); + + await waitFor(() => expect(screen.getByTestId('bulk-actions-bar')).toBeInTheDocument()); + const qualify = await screen.findByTestId('bulk-action-qualify_lead'); + // The declaration's label, not the title-cased name (`Qualify Lead`). + expect(qualify).toHaveTextContent('Qualify'); + expect(qualify).not.toHaveTextContent('Qualify Lead'); + expect(screen.getByTestId('bulk-action-delete')).toBeInTheDocument(); + expect(screen.queryByTestId('bulk-action-create_campaign')).toBeNull(); + + // Authored order, delete where the author put it. + const order = Array.from(screen.getByTestId('bulk-actions-bar').querySelectorAll('[data-testid^="bulk-action-"]')) + .map(el => el.getAttribute('data-testid')); + expect(order).toEqual(['bulk-action-qualify_lead', 'bulk-action-delete']); + + // The declared name still dispatches through the host by its name. + fireEvent.click(qualify); + expect(onBulkAction).toHaveBeenCalledWith('qualify_lead', ROWS); + + await waitFor(() => expect(listViewWarnings(warn)).toHaveLength(1)); + const [message] = listViewWarnings(warn); + expect(message).toContain("gallery (objectName: 'lead_a')"); + expect(message).toContain("1 name matches no action the object declares"); + expect(message).toContain("'create_campaign'"); + expect(message).not.toContain('qualify_lead'); + }); + + it('draws no bar at all when no name is declared and there is no delete', async () => { + const ds = makeDataSource('lead_b', { actions: [{ name: 'qualify_lead', label: 'Qualify' }] }); + renderGallery('lead_b', ['create_campaign', 'send_blast'], ds); + + await screen.findByTestId('gallery-stub'); + await waitFor(() => expect(listViewWarnings(warn)).toHaveLength(1)); + expect(screen.queryByTestId('bulk-actions-bar')).toBeNull(); + expect(listViewWarnings(warn)[0]).toContain("2 names match no action the object declares"); + }); + + it('says the actions are unknown, not undeclared, when the data source cannot read the schema', async () => { + const ds = makeDataSource('lead_c', { noSchema: true }); + renderGallery('lead_c', ['qualify_lead', 'delete'], ds); + + await waitFor(() => expect(screen.getByTestId('bulk-actions-bar')).toBeInTheDocument()); + expect(screen.queryByTestId('bulk-action-qualify_lead')).toBeNull(); + expect(screen.getByTestId('bulk-action-delete')).toBeInTheDocument(); + await waitFor(() => expect(listViewWarnings(warn)).toHaveLength(1)); + expect(listViewWarnings(warn)[0]).toContain("gallery (objectName: 'lead_c') — the object's schema could not be read"); + }); + + it('says nothing while every name is declared', async () => { + const ds = makeDataSource('lead_d', { actions: [{ name: 'qualify_lead', label: 'Qualify' }] }); + renderGallery('lead_d', ['qualify_lead'], ds); + + await screen.findByTestId('bulk-action-qualify_lead'); + expect(listViewWarnings(warn)).toEqual([]); + }); +}); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.permissions.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.permissions.test.tsx index f63f21ee9b8..8a54312a02c 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.permissions.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.permissions.test.tsx @@ -198,11 +198,26 @@ function registerSelectingGallery() { }); } +/** + * The gallery's data source also serves the object, which declares `archive`: + * the non-grid bar draws a bare `bulkActions` name only when the object + * declares it (objectstack#30394), so without the declaration the + * non-delete control below would have nothing to draw. + */ +const galleryDataSource = { + ...mockDataSource, + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'account', + fields: { name: { type: 'text' }, industry: { type: 'text' } }, + actions: [{ name: 'archive', label: 'Archive' }], + }), +}; + /** `permissions: null` renders with NO PermissionProvider at all. */ function renderGalleryBulk(permissions: ObjectPermissionConfig | null) { - const view = ; + const view = ; return render( - + {permissions ? ( From fbecf3327e30b9264a18efc8fc8803c708ff7fd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 13:21:34 +0000 Subject: [PATCH 2/4] chore(changeset): state the ADR-0087 disposition and type the new test's warn spy Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVzvtWN68zb2r9z2zy --- .changeset/30394-non-grid-bulk-bar-declared-actions.md | 2 ++ .../__tests__/ListView.nonGridBulkDeclared-30394.test.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.changeset/30394-non-grid-bulk-bar-declared-actions.md b/.changeset/30394-non-grid-bulk-bar-declared-actions.md index 0eb13da21ff..4b2450c2ca6 100644 --- a/.changeset/30394-non-grid-bulk-bar-declared-actions.md +++ b/.changeset/30394-non-grid-bulk-bar-declared-actions.md @@ -9,6 +9,8 @@ Clause-②: yes (widening) — `@object-ui/plugin-grid` exports `resolveBulkActi Metadata-protocol: narrowed Skills-docs: no move — `skills/**` teaches no handler-only bulk action name (searched for `bulkActions` examples); `content/docs/ui/**` already names only declared actions. + + - **Before.** The bar ListView draws itself for every view but the grid mapped each `bulkActions` member straight to a button, labelled by the humanized name, whether or not the object declared it. A name the object does not declare (a misspelling, or an action a disabled package withholds from the served object) showed a button that toasted "not executable" on click. The grid stopped drawing such names in #30169. - **Now.** The bar folds the names through the grid's `resolveBulkActions`: a name the object declares draws under that action's label (through the app's i18n bundle), the built-in `delete` keeps its own path and gate, and any other name draws nothing. In development the console logs one warning per list naming the key, the view and each name (`[ObjectUI] ListView bulkActions: kanban (objectName: …) — …`), the grid's own message. A bar with nothing left to draw is not shown. - **Remedy.** Declare the named action on the object, or remove the name from `bulkActions`. An action withheld by a disabled package needs nothing: it draws again when the package is enabled. diff --git a/ui/packages/plugin-list/src/__tests__/ListView.nonGridBulkDeclared-30394.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.nonGridBulkDeclared-30394.test.tsx index 7033f4de171..c4d3d8d1d59 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.nonGridBulkDeclared-30394.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.nonGridBulkDeclared-30394.test.tsx @@ -79,15 +79,15 @@ function renderGallery(objectName: string, bulkActions: string[], ds: Record): string[] { +function listViewWarnings(warn: { mock: { calls: unknown[][] } }): string[] { return warn.mock.calls - .map(c => String(c[0])) - .filter(m => m.startsWith('[ObjectUI] ListView bulkActions:')); + .map((c: unknown[]) => String(c[0])) + .filter((m: string) => m.startsWith('[ObjectUI] ListView bulkActions:')); } describe('ListView non-grid bulk bar draws only actions the object declares (objectstack#30394)', () => { let prevGallery: ReturnType; - let warn: ReturnType; + let warn: { mock: { calls: unknown[][] }; mockRestore: () => void }; beforeEach(() => { prevGallery = ComponentRegistry.get('object-gallery'); From 38d91dbe762ca49c1e04684dd20433c63080c3e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 13:54:21 +0000 Subject: [PATCH 3/4] chore(changeset): declare the narrowing arm, the PR both widens and narrows Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVzvtWN68zb2r9z2zy --- .changeset/30394-non-grid-bulk-bar-declared-actions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/30394-non-grid-bulk-bar-declared-actions.md b/.changeset/30394-non-grid-bulk-bar-declared-actions.md index 4b2450c2ca6..588f08ca9dd 100644 --- a/.changeset/30394-non-grid-bulk-bar-declared-actions.md +++ b/.changeset/30394-non-grid-bulk-bar-declared-actions.md @@ -5,7 +5,7 @@ fix(plugin-list): a list's selection bar over a non-grid view (kanban, calendar, gallery, …) draws only actions the object declares, by the grid's own rule; an undeclared bare `bulkActions` name draws nothing and warns once in development (#30394) -Clause-②: yes (widening) — `@object-ui/plugin-grid` exports `resolveBulkActions` and `useUndeclaredActionNamesWarning` (with the `ActionLabelResolver`, `NamedActionDef`, `UndeclaredActionNameKey`, `UndeclaredActionNamesAddress` and `UndeclaredActionNamesBasis` types) so `@object-ui/plugin-list` reuses the grid's fold and warning instead of a second copy; the warning's address gains an optional `component`, defaulting to `ObjectGrid`, so the grid's message is byte-identical. +Clause-②: yes (narrowing) — `@object-ui/plugin-grid` exports `resolveBulkActions` and `useUndeclaredActionNamesWarning` (with the `ActionLabelResolver`, `NamedActionDef`, `UndeclaredActionNameKey`, `UndeclaredActionNamesAddress` and `UndeclaredActionNamesBasis` types) so `@object-ui/plugin-list` reuses the grid's fold and warning instead of a second copy; the warning's address gains an optional `component`, defaulting to `ObjectGrid`, so the grid's message is byte-identical. Metadata-protocol: narrowed Skills-docs: no move — `skills/**` teaches no handler-only bulk action name (searched for `bulkActions` examples); `content/docs/ui/**` already names only declared actions. From 4d680aaabc35974ca9a375d21383118a811bd03a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 14:05:58 +0000 Subject: [PATCH 4/4] chore(changeset): mark the summary breaking, matching the (narrowing) arm Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVzvtWN68zb2r9z2zy --- .changeset/30394-non-grid-bulk-bar-declared-actions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/30394-non-grid-bulk-bar-declared-actions.md b/.changeset/30394-non-grid-bulk-bar-declared-actions.md index 588f08ca9dd..d4cc5c07dc9 100644 --- a/.changeset/30394-non-grid-bulk-bar-declared-actions.md +++ b/.changeset/30394-non-grid-bulk-bar-declared-actions.md @@ -3,7 +3,7 @@ '@object-ui/plugin-grid': minor --- -fix(plugin-list): a list's selection bar over a non-grid view (kanban, calendar, gallery, …) draws only actions the object declares, by the grid's own rule; an undeclared bare `bulkActions` name draws nothing and warns once in development (#30394) +fix(plugin-list)!: a list's selection bar over a non-grid view (kanban, calendar, gallery, …) draws only actions the object declares, by the grid's own rule; an undeclared bare `bulkActions` name draws nothing and warns once in development (#30394) Clause-②: yes (narrowing) — `@object-ui/plugin-grid` exports `resolveBulkActions` and `useUndeclaredActionNamesWarning` (with the `ActionLabelResolver`, `NamedActionDef`, `UndeclaredActionNameKey`, `UndeclaredActionNamesAddress` and `UndeclaredActionNamesBasis` types) so `@object-ui/plugin-list` reuses the grid's fold and warning instead of a second copy; the warning's address gains an optional `component`, defaulting to `ObjectGrid`, so the grid's message is byte-identical. Metadata-protocol: narrowed