|
| 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'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