Skip to content

Commit d2b7215

Browse files
os-zhuangclaude
andauthored
chore(plugin-grid): 对象声明批量 action 的浏览器验证 demo (#3002) (#3034)
* chore(plugin-grid): demo harness for object-declared bulk actions (#3002) Manual-verification page for the #3002 selection-bar fold, following the existing plugin-local vite demo convention (demo/index.html for the Import Wizard, plugin-gantt/demo). pnpm --dir packages/plugin-grid exec vite demo --port 5198 open http://localhost:5198/bulk-actions.html Everything below the mock boundary is the real stack — ObjectGrid, BulkActionBar, BulkActionDialog, useBulkExecutor, ActionRunner. Only the data source and window.fetch are faked, the latter logging each action request to an on-page panel so the per-record fan-out is directly observable. Covers all three vocabularies the bar folds: an object action flagged `bulkEnabled: true` that no view names, a legacy `bulkActions` name promoted to its declared action (with a param collected once), and an unresolvable name that stays a by-name runner-handler dispatch. One record fails with 422 so per-record attribution is visible rather than a blanket success. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(plugin-grid): the demo log is an external store, not a render-time assignment The React Compiler lint rejects reassigning a module-level slot from inside a component ('Cannot reassign variables declared outside of the component/hook'). The fetch stub lives outside React, so subscribe through useSyncExternalStore on the append count instead of capturing setState. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 4c6fa5b commit d2b7215

3 files changed

Lines changed: 297 additions & 0 deletions

File tree

.claude/launch.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@
2020
],
2121
"port": 5181
2222
},
23+
{
24+
"name": "grid-bulk-demo",
25+
"runtimeExecutable": "pnpm",
26+
"runtimeArgs": [
27+
"--dir",
28+
"packages/plugin-grid",
29+
"exec",
30+
"vite",
31+
"demo",
32+
"--port",
33+
"5198",
34+
"--strictPort"
35+
],
36+
"port": 5198,
37+
"url": "http://localhost:5198"
38+
},
2339
{
2440
"name": "gantt-demo",
2541
"runtimeExecutable": "pnpm",
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Object-declared Bulk Actions Demo</title>
7+
<style>
8+
/* Same console theme tokens as index.html, so the selection bar and the
9+
bulk dialog render exactly as they do in the real app. */
10+
:root {
11+
--background: 0 0% 100%;
12+
--foreground: 222.2 84% 4.9%;
13+
--card: 0 0% 100%;
14+
--card-foreground: 222.2 84% 4.9%;
15+
--popover: 0 0% 100%;
16+
--popover-foreground: 222.2 84% 4.9%;
17+
--primary: 243 75% 59%;
18+
--primary-foreground: 0 0% 100%;
19+
--secondary: 210 40% 96.1%;
20+
--secondary-foreground: 222.2 47.4% 11.2%;
21+
--muted: 210 40% 96.1%;
22+
--muted-foreground: 215.4 16.3% 46.9%;
23+
--accent: 210 40% 96.1%;
24+
--accent-foreground: 222.2 47.4% 11.2%;
25+
--destructive: 0 84.2% 60.2%;
26+
--destructive-foreground: 210 40% 98%;
27+
--border: 214.3 31.8% 91.4%;
28+
--input: 214.3 31.8% 91.4%;
29+
--ring: 243 75% 59%;
30+
--radius: 0.5rem;
31+
}
32+
html, body, #root { height: 100%; margin: 0; }
33+
body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background: hsl(var(--background)); color: hsl(var(--foreground)); }
34+
</style>
35+
</head>
36+
<body>
37+
<div id="root"></div>
38+
<script type="module" src="/bulk-actions.tsx"></script>
39+
</body>
40+
</html>
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/**
2+
* Manual-verification demo for object-declared bulk actions (objectui#3002).
3+
*
4+
* pnpm --dir packages/plugin-grid exec vite demo --port 5198
5+
* open http://localhost:5198/bulk-actions.html
6+
*
7+
* Everything below the mock boundary is the REAL stack — `ObjectGrid`,
8+
* `BulkActionBar`, `BulkActionDialog`, `useBulkExecutor`, `ActionRunner`. Only
9+
* two things are faked: the data source (an in-memory store, standing in for
10+
* `/api/v1/<object>`) and `window.fetch` (which logs each action request to the
11+
* on-page panel instead of hitting a server).
12+
*
13+
* What it exercises:
14+
* 1. `bulk_mark_done` is declared on the OBJECT with `bulkEnabled: true` and
15+
* is named by no view — before #3002 an object simply could not express a
16+
* bulk action, so this button did not exist.
17+
* 2. `bulk_recalc_estimate` is declared on the object WITHOUT the flag; the
18+
* view names it in legacy `bulkActions: [...]`. That name used to be
19+
* dispatched as `{ type: 'bulk_recalc_estimate' }` and never ran.
20+
* 3. `legacy_only_handler` resolves to no declared action and stays a
21+
* by-name dispatch — it must still render ALONGSIDE the two defs above.
22+
* 4. Record `t3` fails server-side (422), proving per-record attribution: the
23+
* result panel reports 4/5 with an error row rather than a blanket success.
24+
*/
25+
import * as React from 'react';
26+
import { createRoot } from 'react-dom/client';
27+
import '@object-ui/components/style.css';
28+
import { ActionProvider, I18nProvider } from '@object-ui/react';
29+
import { registerAllFields } from '@object-ui/fields';
30+
import { en } from '@object-ui/i18n';
31+
import { ObjectGrid } from '../src/ObjectGrid';
32+
33+
registerAllFields();
34+
35+
// The prebuilt components CSS ships its own :root theme and (being injected at
36+
// runtime) wins over the html file — re-apply the console brand tokens last.
37+
const themeStyle = document.createElement('style');
38+
themeStyle.textContent = `:root {
39+
--primary: 243 75% 59%;
40+
--primary-foreground: 0 0% 100%;
41+
--ring: 243 75% 59%;
42+
}`;
43+
document.head.appendChild(themeStyle);
44+
45+
// ─────────────────────────── mock boundary ───────────────────────────
46+
47+
const ROWS = [
48+
{ id: 't1', title: 'Ship the release notes', assignee: 'Ada', progress: 20, done: false },
49+
{ id: 't2', title: 'Review the migration plan', assignee: 'Alan', progress: 40, done: false },
50+
{ id: 't3', title: 'Rotate the staging keys', assignee: 'Grace', progress: 60, done: false },
51+
{ id: 't4', title: 'Draft the incident postmortem', assignee: 'Ada', progress: 80, done: false },
52+
{ id: 't5', title: 'Prune stale feature flags', assignee: 'Alan', progress: 10, done: false },
53+
];
54+
55+
/**
56+
* `bulkEnabled: true` is the spec's object-level declaration — "this action can
57+
* be applied to multiple selected records". It also carries `locations:
58+
* ['list_item']`, so the same action is a row entry AND a bulk entry.
59+
*/
60+
const MARK_DONE = {
61+
name: 'bulk_mark_done',
62+
label: 'Mark Done',
63+
icon: 'check',
64+
type: 'api',
65+
target: '/api/demo/mark-done',
66+
recordIdParam: 'taskId',
67+
locations: ['list_item'],
68+
bulkEnabled: true,
69+
};
70+
71+
/** No `bulkEnabled` — the VIEW names it in legacy `bulkActions` instead. */
72+
const RECALC = {
73+
name: 'bulk_recalc_estimate',
74+
label: 'Recalculate Estimate',
75+
icon: 'calculator',
76+
type: 'api',
77+
target: '/api/demo/recalc',
78+
recordIdParam: 'taskId',
79+
locations: ['record_more'],
80+
// Collected ONCE by the bulk dialog, then handed to every per-record dispatch.
81+
params: [
82+
{
83+
name: 'basis',
84+
label: 'Estimate basis',
85+
type: 'select',
86+
required: true,
87+
options: [
88+
{ label: 'Story points', value: 'points' },
89+
{ label: 'Hours', value: 'hours' },
90+
],
91+
defaultValue: 'points',
92+
},
93+
],
94+
};
95+
96+
const dataSource: any = {
97+
async find() {
98+
return { data: ROWS.map(r => ({ ...r })), total: ROWS.length, hasMore: false, pageSize: 50 };
99+
},
100+
async getObjectSchema(name: string) {
101+
return {
102+
name,
103+
label: 'Task',
104+
fields: {
105+
id: { type: 'text' },
106+
title: { type: 'text', label: 'Title' },
107+
assignee: { type: 'text', label: 'Assignee' },
108+
progress: { type: 'percent', label: 'Progress' },
109+
},
110+
// The single source the grid folds into the selection bar.
111+
actions: [MARK_DONE, RECALC],
112+
};
113+
},
114+
};
115+
116+
/**
117+
* Request log, rendered on the page so each per-record dispatch is visible.
118+
* A tiny external store rather than a captured setState: the fetch stub lives
119+
* outside React, and assigning a module-level slot from render is exactly what
120+
* the compiler lint forbids.
121+
*/
122+
type LogEntry = { url: string; recordId: string; params: string; status: number };
123+
const logEntries: LogEntry[] = [];
124+
const logListeners = new Set<() => void>();
125+
function pushLog(e: LogEntry) {
126+
logEntries.push(e);
127+
logListeners.forEach(notify => notify());
128+
}
129+
function useLog(): LogEntry[] {
130+
// Subscribe on the append COUNT — a stable scalar snapshot, so the store
131+
// contract holds while the array itself stays mutable.
132+
React.useSyncExternalStore(
133+
(onChange) => {
134+
logListeners.add(onChange);
135+
return () => logListeners.delete(onChange);
136+
},
137+
() => logEntries.length,
138+
() => 0,
139+
);
140+
return logEntries;
141+
}
142+
143+
const realFetch = window.fetch.bind(window);
144+
window.fetch = (async (input: any, init: any) => {
145+
const url = String(input);
146+
if (!url.startsWith('/api/demo/')) return realFetch(input, init);
147+
const body = init?.body ? JSON.parse(init.body) : {};
148+
const recordId = body._rowRecord?.id ?? '(none)';
149+
// t3 fails — proves the result panel attributes failures per record instead
150+
// of reporting a blanket success.
151+
const status = recordId === 't3' ? 422 : 200;
152+
const { _rowRecord, ...rest } = body;
153+
pushLog({ url, recordId, params: JSON.stringify(rest), status });
154+
return {
155+
ok: status === 200,
156+
status,
157+
statusText: status === 200 ? 'OK' : 'Unprocessable Entity',
158+
headers: { get: () => 'application/json' },
159+
json: async () => ({ success: status === 200 }),
160+
} as any;
161+
}) as typeof window.fetch;
162+
163+
// ───────────────────────────── the demo ──────────────────────────────
164+
165+
const schema: any = {
166+
type: 'object-grid',
167+
objectName: 'demo_task',
168+
columns: [
169+
{ field: 'title', label: 'Title' },
170+
{ field: 'assignee', label: 'Assignee' },
171+
{ field: 'progress', label: 'Progress' },
172+
],
173+
pagination: { pageSize: 50 },
174+
// Legacy bare names: one resolves to a declared action, one doesn't.
175+
bulkActions: ['bulk_recalc_estimate', 'legacy_only_handler'],
176+
};
177+
178+
function Demo() {
179+
const log = useLog();
180+
181+
return (
182+
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
183+
<header style={{ padding: '12px 16px', borderBottom: '1px solid hsl(var(--border))' }}>
184+
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>
185+
Object-declared bulk actions — objectui#3002
186+
</h1>
187+
<p style={{ fontSize: 12, color: 'hsl(var(--muted-foreground))', margin: '4px 0 0' }}>
188+
Select rows → the bar shows <b>Mark Done</b> (derived from the object&apos;s
189+
<code> bulkEnabled: true</code>), <b>Recalculate Estimate</b> (legacy name promoted to
190+
its declared action) and <b>Legacy Only Handler</b> (unresolvable, dispatched by name).
191+
</p>
192+
</header>
193+
194+
<div style={{ flex: 1, minHeight: 0 }}>
195+
<ObjectGrid schema={schema} dataSource={dataSource} />
196+
</div>
197+
198+
<section
199+
style={{
200+
borderTop: '1px solid hsl(var(--border))',
201+
padding: '8px 16px',
202+
maxHeight: 200,
203+
overflow: 'auto',
204+
fontSize: 12,
205+
fontFamily: 'ui-monospace, monospace',
206+
}}
207+
>
208+
<div style={{ fontWeight: 600, marginBottom: 4 }} data-testid="log-count">
209+
Action requests: {log.length}
210+
</div>
211+
{log.length === 0 && (
212+
<div style={{ color: 'hsl(var(--muted-foreground))' }}>
213+
(none yet — one line will appear per selected record)
214+
</div>
215+
)}
216+
{log.map((e, i) => (
217+
<div key={i} style={{ color: e.status === 200 ? 'inherit' : 'hsl(var(--destructive))' }}>
218+
{e.status} {e.url} · record={e.recordId} · params={e.params}
219+
</div>
220+
))}
221+
</section>
222+
</div>
223+
);
224+
}
225+
226+
createRoot(document.getElementById('root')!).render(
227+
<I18nProvider resources={{ en }} language="en">
228+
{/* A handler registered under a bare NAME — the compat path that legacy
229+
`bulkActions` entries must keep working for. */}
230+
<ActionProvider
231+
handlers={{
232+
legacy_only_handler: async () => {
233+
pushLog({ url: '(runner handler)', recordId: 'all-selected', params: '{}', status: 200 });
234+
return { success: true };
235+
},
236+
}}
237+
>
238+
<Demo />
239+
</ActionProvider>
240+
</I18nProvider>,
241+
);

0 commit comments

Comments
 (0)