@svgrid/enterprise 3.1.21 → 3.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/SvGridBoard.svelte +39 -0
  2. package/dist/SvGridGallery.svelte +23 -0
  3. package/dist/SvGridRowCount.svelte +23 -0
  4. package/dist/SvGridRowCount.svelte.d.ts +6 -0
  5. package/dist/SvGridScheduler.svelte +25 -7
  6. package/dist/cdn/svgrid-enterprise.svelte-external.js +13838 -13554
  7. package/dist/designer/assets/GridFooter-DKMfZpL4.js +2 -0
  8. package/dist/designer/assets/GridMenus-DWDghydL.js +6 -0
  9. package/dist/designer/assets/{PinSplitter-C5AwA1xV.js → PinSplitter-BfIjd7pi.js} +1 -1
  10. package/dist/designer/assets/grid-extension.svelte-G1ng0xd2.js +1 -0
  11. package/dist/designer/assets/index-CHx3F4Ij.css +1 -0
  12. package/dist/designer/assets/{index-DLJjuVA4.js → index-CIxqRzKJ.js} +324 -324
  13. package/dist/designer/assets/merges-Bg-KqPEl.js +1 -0
  14. package/dist/designer/index.html +3 -3
  15. package/dist/expressions/compile.js +11 -7
  16. package/dist/expressions/evaluate.js +4 -0
  17. package/dist/expressions/expression-columns.d.ts +4 -1
  18. package/dist/expressions/expression-columns.js +12 -3
  19. package/dist/expressions/parse.js +19 -7
  20. package/dist/fields/RecordView.svelte +5 -2
  21. package/dist/fields/activity.svelte.d.ts +5 -2
  22. package/dist/fields/activity.svelte.js +4 -3
  23. package/dist/fields/grid-access.d.ts +1 -1
  24. package/dist/fields/grid-access.js +13 -3
  25. package/dist/fields/index.d.ts +18 -0
  26. package/dist/fields/index.js +31 -0
  27. package/dist/filter-labels.d.ts +16 -0
  28. package/dist/filter-labels.js +83 -0
  29. package/dist/index.d.ts +4 -0
  30. package/dist/index.js +7 -0
  31. package/dist/install.js +8 -0
  32. package/dist/node/studio.js +12 -5
  33. package/dist/relative-date-core.d.ts +40 -0
  34. package/dist/relative-date-core.js +85 -0
  35. package/dist/relative-dates.d.ts +28 -0
  36. package/dist/relative-dates.js +61 -0
  37. package/dist/review/GhostCell.svelte +60 -0
  38. package/dist/review/GhostCell.svelte.d.ts +14 -0
  39. package/dist/review/RichCellMark.svelte +72 -0
  40. package/dist/review/RichCellMark.svelte.d.ts +8 -0
  41. package/dist/review/SvReviewPanel.svelte +759 -0
  42. package/dist/review/SvReviewPanel.svelte.d.ts +16 -0
  43. package/dist/review/adapters.d.ts +79 -0
  44. package/dist/review/adapters.js +119 -0
  45. package/dist/review/agent.d.ts +52 -0
  46. package/dist/review/agent.js +232 -0
  47. package/dist/review/card-marks.svelte.d.ts +42 -0
  48. package/dist/review/card-marks.svelte.js +51 -0
  49. package/dist/review/cluster.d.ts +26 -0
  50. package/dist/review/cluster.js +377 -0
  51. package/dist/review/equal.d.ts +9 -0
  52. package/dist/review/equal.js +35 -0
  53. package/dist/review/formats.d.ts +31 -0
  54. package/dist/review/formats.js +37 -0
  55. package/dist/review/ghost.d.ts +17 -0
  56. package/dist/review/ghost.js +104 -0
  57. package/dist/review/index.d.ts +42 -0
  58. package/dist/review/index.js +36 -0
  59. package/dist/review/messages.d.ts +113 -0
  60. package/dist/review/messages.js +135 -0
  61. package/dist/review/model.svelte.d.ts +262 -0
  62. package/dist/review/model.svelte.js +883 -0
  63. package/dist/review/pending-filter.d.ts +26 -0
  64. package/dist/review/pending-filter.js +71 -0
  65. package/dist/review/rich-cells.d.ts +1 -0
  66. package/dist/review/rich-cells.js +36 -0
  67. package/dist/review/types.d.ts +83 -0
  68. package/dist/review/types.js +7 -0
  69. package/dist/row-count.d.ts +28 -0
  70. package/dist/row-count.js +39 -0
  71. package/dist/sort-blanks.d.ts +8 -0
  72. package/dist/sort-blanks.js +47 -0
  73. package/dist/studio/ui-components.generated.js +1 -1
  74. package/dist/upgrade-prompt.d.ts +1 -1
  75. package/dist/version.d.ts +2 -2
  76. package/dist/version.js +2 -2
  77. package/dist/wc/GridFooter-CKC4L36O.js +162 -0
  78. package/dist/wc/{GridMenus-DVGuqp2l.js → GridMenus-h7RsvcmI.js} +320 -303
  79. package/dist/wc/{PinSplitter-Btco0txr.js → PinSplitter-BN6ybmjs.js} +1 -1
  80. package/dist/wc/grid-extension.svelte-CDf5EUyz.js +27 -0
  81. package/dist/wc/merges-DQTOlMWM.js +109 -0
  82. package/dist/wc/sv-sheet-element.js +6753 -6792
  83. package/package.json +9 -3
  84. package/dist/designer/assets/GridFooter-CKBVItAU.js +0 -2
  85. package/dist/designer/assets/GridMenus-Dzwu7M9r.js +0 -6
  86. package/dist/designer/assets/grid-extension.svelte-BZ7AqyJI.js +0 -1
  87. package/dist/designer/assets/index-WCxj6WfJ.css +0 -1
  88. package/dist/wc/GridFooter-BgoA77TJ.js +0 -145
  89. package/dist/wc/grid-extension.svelte-CucBS4FL.js +0 -20
@@ -0,0 +1,883 @@
1
+ import { nudgeEnterprise } from '../license.js';
2
+ import { clusterProposals } from './cluster.js';
3
+ import { sameValue } from './equal.js';
4
+ import { FLASH_BG, pendingFormats, CONFLICT_BG, CONFLICT_ICON } from './formats.js';
5
+ import { hasPending, stripPending, trackPendingSource, withPending } from './pending-filter.js';
6
+ import { registerCardMarks } from './card-marks.svelte.js';
7
+ import { registerRichCellMarks } from './rich-cells.js';
8
+ import { fill, reviewMessages } from './messages.js';
9
+ import { showValue } from './cluster.js';
10
+ import { wrapColumns } from './ghost.js';
11
+ const STATUSES = new Set(['pending', 'accepted', 'rejected', 'stale', 'conflict']);
12
+ const SOURCE_KINDS = new Set(['agent', 'ai', 'import', 'bulk', 'sync', 'rule', 'user']);
13
+ const isStr = (v) => typeof v === 'string' && v.length > 0;
14
+ /**
15
+ * An export, checked before it is trusted: it may come from another person,
16
+ * another session or an agent. A wrong shape throws; a malformed proposal is
17
+ * dropped and counted.
18
+ */
19
+ function checkExport(json) {
20
+ const root = json;
21
+ if (!root || typeof root !== 'object' || root.v !== 1 || !Array.isArray(root.sets)) {
22
+ throw new Error('[svgrid review] not a change review export (expected { v: 1, sets: [...] })');
23
+ }
24
+ let dropped = 0;
25
+ const sets = [];
26
+ for (const raw of root.sets) {
27
+ const set = raw;
28
+ const src = set?.source;
29
+ if (!set || !isStr(set.id) || !Array.isArray(set.proposals) || !src || !isStr(src.id) || !SOURCE_KINDS.has(src.kind)) {
30
+ throw new Error('[svgrid review] a set in the export has no id, source or proposals');
31
+ }
32
+ const source = { kind: src.kind, id: src.id, label: typeof src.label === 'string' ? src.label : src.id };
33
+ const proposals = [];
34
+ for (const p of set.proposals) {
35
+ const rowId = typeof p?.rowId === 'number' ? String(p.rowId) : p?.rowId;
36
+ if (!p || !isStr(p.id) || !isStr(rowId) || !isStr(p.field) || !STATUSES.has(p.status)) {
37
+ dropped++;
38
+ continue;
39
+ }
40
+ proposals.push({
41
+ id: p.id, op: 'update', rowId, field: p.field, before: p.before, after: p.after, source,
42
+ reason: typeof p.reason === 'string' ? p.reason : undefined,
43
+ confidence: typeof p.confidence === 'number' && p.confidence >= 0 && p.confidence <= 1 ? p.confidence : undefined,
44
+ createdAt: typeof p.createdAt === 'number' ? p.createdAt : Date.now(),
45
+ status: p.status,
46
+ note: typeof p.note === 'string' ? p.note : undefined,
47
+ decidedBy: typeof p.decidedBy === 'string' ? p.decidedBy : undefined,
48
+ decidedAt: typeof p.decidedAt === 'number' ? p.decidedAt : undefined,
49
+ });
50
+ }
51
+ sets.push({ id: set.id, title: typeof set.title === 'string' ? set.title : source.label, source, createdAt: typeof set.createdAt === 'number' ? set.createdAt : Date.now(), proposals });
52
+ }
53
+ return { sets, dropped };
54
+ }
55
+ let uid = 0;
56
+ const nextId = (prefix) => `${prefix}${Date.now().toString(36)}-${(uid++).toString(36)}`;
57
+ const SEP = '\u0000';
58
+ const cellKey = (rowId, field) => rowId + SEP + field;
59
+ export function createReview(opts) {
60
+ if (!opts.setRows && !opts.server)
61
+ throw new Error('[svgrid review] pass setRows (client data) or server (server row model)');
62
+ if (opts.server && !opts.onCommit)
63
+ throw new Error('[svgrid review] onCommit is required with a server row model');
64
+ const msg = reviewMessages(opts.messages);
65
+ // Without a key: watermark and a one-time nudge. The review still runs.
66
+ nudgeEnterprise('Change review');
67
+ // Proposed values beside rich cells (selects, ratings): registered by the
68
+ // first review, so grids in apps without one keep the plain fast path.
69
+ registerRichCellMarks();
70
+ const all = new Map();
71
+ const sets = new Map();
72
+ /** Live proposals (pending or in conflict) per cell. */
73
+ const byCell = new Map();
74
+ const inFlight = new Set();
75
+ /** Decision order for the log: timestamps tie within one millisecond. */
76
+ const decidedOrder = new Map();
77
+ let lastError = $state(null);
78
+ function failed(error, during) {
79
+ lastError = { error, during, at: Date.now() };
80
+ opts.onError?.(error, during);
81
+ }
82
+ let decisionSeq = 0;
83
+ const stamp = (p) => decidedOrder.set(p.id, ++decisionSeq);
84
+ let version = $state(0);
85
+ // "Only rows with changes": our node ANDed onto the advanced filter. Put on
86
+ // again after every change so the grid filters with the new pending set.
87
+ const filterToken = nextId('rv-');
88
+ let pendingOnly = $state(false);
89
+ let filterNonce = 0;
90
+ let pendingRows = null;
91
+ const pendingSource = { hasPendingRow: (rowId) => hasPendingRow(rowId) };
92
+ function hasPendingRow(rowId) {
93
+ if (pendingRows?.v !== version) {
94
+ const ids = new Set();
95
+ for (const p of all.values())
96
+ if (p.status === 'pending' || p.status === 'conflict')
97
+ ids.add(p.rowId);
98
+ pendingRows = { v: version, ids };
99
+ }
100
+ return pendingRows.ids.has(rowId);
101
+ }
102
+ function applyPendingFilter() {
103
+ const api = getApi();
104
+ if (!api)
105
+ return;
106
+ const current = api.getAdvancedFilter();
107
+ // The user's own filter tools may have dropped the node: then the toggle is off.
108
+ if (filterNonce > 0 && !hasPending(current)) {
109
+ pendingOnly = false;
110
+ return;
111
+ }
112
+ api.setAdvancedFilter(withPending(current, filterToken, ++filterNonce));
113
+ }
114
+ const bump = () => {
115
+ version += 1;
116
+ if (pendingOnly)
117
+ applyPendingFilter();
118
+ };
119
+ /**
120
+ * Show only rows with a pending or conflicting proposal, on top of the
121
+ * user's own filters; off takes our condition back out and leaves theirs.
122
+ * Rows leave the view as their proposals are decided.
123
+ */
124
+ function showPendingOnly(on) {
125
+ const api = getApi();
126
+ if (!api)
127
+ return;
128
+ if (on) {
129
+ trackPendingSource(filterToken, pendingSource);
130
+ filterNonce = 0;
131
+ pendingOnly = true;
132
+ api.setAdvancedFilter(withPending(api.getAdvancedFilter(), filterToken, ++filterNonce));
133
+ }
134
+ else {
135
+ pendingOnly = false;
136
+ api.setAdvancedFilter(stripPending(api.getAdvancedFilter()));
137
+ }
138
+ }
139
+ const getApi = () => (typeof opts.api === 'function' ? opts.api() : opts.api) ?? null;
140
+ function rowIdFn(api) {
141
+ const fn = api.getOption('getRowId');
142
+ if (!fn)
143
+ throw new Error('[svgrid review] the grid needs a getRowId prop: proposals are keyed by row id, and positions move on sort and filter');
144
+ return fn;
145
+ }
146
+ function indexRows(api) {
147
+ const id = rowIdFn(api);
148
+ const map = new Map();
149
+ api.getData().forEach((row, i) => map.set(String(id(row, i)), i));
150
+ return map;
151
+ }
152
+ // ---- adding -------------------------------------------------------------
153
+ function add(input) {
154
+ const linked = getApi();
155
+ if (linked)
156
+ linkCards(linked);
157
+ const setId = input.id ?? nextId('cs-');
158
+ const createdAt = Date.now();
159
+ const ids = [];
160
+ for (const raw of input.proposals) {
161
+ if (sameValue(raw.before, raw.after))
162
+ continue;
163
+ const p = {
164
+ ...raw,
165
+ rowId: String(raw.rowId),
166
+ id: raw.id ?? nextId('pr-'),
167
+ op: 'update',
168
+ source: input.source,
169
+ // A merged proposal keeps when it was made.
170
+ createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : createdAt,
171
+ status: 'pending',
172
+ };
173
+ const key = cellKey(p.rowId, p.field);
174
+ const live = byCell.get(key) ?? new Set();
175
+ for (const otherId of [...live]) {
176
+ const other = all.get(otherId);
177
+ // Being accepted right now: leave it be. If it lands, the stale check
178
+ // catches this one.
179
+ if (inFlight.has(otherId))
180
+ continue;
181
+ if (other.source.id === p.source.id && other.status === 'pending') {
182
+ // A newer proposal from the same producer replaces the older one
183
+ // and keeps what the cell held when the first was made.
184
+ p.before = other.before;
185
+ live.delete(otherId);
186
+ all.delete(otherId);
187
+ for (const set of sets.values()) {
188
+ const at = set.ids.indexOf(otherId);
189
+ if (at >= 0)
190
+ set.ids.splice(at, 1);
191
+ }
192
+ }
193
+ else {
194
+ other.status = 'conflict';
195
+ other.note = msg.noteConflict;
196
+ p.status = 'conflict';
197
+ p.note = other.note;
198
+ }
199
+ }
200
+ if (p.status === 'pending' && sameValue(p.before, p.after))
201
+ continue;
202
+ live.add(p.id);
203
+ byCell.set(key, live);
204
+ all.set(p.id, p);
205
+ ids.push(p.id);
206
+ }
207
+ sets.set(setId, { id: setId, title: input.title ?? input.source.label, source: input.source, createdAt, ids });
208
+ bump();
209
+ return { id: setId, title: input.title ?? input.source.label, source: input.source, createdAt, proposals: ids.map((i) => all.get(i)) };
210
+ }
211
+ const leaveCell = (p) => {
212
+ const live = byCell.get(cellKey(p.rowId, p.field));
213
+ live?.delete(p.id);
214
+ if (live && !live.size)
215
+ byCell.delete(cellKey(p.rowId, p.field));
216
+ };
217
+ const enterCell = (p) => {
218
+ const key = cellKey(p.rowId, p.field);
219
+ byCell.set(key, (byCell.get(key) ?? new Set()).add(p.id));
220
+ };
221
+ // ---- reading ------------------------------------------------------------
222
+ let groupCache = null;
223
+ function fieldLabels() {
224
+ const api = getApi();
225
+ const cols = api?.getColumns() ?? [];
226
+ const map = new Map(cols.map((c) => [c.field ?? c.id, c.header || c.id]));
227
+ return (f) => map.get(f) ?? f;
228
+ }
229
+ const list = (status) => {
230
+ void version;
231
+ const out = [...all.values()];
232
+ return status ? out.filter((p) => p.status === status) : out;
233
+ };
234
+ function groups() {
235
+ const v = version;
236
+ if (groupCache?.v === v)
237
+ return groupCache.groups;
238
+ const g = clusterProposals(list('pending'), { fieldLabel: fieldLabels(), ...opts.cluster, messages: { ...opts.messages, ...opts.cluster?.messages } });
239
+ groupCache = { v, groups: g };
240
+ return g;
241
+ }
242
+ function pendingFor(rowId, field) {
243
+ void version;
244
+ for (const id of byCell.get(cellKey(rowId, field)) ?? []) {
245
+ const p = all.get(id);
246
+ if (p?.status === 'pending')
247
+ return p;
248
+ }
249
+ return undefined;
250
+ }
251
+ // ---- deciding -----------------------------------------------------------
252
+ const outcome = () => ({ accepted: [], rejected: [], stale: [], skipped: [], committedStale: [] });
253
+ /** Drop proposals whose cell no longer holds `before`. */
254
+ function freshOnly(ps, out, api) {
255
+ if (!api)
256
+ return ps;
257
+ const data = api.getData();
258
+ const index = indexRows(api);
259
+ return ps.filter((p) => {
260
+ const i = index.get(p.rowId);
261
+ if (i === undefined) {
262
+ // A server row model only holds the loaded rows; the backend decides there.
263
+ if (opts.server)
264
+ return true;
265
+ markStale(p, msg.noteRowGone, out);
266
+ return false;
267
+ }
268
+ const current = data[i][p.field];
269
+ if (sameValue(current, p.before))
270
+ return true;
271
+ markStale(p, msg.noteCellChanged, out);
272
+ return false;
273
+ });
274
+ }
275
+ function markStale(p, note, out) {
276
+ p.status = 'stale';
277
+ p.note = note;
278
+ p.decidedAt = Date.now();
279
+ stamp(p);
280
+ leaveCell(p);
281
+ out.stale.push(p.id);
282
+ }
283
+ /** Write `value` per (rowId, field) into a copy of the grid's current data. */
284
+ function writeValues(api, writes, beforeSet) {
285
+ const data = api.getData();
286
+ const index = indexRows(api);
287
+ const next = data.slice();
288
+ const copied = new Set();
289
+ for (const [rowId, field, value] of writes) {
290
+ const i = index.get(rowId);
291
+ if (i === undefined)
292
+ continue;
293
+ if (!copied.has(i)) {
294
+ next[i] = { ...next[i] };
295
+ copied.add(i);
296
+ }
297
+ ;
298
+ next[i][field] = value;
299
+ }
300
+ if (!copied.size)
301
+ return;
302
+ beforeSet?.(next);
303
+ opts.setRows(next);
304
+ }
305
+ function currentValue(api, index, p) {
306
+ const i = index.get(p.rowId);
307
+ return i === undefined ? undefined : api.getData()[i][p.field];
308
+ }
309
+ /**
310
+ * What an open editor could write back over. A cell editor is committed
311
+ * first (`stopEditing`), so its value lands and the stale check sees it. A
312
+ * full-row draft has no public commit, so its row is left alone.
313
+ */
314
+ function editedRow(api) {
315
+ if (!api)
316
+ return null;
317
+ api.stopEditing(false);
318
+ if (!api.getOption('fullRowEditing'))
319
+ return null;
320
+ const active = api.getActiveCell();
321
+ return active ? api.getRowId(active.rowIndex) : null;
322
+ }
323
+ async function accept(ids) {
324
+ const out = outcome();
325
+ const api = getApi();
326
+ if (!api && !opts.server) {
327
+ for (const id of ids) {
328
+ const p = all.get(id);
329
+ if (p?.status !== 'pending')
330
+ continue;
331
+ p.note = msg.noteGridNotReady;
332
+ out.skipped.push(id);
333
+ }
334
+ bump();
335
+ return out;
336
+ }
337
+ const busyRow = editedRow(api);
338
+ let take = [];
339
+ for (const id of ids) {
340
+ const p = all.get(id);
341
+ if (!p || p.status !== 'pending')
342
+ continue;
343
+ if (inFlight.has(id) || (busyRow !== null && p.rowId === busyRow)) {
344
+ if (!inFlight.has(id))
345
+ p.note = msg.noteRowEditing;
346
+ out.skipped.push(id);
347
+ continue;
348
+ }
349
+ take.push(p);
350
+ }
351
+ take = freshOnly(take, out, api);
352
+ if (!take.length) {
353
+ bump();
354
+ return out;
355
+ }
356
+ for (const p of take)
357
+ inFlight.add(p.id);
358
+ try {
359
+ if (opts.onCommit)
360
+ await opts.onCommit(take.map((p) => ({ ...p })));
361
+ }
362
+ catch (error) {
363
+ for (const p of take)
364
+ inFlight.delete(p.id);
365
+ bump();
366
+ return { ...out, error };
367
+ }
368
+ for (const p of take)
369
+ inFlight.delete(p.id);
370
+ // The await let other writes in: check again before writing. What
371
+ // onCommit already saved but cannot be written now is told apart.
372
+ if (!opts.server) {
373
+ const saved = take.filter((p) => p.status === 'pending');
374
+ const late = outcome();
375
+ take = freshOnly(saved, late, api);
376
+ for (const id of late.stale) {
377
+ const p = all.get(id);
378
+ if (opts.onCommit) {
379
+ p.note = msg.noteSavedStale;
380
+ out.committedStale.push(id);
381
+ }
382
+ else
383
+ out.stale.push(id);
384
+ }
385
+ }
386
+ if (!take.length) {
387
+ bump();
388
+ return out;
389
+ }
390
+ if (opts.setRows && api)
391
+ writeValues(api, take.map((p) => [p.rowId, p.field, p.after]), (next) => opts.onApply?.(take, 'accept', next));
392
+ else {
393
+ opts.server?.refresh();
394
+ opts.onApply?.(take, 'accept');
395
+ }
396
+ const at = Date.now();
397
+ for (const p of take) {
398
+ p.status = 'accepted';
399
+ p.note = undefined;
400
+ p.decidedBy = opts.user;
401
+ p.decidedAt = at;
402
+ stamp(p);
403
+ leaveCell(p);
404
+ out.accepted.push(p.id);
405
+ }
406
+ recordUndo(api, take, 'accepted');
407
+ decided('accept', take);
408
+ bump();
409
+ return out;
410
+ }
411
+ function decided(action, ps, of) {
412
+ if (ps.length)
413
+ opts.onDecision?.({ action, of, proposals: ps.map((p) => ({ ...p })), by: opts.user, at: Date.now() });
414
+ }
415
+ function reject(ids) {
416
+ const out = outcome();
417
+ const at = Date.now();
418
+ const taken = [];
419
+ // What each proposal was before, so an undo puts a conflict back as one.
420
+ const was = new Map();
421
+ for (const id of ids) {
422
+ const p = all.get(id);
423
+ if (!p || (p.status !== 'pending' && p.status !== 'conflict') || inFlight.has(id))
424
+ continue;
425
+ was.set(p.id, p.status);
426
+ p.status = 'rejected';
427
+ p.decidedBy = opts.user;
428
+ p.decidedAt = at;
429
+ stamp(p);
430
+ leaveCell(p);
431
+ taken.push(p);
432
+ out.rejected.push(id);
433
+ }
434
+ if (taken.length)
435
+ recordUndo(getApi(), taken, 'rejected', was);
436
+ decided('reject', taken);
437
+ bump();
438
+ return out;
439
+ }
440
+ /**
441
+ * One Ctrl+Z for the whole decision, by row id, so it works with paging and
442
+ * filters on. Undoing an accept writes `before` back only where the cell
443
+ * still holds `after`; anything edited since is left as it is. With
444
+ * `onCommit`, the reversed values are saved first and written once that
445
+ * resolves, the way an accept is.
446
+ */
447
+ function recordUndo(api, ps, kind, was) {
448
+ if (!api)
449
+ return;
450
+ const prior = new Map(ps.map((p) => [p.id, { decidedBy: p.decidedBy, decidedAt: p.decidedAt }]));
451
+ const undecided = (p) => was?.get(p.id) ?? 'pending';
452
+ const flip = (undo, which) => {
453
+ for (const p of which) {
454
+ if (undo) {
455
+ p.status = undecided(p);
456
+ p.decidedBy = undefined;
457
+ p.decidedAt = undefined;
458
+ // A proposal dropped since (clear, load) stays out of the cell index.
459
+ if (all.get(p.id) === p)
460
+ enterCell(p);
461
+ }
462
+ else {
463
+ p.status = kind;
464
+ Object.assign(p, prior.get(p.id));
465
+ leaveCell(p);
466
+ }
467
+ }
468
+ decided(undo ? 'undo' : 'redo', which, kind === 'accepted' ? 'accept' : 'reject');
469
+ bump();
470
+ };
471
+ const move = (undo) => {
472
+ const live = ps.filter((p) => (undo ? p.status === kind : p.status === undecided(p)));
473
+ if (kind === 'rejected')
474
+ return flip(undo, live);
475
+ const from = undo ? 'after' : 'before';
476
+ const to = undo ? 'before' : 'after';
477
+ const how = undo ? 'undo' : 'redo';
478
+ if (opts.server) {
479
+ const inverse = live.map((p) => ({ ...p, before: p[from], after: p[to] }));
480
+ Promise.resolve(opts.onCommit(inverse)).then(() => {
481
+ opts.server.refresh();
482
+ flip(undo, live);
483
+ opts.onApply?.(live, how);
484
+ }, (error) => failed(error, how));
485
+ return;
486
+ }
487
+ // One row index per pass (rebuilt after an async save), not one per proposal.
488
+ const holdsIn = () => {
489
+ const index = indexRows(api);
490
+ return (p) => sameValue(currentValue(api, index, p), p[from]);
491
+ };
492
+ const write = (ok) => {
493
+ if (!ok.length)
494
+ return;
495
+ writeValues(api, ok.map((p) => [p.rowId, p.field, p[to]]), (next) => opts.onApply?.(ok, how, next));
496
+ flip(undo, ok);
497
+ };
498
+ const ok = live.filter(holdsIn());
499
+ if (!opts.onCommit)
500
+ return write(ok);
501
+ // The backend holds the accepted values: save the reverse there first.
502
+ Promise.resolve(opts.onCommit(ok.map((p) => ({ ...p, before: p[from], after: p[to] })))).then(() => write(ok.filter(holdsIn())), (error) => failed(error, how));
503
+ };
504
+ api.getCommandContext().recordUndo(() => move(true), () => move(false));
505
+ }
506
+ function groupIds(key, shownIds) {
507
+ const g = groups().find((x) => x.key === key);
508
+ if (!g)
509
+ return [];
510
+ const members = new Set(g.ids);
511
+ return shownIds.filter((id) => members.has(id));
512
+ }
513
+ /**
514
+ * Keep one of a cell's conflicting proposals (it becomes pending) and reject
515
+ * the rest, as one undo step that puts all of them back in conflict.
516
+ */
517
+ function resolveConflict(keepId) {
518
+ const keep = all.get(keepId);
519
+ if (!keep || keep.status !== 'conflict')
520
+ return;
521
+ const rivals = [...(byCell.get(cellKey(keep.rowId, keep.field)) ?? [])]
522
+ .filter((id) => id !== keepId && !inFlight.has(id))
523
+ .map((id) => all.get(id))
524
+ .filter((p) => p?.status === 'conflict');
525
+ const note = keep.note;
526
+ const apply = (resolved) => {
527
+ const at = Date.now();
528
+ for (const r of rivals) {
529
+ if (resolved) {
530
+ r.status = 'rejected';
531
+ r.decidedBy = opts.user;
532
+ r.decidedAt = at;
533
+ stamp(r);
534
+ leaveCell(r);
535
+ }
536
+ else {
537
+ r.status = 'conflict';
538
+ r.decidedBy = undefined;
539
+ r.decidedAt = undefined;
540
+ if (all.get(r.id) === r)
541
+ enterCell(r);
542
+ }
543
+ }
544
+ keep.status = resolved ? 'pending' : 'conflict';
545
+ keep.note = resolved ? undefined : note;
546
+ bump();
547
+ };
548
+ apply(true);
549
+ decided('reject', rivals);
550
+ getApi()?.getCommandContext().recordUndo(() => apply(false), () => apply(true));
551
+ }
552
+ // ---- serialising --------------------------------------------------------
553
+ /**
554
+ * Every set with its proposals and their status, as plain JSON. Pass
555
+ * `setIds` to export only some, to hand them to someone else to decide.
556
+ */
557
+ function toJSON(opts = {}) {
558
+ const only = opts.setIds ? new Set(opts.setIds) : null;
559
+ return {
560
+ v: 1,
561
+ sets: [...sets.values()].filter((set) => !only || only.has(set.id)).map(({ ids, ...meta }) => ({
562
+ ...meta,
563
+ proposals: ids.map((i) => all.get(i)).filter((p) => !!p).map((p) => ({ ...p })),
564
+ })),
565
+ };
566
+ }
567
+ /**
568
+ * Replace everything with an export. The export is checked first: a wrong
569
+ * shape throws and leaves the review as it was; a malformed proposal is
570
+ * dropped. Returns how many were dropped.
571
+ */
572
+ function load(json) {
573
+ const { sets: valid, dropped } = checkExport(json);
574
+ clear();
575
+ for (const set of valid) {
576
+ sets.set(set.id, { id: set.id, title: set.title, source: set.source, createdAt: set.createdAt, ids: set.proposals.map((p) => p.id) });
577
+ for (const p of set.proposals) {
578
+ all.set(p.id, { ...p });
579
+ if (p.status === 'pending' || p.status === 'conflict')
580
+ enterCell(p);
581
+ }
582
+ }
583
+ bump();
584
+ return { dropped };
585
+ }
586
+ /**
587
+ * Add the sets of an export to this review, keeping what is here: an
588
+ * approver's page merges what the author exported. A set already present is
589
+ * skipped. Pending proposals go through the same rules as `add` (a cell
590
+ * with a live proposal from another source becomes a conflict); decided ones
591
+ * are kept as history.
592
+ */
593
+ function merge(json) {
594
+ const { sets: valid, dropped } = checkExport(json);
595
+ for (const set of valid) {
596
+ if (sets.has(set.id))
597
+ continue;
598
+ const live = set.proposals.filter((p) => p.status === 'pending' || p.status === 'conflict');
599
+ const history = set.proposals.filter((p) => !live.includes(p) && !all.has(p.id));
600
+ add({ id: set.id, title: set.title, source: set.source, proposals: live.filter((p) => !all.has(p.id)) });
601
+ sets.get(set.id).createdAt = set.createdAt;
602
+ for (const p of history)
603
+ all.set(p.id, { ...p });
604
+ sets.get(set.id).ids.push(...history.map((p) => p.id));
605
+ }
606
+ bump();
607
+ return { dropped };
608
+ }
609
+ // ---- history ------------------------------------------------------------
610
+ /** Every decided proposal (accepted, rejected or stale), oldest decision first. */
611
+ function log() {
612
+ void version;
613
+ return [...all.values()]
614
+ .filter((p) => p.status === 'accepted' || p.status === 'rejected' || p.status === 'stale')
615
+ .sort((a, b) => (a.decidedAt ?? 0) - (b.decidedAt ?? 0) || (decidedOrder.get(a.id) ?? 0) - (decidedOrder.get(b.id) ?? 0))
616
+ .map((p) => ({ ...p }));
617
+ }
618
+ /**
619
+ * Propose undoing what a set wrote, after the fact: a new set with each
620
+ * accepted proposal reversed (`after` back to `before`). It is reviewed and
621
+ * accepted like any other set, so the stale check keeps a later edit.
622
+ * Returns null when nothing of the set was accepted.
623
+ */
624
+ function revertSet(setId) {
625
+ const set = sets.get(setId);
626
+ if (!set)
627
+ return null;
628
+ const accepted = set.ids.map((id) => all.get(id)).filter((p) => p?.status === 'accepted');
629
+ if (!accepted.length)
630
+ return null;
631
+ return add({
632
+ source: { kind: 'user', id: `revert:${setId}`, label: `Revert: ${set.title}` },
633
+ title: `Revert: ${set.title}`,
634
+ proposals: accepted.map((p) => ({ rowId: p.rowId, field: p.field, before: p.after, after: p.before, reason: `reverts ${p.source.label}` })),
635
+ });
636
+ }
637
+ /** The sets in this review, newest last, without their proposals. */
638
+ function sets_() {
639
+ void version;
640
+ return [...sets.values()].map(({ ids, ...meta }) => ({ ...meta, count: ids.length }));
641
+ }
642
+ function clear() {
643
+ all.clear();
644
+ sets.clear();
645
+ byCell.clear();
646
+ bump();
647
+ }
648
+ // ---- grid display -------------------------------------------------------
649
+ /** A card's mark (board, gallery): how many proposals its row has and what they set. */
650
+ let liveByRow = null;
651
+ function cardMark(rowId) {
652
+ if (liveByRow?.v !== version) {
653
+ const rows = new Map();
654
+ for (const p of all.values()) {
655
+ if (p.status !== 'pending' && p.status !== 'conflict')
656
+ continue;
657
+ const list = rows.get(p.rowId);
658
+ if (list)
659
+ list.push(p);
660
+ else
661
+ rows.set(p.rowId, [p]);
662
+ }
663
+ liveByRow = { v: version, rows };
664
+ }
665
+ const ps = liveByRow.rows.get(rowId);
666
+ if (!ps)
667
+ return null;
668
+ const labels = fieldLabels();
669
+ return {
670
+ count: ps.length,
671
+ label: fill(msg.cardProposed, { count: ps.length }),
672
+ text: ps.map((p) => `${labels(p.field)} → ${showValue(p.after, msg.empty)}`).join(', '),
673
+ };
674
+ }
675
+ /** Whether a row object is in the grid's data (cached per data array). */
676
+ let owned = null;
677
+ function owns(row) {
678
+ const data = getApi()?.getData();
679
+ if (!data || !row || typeof row !== 'object')
680
+ return false;
681
+ if (owned?.data !== data)
682
+ owned = { data, rows: new WeakSet(data.filter((r) => !!r && typeof r === 'object')) };
683
+ return owned.rows.has(row);
684
+ }
685
+ let marksFor = null;
686
+ /** Register for the board and gallery cards of the grid, once its getRowId is known. */
687
+ function linkCards(api) {
688
+ const fn = api.getOption('getRowId');
689
+ if (fn && fn !== marksFor) {
690
+ marksFor = fn;
691
+ registerCardMarks(fn, {
692
+ cardMark,
693
+ owns,
694
+ cellProposal: (rowId, field) => {
695
+ const p = pendingFor(rowId, field);
696
+ return p ? { after: p.after, source: p.source.label, reason: p.reason, proposes: msg.proposes, empty: msg.empty } : null;
697
+ },
698
+ });
699
+ }
700
+ }
701
+ /** The row "Show in grid" just landed on; it flashes for a moment so the eye finds it. */
702
+ let flashRow = $state(null);
703
+ let flashTimer;
704
+ function flash(rowId) {
705
+ flashRow = rowId;
706
+ clearTimeout(flashTimer);
707
+ flashTimer = setTimeout(() => (flashRow = null), 1600);
708
+ }
709
+ const formats = $derived.by(() => {
710
+ void version;
711
+ const api = getApi();
712
+ if (!api || !api.getOption('getRowId'))
713
+ return [];
714
+ linkCards(api);
715
+ const idOf = rowIdFn(api);
716
+ const cols = api.getColumns();
717
+ const out = pendingFormats(list('pending'), idOf, cols);
718
+ // After the pending tint, so a disputed cell reads as disputed.
719
+ out.push(...pendingFormats(list('conflict'), idOf, cols, { background: CONFLICT_BG, icon: CONFLICT_ICON }));
720
+ const lit = flashRow;
721
+ // Last, so it wins over the pending tint; every column of the row.
722
+ if (lit !== null)
723
+ out.push({ type: 'rule', when: ({ row }) => String(idOf(row, 0)) === lit, background: FLASH_BG });
724
+ return out;
725
+ });
726
+ /** Pending-field set as a string, so `columns()` rebuilds only when it changes. */
727
+ const pendingFields = $derived.by(() => {
728
+ void version;
729
+ return [...new Set(list('pending').map((p) => p.field))].sort().join(SEP);
730
+ });
731
+ /** The first non-blank value of `field`, so a boolean column is never wrapped. */
732
+ function sampleValue(field) {
733
+ for (const row of getApi()?.getData() ?? []) {
734
+ const v = row[field];
735
+ if (v != null && v !== '')
736
+ return v;
737
+ }
738
+ return undefined;
739
+ }
740
+ let columnsCache = null;
741
+ function columns(cols) {
742
+ // The grid reads its columns in every view (table, board, gallery), so cards link here too.
743
+ const linked = getApi();
744
+ if (linked)
745
+ linkCards(linked);
746
+ const fields = pendingFields;
747
+ if (columnsCache && columnsCache.input === cols && columnsCache.fields === fields)
748
+ return columnsCache.output;
749
+ const output = wrapColumns(cols, new Set(fields ? fields.split(SEP) : []), review, sampleValue);
750
+ columnsCache = { input: cols, fields, output };
751
+ return output;
752
+ }
753
+ // ---- navigation ---------------------------------------------------------
754
+ /**
755
+ * Show the next proposal of `ids` after `fromId`, on whatever page it is:
756
+ * with client paging the grid moves to its page (`api.getRowPosition`),
757
+ * then scrolls to it. Does not move the active cell. With grouping, where
758
+ * pages are counted in groups, it stays on the current page. Returns the id
759
+ * shown and how many of `ids` cannot be shown (filtered out, or on another
760
+ * page of a grouped grid).
761
+ */
762
+ function reveal(ids, fromId) {
763
+ const api = getApi();
764
+ if (!api)
765
+ return { id: null, hidden: ids.length };
766
+ const proposals = ids.map((i) => all.get(i)).filter((p) => !!p);
767
+ const grouped = (api.getState().grouping?.length ?? 0) > 0;
768
+ // The grid pages with `pageable`, or the older `showPagination`.
769
+ const paged = !!(api.getOption('pageable') ?? api.getOption('showPagination')) && !api.getOption('externalPagination');
770
+ let reachable;
771
+ if (grouped) {
772
+ const idFn = rowIdFn(api);
773
+ const onPage = new Set(api.getDisplayedRows().map((r, i) => String(idFn(r, i))));
774
+ reachable = proposals.filter((p) => onPage.has(p.rowId)).map((p) => ({ p, position: -1 }));
775
+ }
776
+ else {
777
+ reachable = proposals
778
+ .map((p) => ({ p, position: api.getRowPosition(p.rowId) }))
779
+ .filter((x) => x.position !== null);
780
+ }
781
+ const hidden = proposals.length - reachable.length;
782
+ if (!reachable.length)
783
+ return { id: null, hidden };
784
+ const start = fromId ? reachable.findIndex((x) => x.p.id === fromId) + 1 : 0;
785
+ const target = reachable[start % reachable.length];
786
+ if (!grouped) {
787
+ let index = target.position;
788
+ if (paged) {
789
+ const { pageIndex, pageSize } = api.getPageInfo();
790
+ const page = Math.floor(index / pageSize);
791
+ if (page !== pageIndex)
792
+ api.setPage(page);
793
+ index -= page * pageSize;
794
+ }
795
+ api.scrollToRow(index);
796
+ flash(target.p.rowId);
797
+ return { id: target.p.id, hidden };
798
+ }
799
+ // The display index counts group banners, which getDisplayedRows skips:
800
+ // walk the display rows until the id matches.
801
+ const limit = api.getDisplayedRows().length * 2 + 64;
802
+ for (let i = 0; i < limit; i++) {
803
+ if (api.getRowId(i) === target.p.rowId) {
804
+ api.scrollToRow(i);
805
+ flash(target.p.rowId);
806
+ break;
807
+ }
808
+ }
809
+ return { id: target.p.id, hidden };
810
+ }
811
+ // Row index for labels, rebuilt when the grid's data array changes.
812
+ let labelIndex = null;
813
+ function rowLabel(p) {
814
+ const api = getApi();
815
+ if (!api || !api.getOption('getRowId'))
816
+ return null;
817
+ const data = api.getData();
818
+ if (labelIndex?.data !== data)
819
+ labelIndex = { data, map: indexRows(api) };
820
+ const i = labelIndex.map.get(p.rowId);
821
+ if (i === undefined)
822
+ return null;
823
+ const row = data[i];
824
+ if (opts.rowLabel)
825
+ return opts.rowLabel(row) || null;
826
+ for (const c of api.getColumns()) {
827
+ if (!c.visible || !c.field)
828
+ continue;
829
+ const v = row[c.field];
830
+ if (typeof v === 'string' && v.trim())
831
+ return c.field === p.field ? null : v.trim();
832
+ }
833
+ return null;
834
+ }
835
+ const review = {
836
+ get version() {
837
+ return version;
838
+ },
839
+ /** The wording in effect: English with `messages` laid over it. */
840
+ messages: msg,
841
+ /** The last undo or redo whose save failed, or null. */
842
+ get lastError() {
843
+ return lastError;
844
+ },
845
+ add,
846
+ get: (id) => {
847
+ void version;
848
+ return all.get(id);
849
+ },
850
+ proposals: () => list(),
851
+ /** What names the proposal's row (`rowLabel`), or null. */
852
+ rowLabel,
853
+ /** A field's column header, as the group labels show it. */
854
+ fieldLabel: (field) => fieldLabels()(field),
855
+ pending: () => list('pending'),
856
+ conflicts: () => list('conflict'),
857
+ groups,
858
+ pendingFor,
859
+ accept,
860
+ reject,
861
+ acceptGroup: (key, shownIds) => accept(groupIds(key, shownIds)),
862
+ rejectGroup: (key, shownIds) => reject(groupIds(key, shownIds)),
863
+ resolveConflict,
864
+ get formats() {
865
+ return formats;
866
+ },
867
+ columns,
868
+ reveal,
869
+ showPendingOnly,
870
+ /** On while our condition is in the grid's advanced filter (the grid's own Clear takes it out too). */
871
+ get pendingOnly() {
872
+ return pendingOnly && hasPending(getApi()?.getAdvancedFilter() ?? null);
873
+ },
874
+ toJSON,
875
+ load,
876
+ merge,
877
+ log,
878
+ revertSet,
879
+ sets: sets_,
880
+ clear,
881
+ };
882
+ return review;
883
+ }