Skip to content

Commit a149e90

Browse files
os-zhuangclaude
andauthored
fix(grid): a bulk delete / by-name action clears the row checkboxes too (#3056) (#3058)
`ObjectGrid` carries two selection sources that must move together: `selectedRows` (ours — drives the toolbar) and the data-table's internal `selectedRowIds` (drives the checkboxes, cleared only when the host bumps `selectionResetKey`). `handleBulkDialogClose` reset both; `dispatchBulkAction` reset only the first, on BOTH of its branches — the bulk-delete route through `onBulkDelete` and the by-name runner dispatch. So a successful run left the toolbar gone and every row still ticked, with no affordance to act on them. The invariant was already written down in `handleBulkDialogClose`'s comment ("ticked rows with no toolbar"); it just was not upheld on the other path. All three call sites now go through one `resetSelection()` helper, so the rule is structural instead of three places remembering to agree. Failure semantics are unchanged: an action reporting `success: false` keeps both sources so the user can retry the same rows. Reverse-control: with only ObjectGrid.tsx reverted, the two new positive assertions fail (checkboxes stay 'checked' after a successful by-name action and after a bulk delete). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 9369d4b commit a149e90

3 files changed

Lines changed: 126 additions & 15 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@object-ui/plugin-grid": patch
3+
---
4+
5+
fix(grid): a bulk delete / by-name action clears the row checkboxes, not just the toolbar — objectui#3056
6+
7+
After a successful bulk delete (or a bulk action dispatched to a
8+
consumer-registered runner handler), the selection toolbar vanished but every
9+
row stayed visibly ticked. The user was left on a page of selected rows with no
10+
toolbar to act on them, and no way back except unticking each row or reloading.
11+
12+
`ObjectGrid` carries two selection sources that must move together:
13+
`selectedRows` (ours — drives the toolbar) and the data-table's internal
14+
`selectedRowIds` (drives the checkboxes, cleared only when the host bumps
15+
`selectionResetKey`). `handleBulkDialogClose` reset both; `dispatchBulkAction`
16+
reset only the first, on both of its branches.
17+
18+
Both now go through one `resetSelection()` helper — including the dialog path,
19+
so the invariant is structural rather than three call sites remembering to
20+
agree. Failure semantics are untouched: a by-name action that reports
21+
`success: false` still keeps the toolbar AND the checkboxes so the user can fix
22+
the cause and retry the same rows.

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1711,24 +1711,35 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
17111711
// Bulk action dispatcher — for the implicit 'delete' action, route through
17121712
// the consumer-provided onBulkDelete (which already knows about confirm +
17131713
// refresh). Other actions fall through to the generic action runner.
1714+
//
1715+
// [#3056] Both branches must clear BOTH selection sources. `selectedRows` is
1716+
// ours and drives the toolbar; the row checkboxes live inside the data-table
1717+
// and only clear when `selectionResetKey` moves. Bumping one without the
1718+
// other strands the user on a page of ticked rows with no toolbar to act on
1719+
// them — the exact drift `handleBulkDialogClose` below already guards.
1720+
const resetSelection = () => {
1721+
setSelectedRows([]);
1722+
setSelectAllMatching(false);
1723+
setSelectionResetKey(k => k + 1);
1724+
};
1725+
17141726
const dispatchBulkAction = (action: string, rows: any[]) => {
17151727
void (async () => {
17161728
const expanded = await resolveBulkRows(rows);
17171729
if (action === 'delete' && onBulkDelete) {
17181730
onBulkDelete(expanded);
1719-
setSelectedRows([]);
1720-
setSelectAllMatching(false);
1731+
resetSelection();
17211732
return;
17221733
}
1723-
// A string bulk action (e.g. 下推 / 派工) mutated the selected records,
1724-
// usually through a custom API that never touches dataSource.update — so
1725-
// nothing else signals the grid to refetch. On success, reset the
1726-
// selection toolbar and refresh so the list reflects the server state
1727-
// (mirrors the delete branch and handleBulkDialogClose).
1734+
// A string bulk action (e.g. a consumer-registered runner handler)
1735+
// mutated the selected records, usually through a custom API that never
1736+
// touches dataSource.update — so nothing else signals the grid to
1737+
// refetch. On success, reset the selection and refresh so the list
1738+
// reflects the server state (mirrors the delete branch and
1739+
// handleBulkDialogClose).
17281740
const res = await executeAction({ type: action, params: { records: expanded } });
17291741
if (res?.success) {
1730-
setSelectedRows([]);
1731-
setSelectAllMatching(false);
1742+
resetSelection();
17321743
setRefreshKey(k => k + 1);
17331744
}
17341745
})();
@@ -1791,13 +1802,9 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
17911802
// Only reset selection when the run actually changed something. A total
17921803
// failure (0 succeeded — e.g. a "推计划" precondition error) leaves the data
17931804
// untouched, so we keep the selection *and* the toolbar so the user can fix
1794-
// it and retry the same rows. Both selection sources must move together, or
1795-
// the checkboxes (table-internal) and the toolbar (our `selectedRows`) drift
1796-
// out of sync — ticked rows with no toolbar.
1805+
// it and retry the same rows.
17971806
if (result && result.succeeded > 0) {
1798-
setSelectedRows([]);
1799-
setSelectAllMatching(false);
1800-
setSelectionResetKey(k => k + 1);
1807+
resetSelection();
18011808
// Trigger refresh via the same path used by single-record mutations.
18021809
setRefreshKey(k => k + 1);
18031810
}

packages/plugin-grid/src/__tests__/bulkActionRefresh.test.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,85 @@ describe('ObjectGrid — bulk dialog Done keeps selection in sync', () => {
291291
expect(headerChecked()).toBe('checked');
292292
});
293293
});
294+
295+
/**
296+
* Same desync, the OTHER dispatcher (#3056). `dispatchBulkAction` — the
297+
* by-name path and the bulk-delete path — cleared `selectedRows` (toolbar) but
298+
* never bumped `selectionResetKey` (checkboxes), so a successful run left the
299+
* user staring at a page of ticked rows with no toolbar to act on them. Only
300+
* the rich-def path above upheld the invariant; these pin the other two.
301+
*
302+
* Both survive objectui#3002: a name that resolves to a declared object action
303+
* is now promoted to a def and routes through the dialog instead, so what still
304+
* reaches `dispatchBulkAction` is a consumer-registered runner handler
305+
* (`approve` here) and the canonical `'delete'`.
306+
*/
307+
describe('ObjectGrid — dispatchBulkAction keeps selection in sync (#3056)', () => {
308+
const headerChecked = () =>
309+
(document.querySelector('thead [role="checkbox"]') as HTMLElement)?.getAttribute('data-state');
310+
311+
it('clears the row checkboxes (not just the toolbar) after a by-name action', async () => {
312+
const ds = makeDataSource();
313+
const approve = vi.fn(async () => ({ success: true }));
314+
renderGrid(ds, { approve });
315+
316+
await waitFor(() => expect(screen.getByText('Plan A')).toBeInTheDocument());
317+
fireEvent.click(document.querySelector('thead [role="checkbox"]') as HTMLElement);
318+
await waitFor(() => expect(headerChecked()).toBe('checked'));
319+
320+
fireEvent.click(await screen.findByTestId('bulk-action-approve'));
321+
await waitFor(() => expect(approve).toHaveBeenCalledTimes(1));
322+
323+
await waitFor(() =>
324+
expect(screen.queryByTestId('bulk-actions-bar')).not.toBeInTheDocument(),
325+
);
326+
await waitFor(() => expect(headerChecked()).toBe('unchecked'));
327+
});
328+
329+
it('leaves the checkboxes ticked when the by-name action fails', async () => {
330+
const ds = makeDataSource();
331+
const approve = vi.fn(async () => ({ success: false, error: 'nope' }));
332+
renderGrid(ds, { approve });
333+
334+
await waitFor(() => expect(screen.getByText('Plan A')).toBeInTheDocument());
335+
fireEvent.click(document.querySelector('thead [role="checkbox"]') as HTMLElement);
336+
await waitFor(() => expect(headerChecked()).toBe('checked'));
337+
338+
fireEvent.click(await screen.findByTestId('bulk-action-approve'));
339+
await waitFor(() => expect(approve).toHaveBeenCalledTimes(1));
340+
341+
// Failure keeps BOTH sources, so the user can retry the same rows.
342+
await new Promise((r) => setTimeout(r, 50));
343+
expect(screen.queryByTestId('bulk-actions-bar')).toBeInTheDocument();
344+
expect(headerChecked()).toBe('checked');
345+
});
346+
347+
it('clears the row checkboxes after a bulk delete', async () => {
348+
const ds = makeDataSource();
349+
const onBulkDelete = vi.fn();
350+
const schema: any = {
351+
type: 'object-grid',
352+
objectName: OBJECT,
353+
bulkActions: ['delete'],
354+
columns: [{ field: 'name', label: 'Name' }],
355+
pagination: { pageSize: 50 },
356+
};
357+
render(
358+
<ActionProvider handlers={{}}>
359+
<ObjectGrid schema={schema} dataSource={ds} onBulkDelete={onBulkDelete} />
360+
</ActionProvider>,
361+
);
362+
363+
await waitFor(() => expect(screen.getByText('Plan A')).toBeInTheDocument());
364+
fireEvent.click(document.querySelector('thead [role="checkbox"]') as HTMLElement);
365+
await waitFor(() => expect(headerChecked()).toBe('checked'));
366+
367+
fireEvent.click(await screen.findByTestId('bulk-action-delete'));
368+
await waitFor(() => expect(onBulkDelete).toHaveBeenCalledTimes(1));
369+
370+
await waitFor(() =>
371+
expect(screen.queryByTestId('bulk-actions-bar')).not.toBeInTheDocument(),
372+
);
373+
await waitFor(() => expect(headerChecked()).toBe('unchecked'));
374+
});
375+
});

0 commit comments

Comments
 (0)