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
42 changes: 42 additions & 0 deletions .changeset/bulk-enabled-is-a-tombstone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@object-ui/plugin-grid": patch
"@object-ui/types": patch
"@object-ui/app-shell": patch
---

fix(grid): drop the `bulkEnabled` derivation — the spec key is a tombstone

Follow-up to objectui#3002 / #3031. That change folded two sources into the
selection bar: a view's `bulkActions` names resolved against
`objectDef.actions`, and object actions declaring `ActionSchema.bulkEnabled`.
The second source is dead.

`@objectstack/spec` 17.0.0 retired `action.bulkEnabled` in the #3896 audit
close-out (framework#4054, landed while #3031 was in flight — the spec source
still carried the key when its design was settled). It is now a `retiredKey()`
tombstone, so it is not merely ignored: `defineStack` **hard-rejects** a config
that sets it, and the backend refuses to boot. Browser verification against a
real showcase backend is what surfaced this — the derivation branch could never
run, and #3031's changeset pointed authors at a key that breaks their app.

The tombstone's own prescription is the path that survives:

> the multi-select toolbar is driven by the LIST VIEW's `bulkActions` /
> `bulkActionDefs`, never by this flag … declare the action in the view's
> `bulkActions` instead.

So `resolveBulkActions` now folds exactly two vocabularies — inline-authored
`bulkActionDefs`, and `bulkActions` names promoted to their declared object
action — which is what #3031's other half already did and what the end-to-end
run exercised: naming `showcase_mark_done` in the view's `bulkActions` issued
one `POST /api/v1/actions/showcase_task/showcase_mark_done` per selected
record (10/10 → `done: true, progress: 100` server-side). Everything downstream
of the fold is unchanged: promoted defs still carry the action's label, icon,
`visible`, confirm text and params; still run through `BulkActionDialog`
(params → confirm → progress → result); still dispatch per record with
`_rowRecord` attached; still attribute failures per record.

A stale `bulkEnabled: true` on an object action is now inert rather than a
second path into the bar. Note tsc cannot catch this class of drift here — the
fold reads a loosely-typed `NamedActionDef` with an index signature, so the
retired key never surfaces as `never`.
13 changes: 6 additions & 7 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1421,13 +1421,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
: []),
/**
* Selection-bar actions. Unlike `rowActionDefs` above, these are
* NOT derived here: `ObjectGrid` is the single convergence point of
* all three list callers (this view, plugin-view's ObjectView and
* plugin-list's ListView), so it folds `objectDef.actions` — the
* spec's `bulkEnabled: true` declaration, plus any legacy
* `bulkActions` name that resolves to a declared action — into the
* def list itself (`resolveBulkActions`, objectui#3002). What we
* pass through is the view author's own inline declaration.
* NOT resolved here: `ObjectGrid` is the single convergence point
* of all three list callers (this view, plugin-view's ObjectView
* and plugin-list's ListView), so it resolves each `bulkActions`
* name against `objectDef.actions` and promotes it into the def
* list itself (`resolveBulkActions`, objectui#3002). What we pass
* through is the view author's own declaration.
*/
bulkActions: viewDef.bulkActions ?? listSchema.bulkActions,
bulkActionDefs: (viewDef as any).bulkActionDefs ?? (listSchema as any).bulkActionDefs,
Expand Down
33 changes: 15 additions & 18 deletions packages/plugin-grid/demo/bulk-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,14 @@
* on-page panel instead of hitting a server).
*
* What it exercises:
* 1. `bulk_mark_done` is declared on the OBJECT with `bulkEnabled: true` and
* is named by no view — before #3002 an object simply could not express a
* bulk action, so this button did not exist.
* 2. `bulk_recalc_estimate` is declared on the object WITHOUT the flag; the
* view names it in legacy `bulkActions: [...]`. That name used to be
* dispatched as `{ type: 'bulk_recalc_estimate' }` and never ran.
* 3. `legacy_only_handler` resolves to no declared action and stays a
* 1. `bulk_mark_done` and `bulk_recalc_estimate` are declared on the object
* and NAMED in the view's `bulkActions` — the only way to declare a bulk
* 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.
* 4. Record `t3` fails server-side (422), proving per-record attribution: the
* 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.
*/
import * as React from 'react';
Expand Down Expand Up @@ -53,9 +52,8 @@ const ROWS = [
];

/**
* `bulkEnabled: true` is the spec's object-level declaration — "this action can
* be applied to multiple selected records". It also carries `locations:
* ['list_item']`, so the same action is a row entry AND a bulk entry.
* Also carries `locations: ['list_item']`, so naming it in the view's
* `bulkActions` makes the same action a row entry AND a bulk entry.
*/
const MARK_DONE = {
name: 'bulk_mark_done',
Expand All @@ -65,10 +63,9 @@ const MARK_DONE = {
target: '/api/demo/mark-done',
recordIdParam: 'taskId',
locations: ['list_item'],
bulkEnabled: true,
};

/** No `bulkEnabled` — the VIEW names it in legacy `bulkActions` instead. */
/** Surfaces only on the record overflow menu — until the view names it. */
const RECALC = {
name: 'bulk_recalc_estimate',
label: 'Recalculate Estimate',
Expand Down Expand Up @@ -171,8 +168,8 @@ const schema: any = {
{ field: 'progress', label: 'Progress' },
],
pagination: { pageSize: 50 },
// Legacy bare names: one resolves to a declared action, one doesn't.
bulkActions: ['bulk_recalc_estimate', 'legacy_only_handler'],
// Bare names: two resolve to declared actions, one doesn't.
bulkActions: ['bulk_mark_done', 'bulk_recalc_estimate', 'legacy_only_handler'],
};

function Demo() {
Expand All @@ -185,9 +182,9 @@ function Demo() {
Object-declared bulk actions — objectui#3002
</h1>
<p style={{ fontSize: 12, color: 'hsl(var(--muted-foreground))', margin: '4px 0 0' }}>
Select rows → the bar shows <b>Mark Done</b> (derived from the object&apos;s
<code> bulkEnabled: true</code>), <b>Recalculate Estimate</b> (legacy name promoted to
its declared action) and <b>Legacy Only Handler</b> (unresolvable, dispatched by name).
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) and <b>Legacy Only Handler</b> (unresolvable, dispatched by name).
</p>
</header>

Expand Down
9 changes: 4 additions & 5 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1634,10 +1634,9 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
const explicitBulkActions = objectCanDelete
? declaredBulkActions
: declaredBulkActions?.filter((a: unknown) => String(a).toLowerCase() !== 'delete');
// Legacy `bulkActions` carry a bare action NAME, which the runner cannot
// execute on its own, and the object's own `bulkEnabled: true` actions were
// never surfaced at all — resolve both against `objectDef.actions` so they
// dispatch as real defs. See `resolveBulkActions` (objectui#3002). The
// `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
// 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`.
Expand Down Expand Up @@ -1746,7 +1745,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
})();
};

// Per-record executor for a DERIVED bulk action (objectui#3002) — one
// Per-record executor for a PROMOTED bulk action (objectui#3002) — one
// declared object action applied to each selected record through the action
// runner. The dialog already collected params and took the confirmation, so
// this dispatch strips both from the def: leaving them on would make the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,20 @@
*/

/**
* An object-declared bulk action must reach the ActionRunner as a real action
* A view's `bulkActions` name must reach the ActionRunner as a real action
* DEF, applied to every selected record (objectui#3002).
*
* Before this, `bulkActions: ['push_down']` dispatched the action NAME in the
* runner's `type` slot — `{ type: 'push_down', params: { records } }` — which
* matches no built-in type and no handler, so it fell through the runner's
* schema fallback (green success toast pre-#2996, a loud failure after it).
* Either way the action never ran. And an object could not declare a bulk
* action at all: `bulkActionDefs` was passed through from view JSON verbatim,
* never derived from `objectDef.actions`.
* Either way the action never ran, and the button carried none of the def's
* label / icon / params: `bulkActionDefs` was passed through from view JSON
* verbatim, never resolved against `objectDef.actions`.
*
* Naming the action in the view is the ONLY way to declare a bulk action —
* spec 17 retired `action.bulkEnabled` as a tombstone (framework#4054) and
* prescribes exactly this. See `resolveBulkActions`.
*
* These drive the REAL ObjectGrid through a real ActionProvider and assert the
* user-visible outcome: selecting rows and clicking the button issues one
Expand Down Expand Up @@ -51,17 +55,17 @@ const ROWS = [
];

/**
* The object's own bulk action. The label is deliberately NOT the humanization
* The object's declared action. The label is deliberately NOT the humanization
* of the name ("Push Down"), so an assertion on it distinguishes the resolved
* def from the legacy path — which could only ever render `formatActionLabel`.
* def from the by-name path — which could only ever render
* `formatActionLabel`.
*/
const PUSH_DOWN = {
name: 'push_down',
label: '下推',
type: 'api',
target: '/api/v1/plans/push',
recordIdParam: 'planId',
bulkEnabled: true,
};

let fetchMock: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -121,17 +125,32 @@ afterEach(() => {
vi.unstubAllGlobals();
});

describe('object-declared bulk actions (objectui#3002)', () => {
it('surfaces a bulkEnabled object action with no view declaration at all', async () => {
await renderAndSelectAll({ objectActions: [PUSH_DOWN] });
describe('view-declared bulk actions (objectui#3002)', () => {
it('renders the declared action label, not the humanized name', async () => {
await renderAndSelectAll({
objectActions: [PUSH_DOWN],
schema: { bulkActions: ['push_down'] },
});

const button = await screen.findByTestId('bulk-action-push_down');
// The object action's own label, not `formatActionLabel('push_down')`.
expect(button).toHaveTextContent('下推');
});

it('surfaces nothing for an object action the view never names', async () => {
// `action.bulkEnabled` is a spec-17 tombstone, so there is no object-level
// opt-in: an unnamed action must not reach the selection bar.
renderGrid({ objectActions: [{ ...PUSH_DOWN, bulkEnabled: true }] });
await waitFor(() => expect(screen.getByText('Plan A')).toBeInTheDocument());

expect(screen.queryByTestId('bulk-action-push_down')).not.toBeInTheDocument();
});

it('issues one request per selected record instead of no-opping', async () => {
await renderAndSelectAll({ objectActions: [PUSH_DOWN] });
await renderAndSelectAll({
objectActions: [PUSH_DOWN],
schema: { bulkActions: ['push_down'] },
});

fireEvent.click(await screen.findByTestId('bulk-action-push_down'));
// No params on this action → the dialog opens straight on confirm.
Expand All @@ -148,22 +167,6 @@ describe('object-declared bulk actions (objectui#3002)', () => {
expect(sentIds).toEqual(['r1', 'r2']);
});

it('resolves a legacy bulkActions name to the declared action', async () => {
// The reported shape: the object never flagged the action, the view names it.
const unflagged = { ...PUSH_DOWN, bulkEnabled: undefined };
await renderAndSelectAll({
objectActions: [unflagged],
schema: { bulkActions: ['push_down'] },
});

const button = await screen.findByTestId('bulk-action-push_down');
expect(button).toHaveTextContent('下推');

fireEvent.click(button);
fireEvent.click(await screen.findByRole('button', { name: 'Run' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
});

it('reports per-record failures instead of counting them as successes', async () => {
fetchMock.mockResolvedValue({
ok: false,
Expand All @@ -172,7 +175,10 @@ describe('object-declared bulk actions (objectui#3002)', () => {
headers: { get: () => 'application/json' },
json: async () => ({ error: 'precondition not met' }),
});
await renderAndSelectAll({ objectActions: [PUSH_DOWN] });
await renderAndSelectAll({
objectActions: [PUSH_DOWN],
schema: { bulkActions: ['push_down'] },
});

fireEvent.click(await screen.findByTestId('bulk-action-push_down'));
fireEvent.click(await screen.findByRole('button', { name: 'Run' }));
Expand All @@ -184,27 +190,27 @@ describe('object-declared bulk actions (objectui#3002)', () => {
await waitFor(() => expect(screen.getByText(/Succeeded 0 \/ 2/)).toBeInTheDocument());
expect(screen.getByTestId('bulk-error-row-r1')).toHaveTextContent('HTTP 422');
expect(screen.getByTestId('bulk-error-row-r2')).toBeInTheDocument();
// A derived def re-dispatches for one record, so the row keeps its Retry.
// A promoted def re-dispatches for one record, so the row keeps its Retry.
expect(screen.getByTestId('bulk-error-retry-r1')).toBeInTheDocument();
});

it('renders one button when a legacy name repeats a derived action', async () => {
it('renders one button for a repeated name', async () => {
await renderAndSelectAll({
objectActions: [PUSH_DOWN],
schema: { bulkActions: ['push_down'] },
schema: { bulkActions: ['push_down', 'push_down'] },
});

await waitFor(() => expect(screen.getAllByTestId('bulk-action-push_down')).toHaveLength(1));
});

it('keeps an unresolvable legacy name alongside the derived defs', async () => {
it('keeps an unresolvable name alongside the promoted defs', async () => {
// A name the object never declared may still have a runner handler
// registered under it; hiding it because some OTHER def exists dropped
// half the bar's buttons.
const handler = vi.fn(async () => ({ success: true }));
await renderAndSelectAll({
objectActions: [PUSH_DOWN],
schema: { bulkActions: ['crm_only_handler'] },
schema: { bulkActions: ['push_down', 'crm_only_handler'] },
handlers: { crm_only_handler: handler },
});

Expand Down
Loading
Loading