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

fix(grid): an object-declared bulk action runs over the selected records — objectui#3002

A list view declaring `bulkActions: ['push_down']` rendered a selection-bar
button that never ran the action: `ObjectGrid` dispatched the legacy form as
`{ type: <action name>, params: { records } }`, putting the action *name* in the
runner's `type` slot. Since objectui#2996 that fails loudly instead of
green-toasting a no-op, but it still never ran. Nor could the object declare a
bulk action to resolve against — `bulkActionDefs` was passed through from the
view JSON verbatim, never derived from `objectDef.actions` the way
`rowActionDefs` is derived from `locations: ['list_item']`.

**No spec change was needed.** `ActionSchema.bulkEnabled` — *"Whether this
action can be applied to multiple selected records"* — has always been the
declaration; what was missing was a consumer, exactly as framework's own
property-liveness audit recorded (*"engine has `getBulkActions`/`executeBulk`,
but no spec-driven view path calls `executeBulk`"*). So no new `locations`
entry: a list's selection bar is the only surface on which records are
multi-selected, which is what the flag already names. `locations` stays
orthogonal — it places an action's single-record entry, and an action may carry
both (`locations: ['list_item'], bulkEnabled: true` = one row from the kebab, N
rows from the selection bar).

**`ObjectGrid` folds three sources into the selection bar** (new pure
`resolveBulkActions`, the twin of `resolveLegacyRowActions`; `ObjectGrid` is the
single convergence point of all three list callers):

- defs authored inline in the view JSON — unchanged, they win every collision;
- object actions declaring `bulkEnabled: true` — **derived**, which is what
"declare a bulk action on the object" now means;
- legacy `bulkActions` names — resolved against `objectDef.actions` and
**promoted** to that def, so they carry the action's label, icon, `visible`
predicate, confirm text and params instead of a bare humanized name. A name
matching a def already on the bar is dropped rather than rendered as a dead
twin; a name matching nothing is still dispatched by name, since a consumer
may have registered a runner handler under it.

**Execution reuses the existing `BulkActionDialog` model** (params → confirm →
progress → result). A derived def carries the source action under `actionDef`,
and `useBulkExecutor` dispatches it through the action runner once per selected
record with the row attached as `_rowRecord` — so `recordIdParam` injection
behaves exactly as it does for a `list_item` row action. Client fan-out is the
only semantics the single-record action contract supports; a server-side "take
every id at once" variant would need its own spec key and endpoint contract.
Params and confirmation are collected once by the dialog and handed to the
runner as values so it never re-prompts per record, per-record toasts are muted
in favour of the dialog's aggregate result, and a failing record is attributed
in the result list (and error CSV) rather than counted as a success.

Also fixed: the bar rendered legacy string buttons **only when no defs
existed**, so a view mixing both silently lost half its buttons. After the fold
the two lists are disjoint, and both render.
10 changes: 10 additions & 0 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,16 @@ 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.
*/
bulkActions: viewDef.bulkActions ?? listSchema.bulkActions,
bulkActionDefs: (viewDef as any).bulkActionDefs ?? (listSchema as any).bulkActionDefs,
sharing: viewDef.sharing ?? listSchema.sharing,
Expand Down
76 changes: 70 additions & 6 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { GroupRow } from './GroupRow';
import { useColumnSummary } from './useColumnSummary';
import { resolveRowCrudAffordances } from './rowCrudAffordances';
import { resolveLegacyRowActions } from './resolveLegacyRowActions';
import { resolveBulkActions } from './resolveBulkActions';
import { RowActionMenu, formatActionLabel } from './components/RowActionMenu';
import { BulkActionBar } from './components/BulkActionBar';
import { BulkActionDialog } from './components/BulkActionDialog';
Expand Down Expand Up @@ -236,7 +237,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
// Tenant default currency (ADR-0053) backstops amount cells that lack a code.
const { currency: tenantCurrency } = useLocalization();
const { t } = useGridTranslation();
const { fieldLabel: resolveFieldLabel, translateOptions } = useSafeFieldLabel();
const { fieldLabel: resolveFieldLabel, translateOptions, actionLabel: resolveActionLabel } = useSafeFieldLabel();
const [objectSchema, setObjectSchema] = useState<any>(null);
const [useCardView, setUseCardView] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
Expand Down Expand Up @@ -1633,13 +1634,34 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
const explicitBulkActions = objectCanDelete
? declaredBulkActions
: declaredBulkActions?.filter((a: unknown) => String(a).toLowerCase() !== 'delete');
const bulkActionDefs: BulkActionDef[] = (Array.isArray(schema.bulkActionDefs)
? schema.bulkActionDefs
: []
).filter((def: BulkActionDef) => objectCanDelete || def?.operation !== '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
// 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({
bulkActions: legacyBulkNames,
bulkActionDefs: Array.isArray(schema.bulkActionDefs) ? schema.bulkActionDefs : [],
objectActions: (objectSchema as any)?.actions,
localizeLabel: (actionName, fallback) =>
resolveActionLabel(schema.objectName, actionName, fallback),
});
const bulkActionDefs: BulkActionDef[] = resolvedBulkDefs.filter(
(def: BulkActionDef) => objectCanDelete || def?.operation !== 'delete',
);
// Names still dispatched by string: the unresolved ones, plus `'delete'`
// when the author asked for it — or the implicit bulk-delete affordance.
const keptDeleteAction = (explicitBulkActions ?? []).filter(
(a: unknown) => String(a).toLowerCase() === 'delete',
);
const effectiveBulkActions: string[] =
explicitBulkActions && explicitBulkActions.length > 0
? explicitBulkActions
? [...unresolvedBulkActions, ...keptDeleteAction]
: canDelete && onBulkDelete && bulkActionDefs.length === 0
? ['delete']
: [];
Expand Down Expand Up @@ -1723,6 +1745,47 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
setActiveBulkRows(expanded);
})();
};

// Per-record executor for a DERIVED 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
// runner re-prompt once per record. Toasts are muted for the same reason —
// the dialog's progress/result panel is the single report for the whole run.
// (Plain function for the same reason as `resolveBulkRows` above: this point
// in the body is past render paths that short-circuit, so a hook here would
// tripwire the rules-of-hooks balance.)
const runBulkActionRecord = async (
def: BulkActionDef,
row: Record<string, unknown>,
params: Record<string, unknown>,
): Promise<void> => {
const source = def.actionDef as Record<string, any> | undefined;
if (!source) return;
const {
params: _declaredParams,
actionParams: _actionParams,
confirmText: _confirmText,
confirm: _confirm,
// A one-shot reveal (2FA code, fresh OAuth secret) is a single-record
// affordance by construction, and the runner AWAITS acknowledgement —
// one modal per record would stall the run behind N dialogs.
resultDialog: _resultDialog,
...rest
} = source;
const res = await executeAction({
...rest,
// `_rowRecord` is the same row-context key the list_item path attaches,
// so `recordIdParam` / `recordIdField` injection behaves identically.
params: { ...params, _rowRecord: row },
toast: { showOnSuccess: false, showOnError: false },
} as any);
// Reject so the executor records this row under its own id in the result
// (and the error CSV) instead of counting a failed action as succeeded.
if (!res?.success) {
throw new Error(res?.error || `Action ${def.name} failed`);
}
};
const handleBulkDialogClose = (result?: BulkResult | null) => {
setActiveBulkDef(null);
setActiveBulkRows([]);
Expand Down Expand Up @@ -2539,6 +2602,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
dataSource={dataSource as any}
resource={schema.objectName ?? ''}
objectFields={objectSchema?.fields}
runAction={runBulkActionRecord}
/>
);

Expand Down
Loading
Loading