Skip to content

Commit 4874117

Browse files
os-zhuangclaude
andauthored
fix(grid,types): an object-declared bulk action runs over the selected records (#3002) (#3031)
`bulkActions: ['push_down']` dispatched the action NAME in the runner's `type` slot, so it never ran — and the object had nothing to declare a bulk action with, since `bulkActionDefs` was passed through from view JSON verbatim rather than derived from `objectDef.actions`. No spec change needed: `ActionSchema.bulkEnabled` ("whether this action can be applied to multiple selected records") is already the declaration; it just had no consumer. `ObjectGrid` — the single convergence point of the three list callers — now folds three sources into the selection bar via the new pure `resolveBulkActions`: inline-authored defs, object actions flagged `bulkEnabled`, and legacy names resolved against `objectDef.actions`. A derived def carries the source action under `actionDef`; `useBulkExecutor` dispatches it through the action runner once per record with the row attached as `_rowRecord`, reusing BulkActionDialog's params → confirm → progress → result model so params/confirmation are collected once. Failures are attributed per record instead of counted as successes. Also: the bar rendered legacy string buttons only when no defs existed, so a view mixing both silently lost half its buttons. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent c0d0bc8 commit 4874117

10 files changed

Lines changed: 857 additions & 24 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@object-ui/plugin-grid": patch
3+
"@object-ui/types": patch
4+
"@object-ui/app-shell": patch
5+
---
6+
7+
fix(grid): an object-declared bulk action runs over the selected records — objectui#3002
8+
9+
A list view declaring `bulkActions: ['push_down']` rendered a selection-bar
10+
button that never ran the action: `ObjectGrid` dispatched the legacy form as
11+
`{ type: <action name>, params: { records } }`, putting the action *name* in the
12+
runner's `type` slot. Since objectui#2996 that fails loudly instead of
13+
green-toasting a no-op, but it still never ran. Nor could the object declare a
14+
bulk action to resolve against — `bulkActionDefs` was passed through from the
15+
view JSON verbatim, never derived from `objectDef.actions` the way
16+
`rowActionDefs` is derived from `locations: ['list_item']`.
17+
18+
**No spec change was needed.** `ActionSchema.bulkEnabled`*"Whether this
19+
action can be applied to multiple selected records"* — has always been the
20+
declaration; what was missing was a consumer, exactly as framework's own
21+
property-liveness audit recorded (*"engine has `getBulkActions`/`executeBulk`,
22+
but no spec-driven view path calls `executeBulk`"*). So no new `locations`
23+
entry: a list's selection bar is the only surface on which records are
24+
multi-selected, which is what the flag already names. `locations` stays
25+
orthogonal — it places an action's single-record entry, and an action may carry
26+
both (`locations: ['list_item'], bulkEnabled: true` = one row from the kebab, N
27+
rows from the selection bar).
28+
29+
**`ObjectGrid` folds three sources into the selection bar** (new pure
30+
`resolveBulkActions`, the twin of `resolveLegacyRowActions`; `ObjectGrid` is the
31+
single convergence point of all three list callers):
32+
33+
- defs authored inline in the view JSON — unchanged, they win every collision;
34+
- object actions declaring `bulkEnabled: true`**derived**, which is what
35+
"declare a bulk action on the object" now means;
36+
- legacy `bulkActions` names — resolved against `objectDef.actions` and
37+
**promoted** to that def, so they carry the action's label, icon, `visible`
38+
predicate, confirm text and params instead of a bare humanized name. A name
39+
matching a def already on the bar is dropped rather than rendered as a dead
40+
twin; a name matching nothing is still dispatched by name, since a consumer
41+
may have registered a runner handler under it.
42+
43+
**Execution reuses the existing `BulkActionDialog` model** (params → confirm →
44+
progress → result). A derived def carries the source action under `actionDef`,
45+
and `useBulkExecutor` dispatches it through the action runner once per selected
46+
record with the row attached as `_rowRecord` — so `recordIdParam` injection
47+
behaves exactly as it does for a `list_item` row action. Client fan-out is the
48+
only semantics the single-record action contract supports; a server-side "take
49+
every id at once" variant would need its own spec key and endpoint contract.
50+
Params and confirmation are collected once by the dialog and handed to the
51+
runner as values so it never re-prompts per record, per-record toasts are muted
52+
in favour of the dialog's aggregate result, and a failing record is attributed
53+
in the result list (and error CSV) rather than counted as a success.
54+
55+
Also fixed: the bar rendered legacy string buttons **only when no defs
56+
existed**, so a view mixing both silently lost half its buttons. After the fold
57+
the two lists are disjoint, and both render.

packages/app-shell/src/views/ObjectView.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,6 +1419,16 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
14191419
}),
14201420
}))
14211421
: []),
1422+
/**
1423+
* Selection-bar actions. Unlike `rowActionDefs` above, these are
1424+
* NOT derived here: `ObjectGrid` is the single convergence point of
1425+
* all three list callers (this view, plugin-view's ObjectView and
1426+
* plugin-list's ListView), so it folds `objectDef.actions` — the
1427+
* spec's `bulkEnabled: true` declaration, plus any legacy
1428+
* `bulkActions` name that resolves to a declared action — into the
1429+
* def list itself (`resolveBulkActions`, objectui#3002). What we
1430+
* pass through is the view author's own inline declaration.
1431+
*/
14221432
bulkActions: viewDef.bulkActions ?? listSchema.bulkActions,
14231433
bulkActionDefs: (viewDef as any).bulkActionDefs ?? (listSchema as any).bulkActionDefs,
14241434
sharing: viewDef.sharing ?? listSchema.sharing,

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { GroupRow } from './GroupRow';
4545
import { useColumnSummary } from './useColumnSummary';
4646
import { resolveRowCrudAffordances } from './rowCrudAffordances';
4747
import { resolveLegacyRowActions } from './resolveLegacyRowActions';
48+
import { resolveBulkActions } from './resolveBulkActions';
4849
import { RowActionMenu, formatActionLabel } from './components/RowActionMenu';
4950
import { BulkActionBar } from './components/BulkActionBar';
5051
import { BulkActionDialog } from './components/BulkActionDialog';
@@ -236,7 +237,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
236237
// Tenant default currency (ADR-0053) backstops amount cells that lack a code.
237238
const { currency: tenantCurrency } = useLocalization();
238239
const { t } = useGridTranslation();
239-
const { fieldLabel: resolveFieldLabel, translateOptions } = useSafeFieldLabel();
240+
const { fieldLabel: resolveFieldLabel, translateOptions, actionLabel: resolveActionLabel } = useSafeFieldLabel();
240241
const [objectSchema, setObjectSchema] = useState<any>(null);
241242
const [useCardView, setUseCardView] = useState(false);
242243
const [refreshKey, setRefreshKey] = useState(0);
@@ -1633,13 +1634,34 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
16331634
const explicitBulkActions = objectCanDelete
16341635
? declaredBulkActions
16351636
: declaredBulkActions?.filter((a: unknown) => String(a).toLowerCase() !== 'delete');
1636-
const bulkActionDefs: BulkActionDef[] = (Array.isArray(schema.bulkActionDefs)
1637-
? schema.bulkActionDefs
1638-
: []
1639-
).filter((def: BulkActionDef) => objectCanDelete || def?.operation !== 'delete');
1637+
// Legacy `bulkActions` carry a bare action NAME, which the runner cannot
1638+
// execute on its own, and the object's own `bulkEnabled: true` actions were
1639+
// never surfaced at all — resolve both against `objectDef.actions` so they
1640+
// dispatch as real defs. See `resolveBulkActions` (objectui#3002). The
1641+
// canonical `'delete'` is held back: it routes to `onBulkDelete` (which owns
1642+
// confirm + refresh), not the runner, even if the object declares an action
1643+
// that happens to be named `delete`.
1644+
const legacyBulkNames = (explicitBulkActions ?? []).filter(
1645+
(a: unknown) => String(a).toLowerCase() !== 'delete',
1646+
);
1647+
const { defs: resolvedBulkDefs, unresolved: unresolvedBulkActions } = resolveBulkActions({
1648+
bulkActions: legacyBulkNames,
1649+
bulkActionDefs: Array.isArray(schema.bulkActionDefs) ? schema.bulkActionDefs : [],
1650+
objectActions: (objectSchema as any)?.actions,
1651+
localizeLabel: (actionName, fallback) =>
1652+
resolveActionLabel(schema.objectName, actionName, fallback),
1653+
});
1654+
const bulkActionDefs: BulkActionDef[] = resolvedBulkDefs.filter(
1655+
(def: BulkActionDef) => objectCanDelete || def?.operation !== 'delete',
1656+
);
1657+
// Names still dispatched by string: the unresolved ones, plus `'delete'`
1658+
// when the author asked for it — or the implicit bulk-delete affordance.
1659+
const keptDeleteAction = (explicitBulkActions ?? []).filter(
1660+
(a: unknown) => String(a).toLowerCase() === 'delete',
1661+
);
16401662
const effectiveBulkActions: string[] =
16411663
explicitBulkActions && explicitBulkActions.length > 0
1642-
? explicitBulkActions
1664+
? [...unresolvedBulkActions, ...keptDeleteAction]
16431665
: canDelete && onBulkDelete && bulkActionDefs.length === 0
16441666
? ['delete']
16451667
: [];
@@ -1723,6 +1745,47 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
17231745
setActiveBulkRows(expanded);
17241746
})();
17251747
};
1748+
1749+
// Per-record executor for a DERIVED bulk action (objectui#3002) — one
1750+
// declared object action applied to each selected record through the action
1751+
// runner. The dialog already collected params and took the confirmation, so
1752+
// this dispatch strips both from the def: leaving them on would make the
1753+
// runner re-prompt once per record. Toasts are muted for the same reason —
1754+
// the dialog's progress/result panel is the single report for the whole run.
1755+
// (Plain function for the same reason as `resolveBulkRows` above: this point
1756+
// in the body is past render paths that short-circuit, so a hook here would
1757+
// tripwire the rules-of-hooks balance.)
1758+
const runBulkActionRecord = async (
1759+
def: BulkActionDef,
1760+
row: Record<string, unknown>,
1761+
params: Record<string, unknown>,
1762+
): Promise<void> => {
1763+
const source = def.actionDef as Record<string, any> | undefined;
1764+
if (!source) return;
1765+
const {
1766+
params: _declaredParams,
1767+
actionParams: _actionParams,
1768+
confirmText: _confirmText,
1769+
confirm: _confirm,
1770+
// A one-shot reveal (2FA code, fresh OAuth secret) is a single-record
1771+
// affordance by construction, and the runner AWAITS acknowledgement —
1772+
// one modal per record would stall the run behind N dialogs.
1773+
resultDialog: _resultDialog,
1774+
...rest
1775+
} = source;
1776+
const res = await executeAction({
1777+
...rest,
1778+
// `_rowRecord` is the same row-context key the list_item path attaches,
1779+
// so `recordIdParam` / `recordIdField` injection behaves identically.
1780+
params: { ...params, _rowRecord: row },
1781+
toast: { showOnSuccess: false, showOnError: false },
1782+
} as any);
1783+
// Reject so the executor records this row under its own id in the result
1784+
// (and the error CSV) instead of counting a failed action as succeeded.
1785+
if (!res?.success) {
1786+
throw new Error(res?.error || `Action ${def.name} failed`);
1787+
}
1788+
};
17261789
const handleBulkDialogClose = (result?: BulkResult | null) => {
17271790
setActiveBulkDef(null);
17281791
setActiveBulkRows([]);
@@ -2539,6 +2602,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
25392602
dataSource={dataSource as any}
25402603
resource={schema.objectName ?? ''}
25412604
objectFields={objectSchema?.fields}
2605+
runAction={runBulkActionRecord}
25422606
/>
25432607
);
25442608

0 commit comments

Comments
 (0)