Skip to content

Commit 10cbfac

Browse files
fix(plugin-grid): a bulk action's result lists each refused record by its title (#27996)
domain:ui#3 · os-bill · session_01Utp6CaJoinvGQSbHeDaWBe <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg4i9JiKjEjwtuVzd4gKy9hB)_ ## 维护者速读 - 改了什么:列表批量操作的结果面板里,每条被拒绝的记录现在按记录标题列出(如「Grace Hopper: 已在该活动中」),不再只显示记录 ID。 - 为什么改:#27380 第 1 行要求「列出每一条拒绝,并带上它的记录」;实测当前 main 上计数已按每行的真实回答统计(被拒的行进 Failed),缺的只是记录的名字。 - 风险与回滚:只改结果面板一处标签的文字来源,计数、重试、错误 CSV 都不变;回滚即还原 BulkActionDialog.tsx 一处。 - 先看:`ui/packages/plugin-grid/src/components/BulkActionDialog.tsx` 的 `refusedRecordName`;测试 `bulkResultPerRecord-27380.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-grid/src/__tests__/bulkResultPerRecord-27380.test.tsx`,预期 3 个用例全绿。 - 需要你做的:无,席位落地。 Fixes #27380 Clause-②: no Metadata-protocol: none ## What changes `BulkActionDialog`'s result panel lists each refusal under the record it refused. The label used to be the record's raw id; it is now the record's title, resolved by the same `affectedRecordName` the confirm step already uses to list the affected records (the declared `nameField`, or the resolver's `Record #ID` floor). An entry that is not a record of the run (a row with no id, a batch the server answered with a count only) keeps its id. Counts, Retry and the error CSV are unchanged. This PR takes row 1 of #27380. Rows 2, 3 and 4 are carded by the seat: row 2 is #27999 (Blocked-by #27907, which holds the file it needs), rows 3 and 4 are #28000 and #28001 (spec and server changes). With every row owned, this PR closes the card. ## Tests `ui/packages/plugin-grid/src/__tests__/bulkResultPerRecord-27380.test.tsx` drives the real `ObjectGrid`, a real `ActionProvider` and the real `createServerActionHandler` over a transport that answers per record (HotCRM's "Add to Campaign" shape, three leads, two refused with a 400): - the counts follow each record's own answer (`Succeeded 1 / 3 · Failed 2`): already true on `main`, pinned so it stays true; - every refusal is listed under its record, by the record's title; - a clean run reports `Succeeded 3 / 3` and no refusal list. ## Acceptance notes - Row 1, measured on `main` 5f6dc96 through the chain above: a record whose dispatch is refused already counts as failed and is listed; the card's "Succeeded 2 / 2 whatever each row did" did not reproduce for a refusal the server answers with a 4xx. A handler that answers 200 for a record it did nothing to is a success by the `/actions` door's contract (a handler that refuses throws, which the door answers 400), so that half needs no platform change. - Row 1's "shows only the last refusal" did not reproduce either: the per-record path lists every refusal (three records, two refusals, both listed). ## Verification Head `17fa19bc` (`main` merged in). Local subset only; every family CI runs is read from this PR's check-runs. - `pnpm --dir ui exec vitest run packages/plugin-grid/`: lock VERDICT command-exit 0, Test Files 261 passed (261), Tests 2289 passed (2289). - `pnpm --filter @object-ui/plugin-grid run type-check`: lock VERDICT command-exit 0. - Ablation (fix committed first, mutation through `scripts/ablation-replace.mjs`): the result label put back to the raw id, anchor 1 to 0 on disk, `lists every refusal, each under the record it refused, by that record's title` fails (expected `Grace Hopper`, received `lead_2:`), 1 failed and 2 passed; restored, blob equals HEAD and `git diff HEAD` empty. - `node scripts/pm/dispatch-gates.mjs --ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. - `node scripts/check-new-cross-file-line-citations.mjs` (from `ui/`): 0 new citations. <details><summary>Derived gate families (34), each command and its exit code</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZM489wSkBwix6Gjde)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3a09125 commit 10cbfac

3 files changed

Lines changed: 172 additions & 1 deletion

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/plugin-grid': patch
3+
---
4+
5+
fix(plugin-grid): a bulk action's result lists each refused record by its title, not by its id
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
When a bulk action is refused for some of the selected records, the result panel lists every refusal with the reason the server gave. Each refusal used to be labelled with the record's raw id (`lead_2: … is already a member of this campaign`), so the user had to work out which record it was. It is now labelled with the record's title, resolved the same way the confirm step lists the affected records (the object's `nameField`, or `Record #<id>` when the record has no readable title). An entry that is not a record of the run, such as a row with no id or a batch the server answered with a count only, keeps its id. The counts and the error CSV are unchanged.
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack-ai/objectstack#27380 row 1 — a bulk action's result panel
11+
* reports what each record's own dispatch answered, and names every refused
12+
* record by its title.
13+
*
14+
* Measured on HotCRM's Leads list: "Add to Campaign", a single-record script
15+
* action named in the view's `bulkActions`, run over two leads of which the
16+
* server refuses one. These drive the REAL ObjectGrid, a real ActionProvider
17+
* and the real `createServerActionHandler` (the dispatch the console
18+
* registers as `script`) over a stubbed transport that answers per record.
19+
*
20+
* A record the server refuses answers a 4xx (the `/actions` door's rule:
21+
* a handler that says no throws), so the counts follow each record's answer;
22+
* the first case pins that. What a refusal is listed under is the record's
23+
* title, read the way the confirm step lists the affected records.
24+
*/
25+
26+
import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest';
27+
import { render, screen, waitFor, fireEvent, cleanup, within } from '@testing-library/react';
28+
import '@testing-library/jest-dom';
29+
import React from 'react';
30+
31+
vi.mock('@object-ui/permissions', async (importOriginal) => {
32+
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
33+
return {
34+
...actual,
35+
usePermissions: () => ({ isLoaded: false, checkField: () => true, getObjectApiOperations: () => undefined, can: () => true }),
36+
};
37+
});
38+
39+
import { ObjectGrid } from '../ObjectGrid';
40+
import { registerAllFields } from '@object-ui/fields';
41+
import { ActionProvider } from '@object-ui/react';
42+
import { createServerActionHandler, type ServerActionFetch } from '@object-ui/core';
43+
44+
registerAllFields();
45+
46+
beforeAll(() => {
47+
if (!Element.prototype.scrollIntoView) {
48+
Element.prototype.scrollIntoView = vi.fn() as any;
49+
}
50+
});
51+
52+
const OBJECT = 'crm_lead';
53+
const ROWS = [
54+
{ id: 'lead_1', name: 'Ada Lovelace' },
55+
{ id: 'lead_2', name: 'Grace Hopper' },
56+
{ id: 'lead_3', name: 'Edsger Dijkstra' },
57+
];
58+
59+
/** HotCRM's shape, the keys this chain reads: a record-scoped script action. */
60+
const ADD_TO_CAMPAIGN = {
61+
name: 'add_to_campaign',
62+
label: 'Add to Campaign',
63+
type: 'script',
64+
objectName: OBJECT,
65+
locations: ['list_toolbar', 'list_item'],
66+
};
67+
68+
/** A transport that answers each record from `refusals` (record id → message), 200 otherwise. */
69+
function perRecordFetch(refusals: Record<string, string>) {
70+
return vi.fn(async (_url: string, init: { body: string }) => {
71+
const { recordId } = JSON.parse(init.body) as { recordId: string };
72+
const refusal = refusals[recordId];
73+
return refusal
74+
? { ok: false, status: 400, json: async () => ({ success: false, error: { code: 'VALIDATION_ERROR', message: refusal } }) }
75+
: { ok: true, status: 200, json: async () => ({ success: true, data: { added: 1 } }) };
76+
});
77+
}
78+
79+
async function runOverAll(fetch: ReturnType<typeof perRecordFetch>) {
80+
const dataSource: any = {
81+
find: vi.fn(async () => ({ data: ROWS.map(r => ({ ...r })), total: ROWS.length, hasMore: false, pageSize: 50 })),
82+
getObjectSchema: async (name: string) => ({
83+
name,
84+
nameField: 'name',
85+
fields: { id: { type: 'text' }, name: { type: 'text', label: 'Name' } },
86+
actions: [ADD_TO_CAMPAIGN],
87+
}),
88+
};
89+
const script = createServerActionHandler({ fetch: fetch as unknown as ServerActionFetch });
90+
render(
91+
<ActionProvider handlers={{ script }}>
92+
<ObjectGrid
93+
schema={{
94+
type: 'object-grid',
95+
objectName: OBJECT,
96+
columns: [{ field: 'name', label: 'Name' }],
97+
pagination: { pageSize: 50 },
98+
bulkActions: ['add_to_campaign'],
99+
} as any}
100+
dataSource={dataSource}
101+
/>
102+
</ActionProvider>,
103+
);
104+
await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeInTheDocument());
105+
await waitFor(() => expect(document.querySelector('thead [role="checkbox"]')).toBeTruthy());
106+
fireEvent.click(document.querySelector('thead [role="checkbox"]') as HTMLElement);
107+
fireEvent.click(await screen.findByTestId('bulk-action-add_to_campaign'));
108+
fireEvent.click(await screen.findByRole('button', { name: 'Run' }));
109+
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(ROWS.length));
110+
}
111+
112+
afterEach(() => {
113+
cleanup();
114+
});
115+
116+
describe('bulk result reports each record’s own answer (objectstack#27380 row 1)', () => {
117+
it('counts the refused records apart from the ones that landed', async () => {
118+
const fetch = perRecordFetch({
119+
lead_2: 'Grace Hopper is already a member of this campaign',
120+
lead_3: 'Edsger Dijkstra has opted out of marketing',
121+
});
122+
await runOverAll(fetch);
123+
124+
await waitFor(() => expect(screen.getByText(/Succeeded 1 \/ 3/)).toBeInTheDocument());
125+
expect(screen.getByText(/Failed 2/)).toBeInTheDocument();
126+
});
127+
128+
it('lists every refusal, each under the record it refused, by that record’s title', async () => {
129+
const fetch = perRecordFetch({
130+
lead_2: 'Grace Hopper is already a member of this campaign',
131+
lead_3: 'Edsger Dijkstra has opted out of marketing',
132+
});
133+
await runOverAll(fetch);
134+
135+
const inspector = await screen.findByTestId('bulk-error-inspector');
136+
const second = within(inspector).getByTestId('bulk-error-row-lead_2');
137+
const third = within(inspector).getByTestId('bulk-error-row-lead_3');
138+
expect(second).toHaveTextContent('Grace Hopper is already a member of this campaign');
139+
expect(third).toHaveTextContent('Edsger Dijkstra has opted out of marketing');
140+
// The record is named the way the confirm step named it, not by its id.
141+
expect(within(second).getByTestId('bulk-error-record-lead_2')).toHaveTextContent('Grace Hopper');
142+
expect(within(third).getByTestId('bulk-error-record-lead_3')).toHaveTextContent('Edsger Dijkstra');
143+
expect(within(inspector).queryByTestId('bulk-error-row-lead_1')).toBeNull();
144+
});
145+
146+
it('reports a clean run as every record succeeded, with no refusal list', async () => {
147+
const fetch = perRecordFetch({});
148+
await runOverAll(fetch);
149+
150+
await waitFor(() => expect(screen.getByText(/Succeeded 3 \/ 3/)).toBeInTheDocument());
151+
expect(screen.queryByTestId('bulk-error-inspector')).toBeNull();
152+
});
153+
});

‎ui/packages/plugin-grid/src/components/BulkActionDialog.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,14 @@ export const BulkActionDialog: React.FC<BulkActionDialogProps> = ({
388388

389389
if (!def) return null;
390390

391+
// A refusal is listed under the record it refused, named as the confirm
392+
// step named it. An entry that is no record of the run (a missing id, a
393+
// batch the server answered with a count only) keeps its own id.
394+
const refusedRecordName = (id: string): string => {
395+
const row = rows.find(r => r.id != null && String(r.id) === id);
396+
return row ? affectedRecordName(objectDef, row, id) : id;
397+
};
398+
391399
const title = def.label ?? def.name;
392400
const previewRows = rows.slice(0, 5);
393401
const restCount = Math.max(0, rows.length - previewRows.length);
@@ -571,7 +579,7 @@ export const BulkActionDialog: React.FC<BulkActionDialogProps> = ({
571579
<XCircle className="h-3 w-3 mt-0.5 shrink-0 text-destructive" />
572580
<div className="min-w-0 flex-1">
573581
<div className="break-words">
574-
<span className="text-muted-foreground">{e.id}:</span> {e.error}
582+
<span className="text-muted-foreground" data-testid={`bulk-error-record-${e.id}`}>{refusedRecordName(e.id)}:</span> {e.error}
575583
</div>
576584
</div>
577585
{/* A plain `custom` callout has nothing to re-run, but

0 commit comments

Comments
 (0)