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
19 changes: 19 additions & 0 deletions .changeset/30169-undeclared-bulk-action-name-draws-nothing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/plugin-grid': minor
---

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)

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.
Metadata-protocol: narrowed
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.

<!-- 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. -->

**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`.

- **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.
- **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.
- **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`.

**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.
15 changes: 12 additions & 3 deletions ui/packages/plugin-grid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,8 @@ const schema: ObjectGridBlockNode = {
objectName: 'users',
columns: ['name', 'email'],
selection: { type: 'multiple' },
bulkActions: ['delete', 'export'],
// `deactivate` is an action the `users` object declares.
bulkActions: ['delete', 'deactivate'],
} satisfies ObjectGridProps,
};
```
Expand Down Expand Up @@ -757,6 +758,13 @@ const schema: ObjectGridBlockNode = {
themselves live in the object's action set, so the same action behaves identically
wherever it is offered. They are `string[]`, not inline definitions with callbacks.

Each name must name an action the object declares; `'edit'` and `'delete'` are the
built-in exceptions. A name the object does not declare draws nothing: no row-menu
entry and no selection-bar button, and in development the console logs one warning
naming it. That includes a misspelling and an action a disabled package withholds,
which the server leaves out of the object. A runner handler registered under a bare
name is not a declaration and does not bring the entry back.

```typescript
import type { ObjectGridBlockNode } from '@object-ui/types';
import type { ObjectGridProps } from '@objectstack/spec/ui';
Expand All @@ -766,9 +774,10 @@ const schema: ObjectGridBlockNode = {
properties: {
objectName: 'users',
columns: ['name', 'email'],
rowActions: ['view', 'edit', 'delete'],
// `deactivate` is an action the `users` object declares.
rowActions: ['deactivate', 'edit', 'delete'],
selection: { type: 'multiple' },
bulkActions: ['delete', 'export'],
bulkActions: ['delete', 'deactivate'],
} satisfies ObjectGridProps,
};
```
Expand Down
28 changes: 12 additions & 16 deletions ui/packages/plugin-grid/demo/bulk-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
* action (spec 17 retired `action.bulkEnabled` as a tombstone). Both
* names used to dispatch as `{ type: '<name>' }` and never run; the
* buttons also carried none of the def's label / icon / params.
* 2. `legacy_only_handler` resolves to no declared action and stays a
* by-name dispatch — it must still render ALONGSIDE the two defs above.
* 2. `not_declared` names no action the object declares, so the bar draws
* NO button for it (the spec: a bare name "names an action the object
* declares"), and the browser console logs one warning naming it. An
* action a disabled package withholds reads the same way: it leaves the
* served object, so its name draws nothing (objectstack#30169).
* 3. Record `t3` fails server-side (422), proving per-record attribution: the
* result panel reports 4/5 with an error row rather than a blanket success.
* 4. Rich `bulkActionDefs` with picker params (#3064 / ADR-0059): `Set
Expand Down Expand Up @@ -237,8 +240,9 @@ const schema: any = {
{ field: 'progress', label: 'Progress' },
],
pagination: { pageSize: 50 },
// Bare names: two resolve to declared actions, one doesn't.
bulkActions: ['bulk_mark_done', 'bulk_recalc_estimate', 'legacy_only_handler'],
// Bare names: two resolve to declared actions; `not_declared` names nothing
// the object declares, so it draws no button and warns once in the console.
bulkActions: ['bulk_mark_done', 'bulk_recalc_estimate', 'not_declared'],
// Rich defs with picker params — the shared-field-widget surface (#3064).
bulkActionDefs: [
// Aggregate mode (#3139): names the declared action, ONE dispatch for the
Expand Down Expand Up @@ -285,8 +289,9 @@ function Demo() {
Select rows → the bar shows <b>Mark Done</b> and <b>Recalculate Estimate</b> (names
from the view&apos;s <code>bulkActions</code>, promoted to their declared object
actions), <b>Export Zip</b> (an <code>execution: &apos;aggregate&apos;</code> def —
ONE dispatch carrying every selected id, #3139) and <b>Legacy Only
Handler</b> (unresolvable, dispatched by name).
ONE dispatch carrying every selected id, #3139). The view also names{' '}
<code>not_declared</code>, which the object does not declare: no button, and one
warning in the browser console.
</p>
</header>

Expand Down Expand Up @@ -324,16 +329,7 @@ function Demo() {

createRoot(document.getElementById('root')!).render(
<I18nProvider resources={{ en }} language="en">
{/* A handler registered under a bare NAME — the compat path that legacy
`bulkActions` entries must keep working for. */}
<ActionProvider
handlers={{
legacy_only_handler: async () => {
pushLog({ url: '(runner handler)', recordId: 'all-selected', params: '{}', status: 200 });
return { success: true };
},
}}
>
<ActionProvider>
<Demo />
</ActionProvider>
</I18nProvider>,
Expand Down
52 changes: 44 additions & 8 deletions ui/packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import { resolveRowCrudAffordances, resolveRowRecordCrudAffordance } from './row
import { useRecordCrudVerdicts } from './hooks/useRecordCrudVerdicts';
import { resolveLegacyRowActions } from './resolveLegacyRowActions';
import { resolveBulkActions, resolvePromotedBulkParams } from './resolveBulkActions';
import { useUndeclaredActionNamesWarning } from './undeclaredActionNames';
import { partitionBulkRows } from './bulkEligibility';
import { RowActionMenu, formatActionLabel } from './components/RowActionMenu';
import { BulkActionBar } from './components/BulkActionBar';
Expand Down Expand Up @@ -701,6 +702,10 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// getter, which reads the live cache at call time.
const metadata = useMetadata();
const [objectSchema, setObjectSchema] = useState<any>(null);
// The inline-data schema read failed (the non-inline load says so through
// `error`). Read only by the undeclared-name warning's readiness below, so a
// failed read reports the names instead of dropping them silently.
const [objectSchemaReadFailed, setObjectSchemaReadFailed] = useState(false);
const [useCardView, setUseCardView] = useState(false);
// objectstack#28434 — the record URL a row links to, for its context menu's
// "Open in new tab": the host's own builder, the one `LinkCell` draws the
Expand Down Expand Up @@ -997,9 +1002,10 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// Legacy `rowActions` carry a bare action NAME, which the runner cannot
// execute on its own — resolve each against the object's declared actions so
// it dispatches as a real def (and so a name that duplicates an existing
// `list_item` def stops rendering a dead twin of it). See
// `list_item` def stops rendering a dead twin of it). A name the object does
// not declare draws nothing and is reported below, in development. See
// `resolveLegacyRowActions` for the full rationale (objectui#2960).
const { defs: resolvedRowActionDefs, unresolved: customRowActions } = resolveLegacyRowActions({
const { defs: resolvedRowActionDefs, undeclared: undeclaredRowActions } = resolveLegacyRowActions({
rowActions: rowActionsList.filter(a => a !== 'edit' && a !== 'delete'),
rowActionDefs: rowActionDefsList,
objectActions: (objectSchema as any)?.actions,
Expand Down Expand Up @@ -1129,6 +1135,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// mask, handled as masked), because it could be a `password` /
// `secret` field — fail closed (objectui#10657, `objectTypesPending`).
console.warn(`[ObjectGrid] Failed to fetch objectSchema for inline data (objectName: ${objectName}):`, err);
if (!cancelled) setObjectSchemaReadFailed(true);
}
};

Expand Down Expand Up @@ -1250,14 +1257,16 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
: declaredBulkActions?.filter((a: unknown) => String(a).toLowerCase() !== 'delete');
// `bulkActions` carries a bare action NAME, which the runner cannot execute
// on its own — resolve each against `objectDef.actions` so it dispatches as a
// real def. See `resolveBulkActions` (objectui#3002). The
// real def. See `resolveBulkActions` (objectui#3002). A name the object does
// not declare gets no button (spec: a bare name "names an action the object
// declares") and is reported below, in development. The
// canonical `'delete'` is held back: it routes to `onBulkDelete` (which owns
// confirm + refresh), not the runner, even if the object declares an action
// that happens to be named `delete`.
const legacyBulkNames = (explicitBulkActions ?? []).filter(
(a: unknown) => String(a).toLowerCase() !== 'delete',
);
const { defs: resolvedBulkDefs, unresolved: unresolvedBulkActions } = resolveBulkActions({
const { defs: resolvedBulkDefs, undeclared: undeclaredBulkActions } = resolveBulkActions({
bulkActions: legacyBulkNames,
bulkActionDefs: Array.isArray(schema.bulkActionDefs) ? schema.bulkActionDefs : [],
objectActions: (objectSchema as any)?.actions,
Expand All @@ -1277,18 +1286,45 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
(objectCanDelete || def?.operation !== 'delete')
&& (rowEditVerdict.allowed || def?.operation !== 'update'),
);
// Names still dispatched by string: the unresolved ones, plus `'delete'`
// when the author asked for it — or the implicit bulk-delete affordance.
// The one name still dispatched by string: `'delete'` when the author asked
// for it — or the implicit bulk-delete affordance. An undeclared name is not
// among them: it draws nothing.
const keptDeleteAction = (explicitBulkActions ?? []).filter(
(a: unknown) => String(a).toLowerCase() === 'delete',
);
const effectiveBulkActions: string[] =
explicitBulkActions && explicitBulkActions.length > 0
? [...unresolvedBulkActions, ...keptDeleteAction]
? keptDeleteAction
: canDelete && onBulkDelete && bulkActionDefs.length === 0
? ['delete']
: [];
const hasBulkActions = effectiveBulkActions.length > 0 || bulkActionDefs.length > 0;
// The names the two folds above did not draw, said once in development, and
// only once there is nothing left to wait for. Keyed on the RESOLVED
// `objectName` (a grid may name its object only through
// `data: { provider: 'object', object }`), and mirroring `objectTypesPending`:
// a schema is awaited only when there is an object to ask about and a data
// source that can answer `getObjectSchema`, and a read that failed (inline:
// `objectSchemaReadFailed`; the row load: `error`) is not awaited either.
// Reported with no schema in hand, the names are said to be unread, never
// undeclared.
const objectSchemaAwaited =
!!objectName && typeof dataSource?.getObjectSchema === 'function' && objectSchema == null
&& !objectSchemaReadFailed && error == null;
const undeclaredNamesBasis = objectName && objectSchema == null ? 'unread' : 'declared';
const undeclaredNamesAddress = {
blockType: (schema as { type?: unknown }).type,
objectName,
label: schema.label ?? (schema as { title?: unknown }).title,
};
useUndeclaredActionNamesWarning({
key: 'bulkActions', names: undeclaredBulkActions, ready: !objectSchemaAwaited,
basis: undeclaredNamesBasis, address: undeclaredNamesAddress,
});
useUndeclaredActionNamesWarning({
key: 'rowActions', names: undeclaredRowActions, ready: !objectSchemaAwaited,
basis: undeclaredNamesBasis, address: undeclaredNamesAddress,
});
let selectionMode: 'none' | 'single' | 'multiple' | boolean = false;
if (schema.selection !== undefined) {
// "presence enables; an explicit off wins" — the ONE rule both
Expand Down Expand Up @@ -1676,7 +1712,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
columnsWithActions, hasActions, hasPinnedColumns, hasUserPinnedColumns, pinnedLeftCols,
pinnedRightCols, unpinnedCols, rightPinnedClasses, rightPinnedCellClasses, userLeftPinnedCount,
} = resolveObjectGridActionsColumn({
schema, data, objectSchema, persistedColumns, operations, customRowActions, resolvedRowActionDefs,
schema, data, objectSchema, persistedColumns, operations, resolvedRowActionDefs,
wantEditAction, wantDeleteAction, mayInvokeRowAction, rowOperations, canEdit, canDelete,
recordVerdict, predicateScope, onEdit, onDelete, editPredicates, deletePredicates, executeAction,
setRefreshKey, t,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ function renderGrid(opts: {
schema?: Record<string, unknown>;
objectFields?: Record<string, unknown>;
wired?: boolean;
/** Serves the object's schema (the rows stay inline), e.g. to declare actions. */
dataSource?: Record<string, unknown>;
} = {}) {
const schema: any = {
type: 'object-grid',
Expand All @@ -59,6 +61,7 @@ function renderGrid(opts: {
<ObjectGrid
schema={schema}
objectFields={opts.objectFields}
{...(opts.dataSource ? { dataSource: opts.dataSource as any } : {})}
{...(wired ? { onEdit: vi.fn(), onDelete: vi.fn() } : {})}
{...(opts.rowOperations ? { rowOperations: opts.rowOperations } : {})}
/>
Expand Down Expand Up @@ -122,9 +125,21 @@ describe('objectui#12708 · CONTROL — a list whose rows offer actions keeps it
});

it('draws it for a legacy row action that is not Edit or Delete, with nothing else wired', async () => {
renderGrid({ wired: false, schema: { rowActions: ['archive'] } });
// `archive` is declared on the object: a bare `rowActions` name draws only
// when it names an action the object declares (objectstack#30169).
renderGrid({
wired: false,
schema: { rowActions: ['archive'] },
dataSource: {
getObjectSchema: async (name: string) => ({
name,
fields: { name: { type: 'text' }, status: { type: 'text' } },
actions: [{ name: 'archive', label: 'Archive' }],
}),
},
});
await settle();
expect(actionsHeader()).not.toBeNull();
await waitFor(() => expect(actionsHeader()).not.toBeNull());
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,13 @@
* pass without ever reaching the bar.
*/

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

import { ObjectGrid } from '../ObjectGrid';
import { resetUndeclaredActionNamesWarnings } from '../undeclaredActionNames';
import { registerAllFields } from '@object-ui/fields';
import { ActionProvider } from '@object-ui/react';

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

// The warning is said once per message per module; each row starts unwarned.
beforeEach(() => resetUndeclaredActionNamesWarnings());

describe('object-grid `bulkActions` members are BARE ACTION NAMES (objectui#8071)', () => {
it('1. a name member is resolved against `objectDef.actions` and promoted', async () => {
await renderAndSelectAll({ bulkActions: ['approve'] });
Expand All @@ -199,16 +203,30 @@ describe('object-grid `bulkActions` members are BARE ACTION NAMES (objectui#8071
}
});

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

Expand Down
Loading
Loading