@opencorestack/opengridx 3.3.0 → 3.4.0

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 (59) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +4 -1
  3. package/dist/ai.cjs +1 -0
  4. package/dist/ai.cjs.map +1 -0
  5. package/dist/ai.d.ts +166 -0
  6. package/dist/ai.es.js +1 -0
  7. package/dist/ai.es.js.map +1 -0
  8. package/dist/index.d.ts +148 -2
  9. package/dist/opengridx.css +1 -1
  10. package/dist/opengridx.es.js +6084 -5523
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +17 -15
  13. package/dist/opengridx.umd.js.map +1 -1
  14. package/docs/API_REFERENCE.md +98 -1
  15. package/docs/README.md +3 -1
  16. package/docs/architecture/datagrid-orchestration.md +10 -1
  17. package/docs/customization/theming.md +9 -0
  18. package/docs/features/ai-toolkit.md +119 -0
  19. package/docs/features/clipboard.md +88 -0
  20. package/docs/features/editing-reordering.md +50 -0
  21. package/docs/features/keyboard-navigation.md +11 -0
  22. package/docs/features/undo-redo.md +79 -0
  23. package/docs/roadmap.md +1 -1
  24. package/lib/ai/coerce.ts +90 -0
  25. package/lib/ai/columns.ts +90 -0
  26. package/lib/ai/index.ts +16 -0
  27. package/lib/ai/schema.ts +214 -0
  28. package/lib/ai/types.ts +88 -0
  29. package/lib/ai/validate.ts +286 -0
  30. package/lib/components/Cell/Cell.css +5 -0
  31. package/lib/components/Cell/GridEditInputCell.css +12 -4
  32. package/lib/components/Cell/GridEditInputCell.tsx +19 -12
  33. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
  34. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
  35. package/lib/components/DataGrid/DataGrid.tsx +62 -2
  36. package/lib/components/FilterPanel/FilterPanel.css +14 -0
  37. package/lib/components/FilterPanel/FilterPanel.tsx +3 -2
  38. package/lib/components/ListView/ListView.css +2 -4
  39. package/lib/components/ListView/ListViewRow.tsx +2 -2
  40. package/lib/components/Toolbar/GlobalSearch.css +16 -0
  41. package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
  42. package/lib/components/ui/Checkbox.tsx +16 -5
  43. package/lib/components/ui/Input.css +43 -0
  44. package/lib/components/ui/Input.tsx +24 -5
  45. package/lib/hooks/core/gridApiPlaceholder.ts +8 -1
  46. package/lib/hooks/core/useDataGrid.ts +27 -0
  47. package/lib/hooks/core/useGridCellSelection.ts +30 -2
  48. package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
  49. package/lib/hooks/core/useGridUndoRedo.ts +220 -0
  50. package/lib/hooks/features/useGridBatchEdit.ts +217 -0
  51. package/lib/hooks/features/useGridEditing.ts +24 -45
  52. package/lib/index.ts +7 -0
  53. package/lib/styles/opengridx.css +2 -1
  54. package/lib/types/index.ts +124 -0
  55. package/lib/utils/editing/commit.ts +69 -0
  56. package/lib/utils/editing/targets.ts +109 -0
  57. package/lib/utils/parsing.ts +252 -0
  58. package/llms.txt +55 -1
  59. package/package.json +8 -2
@@ -0,0 +1,220 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
2
+ import type { MutableRefObject, RefObject } from 'react';
3
+ import type {
4
+ GridApi,
5
+ GridBatchEditResult,
6
+ GridColDef,
7
+ GridHistoryChangeParams,
8
+ GridRowId,
9
+ GridRowModel,
10
+ } from '../../types';
11
+ import { classifyKeyTarget } from '../../utils/focus';
12
+ import type { FocusedCell } from '../../utils/focus';
13
+ import { scrollColumnIntoView, scrollRowIntoView } from '../../utils/scroll';
14
+ import { boundingTargetRect, buildEditTargetContext, rectCorners } from '../../utils/editing/targets';
15
+ import type { GridCellChange } from '../../utils/editing/commit';
16
+ import type { GridEditSource, UseGridBatchEditReturn } from '../features/useGridBatchEdit';
17
+ import type { UseGridCellSelectionReturn } from './useGridCellSelection';
18
+ import type { LayoutResult } from './useLayout';
19
+
20
+ /** Default number of actions `undoRedo` keeps. */
21
+ export const UNDO_HISTORY_LIMIT = 100;
22
+
23
+ type CommitListener = (changes: GridCellChange[], source: GridEditSource) => void;
24
+
25
+ export interface GridEditCommitChannel {
26
+ /** Passed to the commit paths (`useGridEditing`, `useGridBatchEdit`) as `onCommitted`. Stable. */
27
+ emit: CommitListener;
28
+ /** Set by `useGridUndoRedo`, which runs later in the hook order. Stable. */
29
+ setListener: (listener: CommitListener | null) => void;
30
+ }
31
+
32
+ /**
33
+ * Connects the commit paths, which run early in the hook order, to the history, which runs late
34
+ * (it needs the focus and selection hooks). Created before `useGridBatchEdit`.
35
+ */
36
+ export function useGridEditCommitChannel(): GridEditCommitChannel {
37
+ const listenerRef = useRef<CommitListener | null>(null);
38
+ const emit = useCallback<CommitListener>((changes, source) => {
39
+ const listener = listenerRef.current;
40
+ if (listener) listener(changes, source);
41
+ }, []);
42
+ const setListener = useCallback((listener: CommitListener | null) => {
43
+ listenerRef.current = listener;
44
+ }, []);
45
+ return useMemo(() => ({ emit, setListener }), [emit, setListener]);
46
+ }
47
+
48
+ export interface UseGridUndoRedoParams<R extends GridRowModel> {
49
+ apiRef: MutableRefObject<GridApi>;
50
+ enabled: boolean;
51
+ limit: number;
52
+ onHistoryChange?: (params: GridHistoryChangeParams) => void;
53
+ channel: GridEditCommitChannel;
54
+ batchEdit: UseGridBatchEditReturn<R>;
55
+ isEditing: boolean;
56
+ containerRef: RefObject<HTMLDivElement | null>;
57
+ viewportRef: RefObject<HTMLDivElement | null>;
58
+ selection: UseGridCellSelectionReturn;
59
+ setFocusedCell: React.Dispatch<React.SetStateAction<FocusedCell | null>>;
60
+ allRenderableRows: R[];
61
+ getRowId: (row: R) => GridRowId;
62
+ navigationColumns: ReadonlyArray<GridColDef<R>>;
63
+ layout: LayoutResult<R>;
64
+ rowHeight: number;
65
+ pinnedTopRowCount: number;
66
+ }
67
+
68
+ const emptyResult = (): GridBatchEditResult => ({ updated: [], failed: [], skipped: [] });
69
+
70
+ /** 'undo', 'redo' or null for a keydown. Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, Ctrl+Y (also on non-Latin layouts). */
71
+ function historyShortcut(event: KeyboardEvent): 'undo' | 'redo' | null {
72
+ if (event.altKey || !(event.ctrlKey || event.metaKey)) return null;
73
+ const key = (event.key ?? '').toLowerCase();
74
+ const latin = /^[a-z]$/.test(key);
75
+ const isZ = key === 'z' || (!latin && event.code === 'KeyZ');
76
+ const isY = key === 'y' || (!latin && event.code === 'KeyY');
77
+ if (isZ) return event.shiftKey ? 'redo' : 'undo';
78
+ if (isY && event.ctrlKey && !event.shiftKey) return 'redo';
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * Undo / redo of cell edits, pastes and range clears (`undoRedo`, v3.4). An action is recorded only
84
+ * after `processRowUpdate` succeeded, as the changed cells with their value before and the stored
85
+ * value after. Undo writes the `before` values as a batch edit (so `valueSetter` and
86
+ * `processRowUpdate` run); a cell that no longer holds its `after` value is skipped (`'changed'`), a
87
+ * row that is gone too (`'missing'`). Redo is the reverse. Runs after useGridClipboardPaste.
88
+ */
89
+ export function useGridUndoRedo<R extends GridRowModel>(params: UseGridUndoRedoParams<R>): void {
90
+ const { apiRef, enabled, channel, containerRef } = params;
91
+ const latestRef = useRef(params);
92
+ useLayoutEffect(() => {
93
+ latestRef.current = params;
94
+ });
95
+
96
+ const undoStackRef = useRef<GridCellChange[][]>([]);
97
+ const redoStackRef = useRef<GridCellChange[][]>([]);
98
+ const lastEmittedRef = useRef<GridHistoryChangeParams>({ canUndo: false, canRedo: false, size: 0 });
99
+ // Undo and redo run one after the other, each on the rows the previous one stored.
100
+ const queueRef = useRef<Promise<unknown>>(Promise.resolve());
101
+
102
+ const notify = useCallback(() => {
103
+ const next = {
104
+ canUndo: undoStackRef.current.length > 0,
105
+ canRedo: redoStackRef.current.length > 0,
106
+ size: undoStackRef.current.length,
107
+ };
108
+ const last = lastEmittedRef.current;
109
+ if (last.canUndo === next.canUndo && last.canRedo === next.canRedo && last.size === next.size) return;
110
+ lastEmittedRef.current = next;
111
+ latestRef.current.onHistoryChange?.(next);
112
+ }, []);
113
+
114
+ const record = useCallback((changes: GridCellChange[]) => {
115
+ if (!latestRef.current.enabled || changes.length === 0) return;
116
+ const stack = undoStackRef.current;
117
+ stack.push(changes);
118
+ const limit = Math.max(1, latestRef.current.limit);
119
+ if (stack.length > limit) stack.splice(0, stack.length - limit);
120
+ redoStackRef.current = [];
121
+ notify();
122
+ }, [notify]);
123
+
124
+ useLayoutEffect(() => {
125
+ channel.setListener(enabled ? record : null);
126
+ return () => channel.setListener(null);
127
+ }, [channel, enabled, record]);
128
+
129
+ const clearHistory = useCallback(() => {
130
+ undoStackRef.current = [];
131
+ redoStackRef.current = [];
132
+ notify();
133
+ }, [notify]);
134
+
135
+ // Turning undoRedo off forgets the history.
136
+ useEffect(() => {
137
+ if (!enabled) clearHistory();
138
+ }, [enabled, clearHistory]);
139
+
140
+ /** Selects the affected cells (their bounding rectangle) and scrolls its first cell into view. */
141
+ const reveal = useCallback((cells: GridCellChange[]) => {
142
+ const p = latestRef.current;
143
+ const ctx = buildEditTargetContext(p.allRenderableRows, p.getRowId, p.navigationColumns);
144
+ const rect = boundingTargetRect(ctx, cells);
145
+ if (!rect) return;
146
+ const { anchor, head } = rectCorners(ctx, rect);
147
+ if (p.selection.enabled) p.selection.setModel([{ anchor, head }], 'keyboard');
148
+ else p.setFocusedCell({ id: anchor.id, field: anchor.field, rowIndex: rect.top });
149
+ const viewport = p.viewportRef.current;
150
+ if (!viewport) return;
151
+ scrollRowIntoView(viewport, rect.top - p.pinnedTopRowCount, p.layout.cumulativeHeights, p.layout.pinnedBottomHeight, p.rowHeight);
152
+ scrollColumnIntoView(viewport, p.layout.unpinnedColsWithWidth.findIndex(c => c.field === anchor.field), p.layout);
153
+ }, []);
154
+
155
+ /** Replays the top action of one stack onto the other. */
156
+ const replay = useCallback((direction: 'undo' | 'redo'): Promise<GridBatchEditResult> => {
157
+ const run = async (): Promise<GridBatchEditResult> => {
158
+ if (!latestRef.current.enabled) return emptyResult();
159
+ const from = direction === 'undo' ? undoStackRef : redoStackRef;
160
+ const to = direction === 'undo' ? redoStackRef : undoStackRef;
161
+ const action = from.current.pop();
162
+ if (!action) return emptyResult();
163
+ notify();
164
+ // Undo writes `before` where `after` still holds; redo writes `after` where `before` holds.
165
+ const edits = action.map(change => direction === 'undo'
166
+ ? { id: change.id, field: change.field, value: change.before, expect: { value: change.after } }
167
+ : { id: change.id, field: change.field, value: change.after, expect: { value: change.before } });
168
+ const outcome = await latestRef.current.batchEdit.applyEdits(edits, direction);
169
+ if (outcome.committed.length > 0) {
170
+ // The opposite action: for an undo, `before` is what it stored and `after` what it replaced.
171
+ to.current.push(direction === 'undo'
172
+ ? outcome.committed.map(c => ({ id: c.id, field: c.field, before: c.after, after: c.before }))
173
+ : outcome.committed);
174
+ }
175
+ // Rows whose processRowUpdate failed keep their part of the action, to try again.
176
+ const failedIds = new Set<GridRowId>(outcome.result.failed.map(f => f.id));
177
+ if (failedIds.size > 0) {
178
+ const remainder = action.filter(change => failedIds.has(change.id));
179
+ if (remainder.length > 0) from.current.push(remainder);
180
+ }
181
+ notify();
182
+ if (outcome.committed.length > 0) reveal(outcome.committed);
183
+ return outcome.result;
184
+ };
185
+ const next = queueRef.current.then(run, run);
186
+ queueRef.current = next;
187
+ return next;
188
+ }, [notify, reveal]);
189
+
190
+ const undo = useCallback(() => replay('undo'), [replay]);
191
+ const redo = useCallback(() => replay('redo'), [replay]);
192
+
193
+ useEffect(() => {
194
+ const root = containerRef.current;
195
+ if (!root || !enabled) return;
196
+ const onKeyDown = (event: KeyboardEvent) => {
197
+ if (event.defaultPrevented) return;
198
+ const shortcut = historyShortcut(event);
199
+ if (!shortcut) return;
200
+ const viewport = latestRef.current.viewportRef.current;
201
+ if (!viewport || latestRef.current.isEditing) return;
202
+ // Inside an editor or a text field the input's own undo wins.
203
+ const kind = classifyKeyTarget(event.target, viewport);
204
+ if (kind !== 'grid' && kind !== 'control') return;
205
+ event.preventDefault();
206
+ void (shortcut === 'undo' ? undo() : redo());
207
+ };
208
+ root.addEventListener('keydown', onKeyDown);
209
+ return () => root.removeEventListener('keydown', onKeyDown);
210
+ }, [containerRef, enabled, undo, redo]);
211
+
212
+ useLayoutEffect(() => {
213
+ const api = apiRef.current;
214
+ api.undo = undo;
215
+ api.redo = redo;
216
+ api.canUndo = () => latestRef.current.enabled && undoStackRef.current.length > 0;
217
+ api.canRedo = () => latestRef.current.enabled && redoStackRef.current.length > 0;
218
+ api.clearHistory = clearHistory;
219
+ }, [apiRef, undo, redo, clearHistory]);
220
+ }
@@ -0,0 +1,217 @@
1
+ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react';
2
+ import type { GridBatchEditResult, GridColDef, GridEditSkipReason, GridRowId, GridRowModel } from '../../types';
3
+ import { attempt } from '../../utils/attempt';
4
+ import { getCellValue } from '../../utils/values';
5
+ import {
6
+ buildEditedRow,
7
+ invalidProcessedRowError,
8
+ isPromiseLike,
9
+ isSameValue,
10
+ reportEditError,
11
+ } from '../../utils/editing/commit';
12
+ import type { GridCellChange } from '../../utils/editing/commit';
13
+
14
+ /** What made a commit: history records `edit`, `paste` and `clear`; `undo` / `redo` replay it. */
15
+ export type GridEditSource = 'edit' | 'paste' | 'clear' | 'undo' | 'redo';
16
+
17
+ /** One cell to write in a batch edit. */
18
+ export interface GridBatchCellEdit {
19
+ id: GridRowId;
20
+ field: string;
21
+ value: unknown;
22
+ /**
23
+ * Undo/redo: the value the cell must still hold. When it holds something else the cell is
24
+ * skipped with reason `'changed'` (values are compared, not row objects).
25
+ */
26
+ expect?: { value: unknown };
27
+ }
28
+
29
+ /** A batch edit's result, plus the cell changes that were stored (what history records). */
30
+ export interface GridBatchEditOutcome {
31
+ result: GridBatchEditResult;
32
+ committed: GridCellChange[];
33
+ }
34
+
35
+ export interface UseGridBatchEditParams<R extends GridRowModel> {
36
+ /** The rendered row store (`state.rows.idRowsLookup`). */
37
+ rowsLookup: ReadonlyMap<GridRowId, R>;
38
+ columns: GridColDef<R>[];
39
+ processRowUpdate?: (newRow: R, oldRow: R) => R | Promise<R>;
40
+ onProcessRowUpdateError?: (error: unknown) => void;
41
+ /** Stores rows in one update (`replaceRows` of the row store). */
42
+ replaceRows: (rows: ReadonlyMap<GridRowId, R>) => void;
43
+ /** Receives the stored changes of every commit that is not an undo or redo (history). */
44
+ onCommitted?: (changes: GridCellChange[], source: GridEditSource) => void;
45
+ }
46
+
47
+ export interface UseGridBatchEditReturn<R extends GridRowModel> {
48
+ /**
49
+ * Writes cells through `valueSetter` → `processRowUpdate` (once per row, rows in parallel) →
50
+ * the store (one update for every row that succeeded). A row that throws or rejects is reported
51
+ * to `onProcessRowUpdateError` and does not stop the others. Cells whose value does not change
52
+ * are left alone. Resolves when every row has settled.
53
+ */
54
+ applyEdits: (edits: readonly GridBatchCellEdit[], source: GridEditSource) => Promise<GridBatchEditOutcome>;
55
+ /** The latest row under `id`, including rows stored but not rendered yet. */
56
+ getRow: (id: GridRowId) => R | undefined;
57
+ /** Stores one committed row (a single-cell edit), so `getRow` sees it before the next render. */
58
+ storeRow: (id: GridRowId, row: R) => void;
59
+ }
60
+
61
+ interface RowCell<R extends GridRowModel> {
62
+ field: string;
63
+ value: unknown;
64
+ colDef: GridColDef<R> | undefined;
65
+ order: number;
66
+ }
67
+
68
+ interface RowGroup<R extends GridRowModel> {
69
+ id: GridRowId;
70
+ row: R;
71
+ cells: RowCell<R>[];
72
+ }
73
+
74
+ type Settled = { id: GridRowId; ok: true; processed: unknown } | { id: GridRowId; ok: false; error: unknown };
75
+
76
+ /**
77
+ * Batch edits: several cells committed together through the single-edit pipeline (paste, range clear,
78
+ * undo, redo). One `processRowUpdate` per row, one store update for the batch, failures isolated per
79
+ * row and reported through the same path as single edits. The returned functions are stable.
80
+ */
81
+ export function useGridBatchEdit<R extends GridRowModel>(params: UseGridBatchEditParams<R>): UseGridBatchEditReturn<R> {
82
+ const latestRef = useRef(params);
83
+ // Rows stored since the last render. The rendered lookup catches up on the next render, when
84
+ // this is emptied; until then an undo right after a paste still reads the pasted rows.
85
+ const overlayRef = useRef(new Map<GridRowId, R>());
86
+ const warnedFieldsRef = useRef(new Set<string>());
87
+ useLayoutEffect(() => {
88
+ latestRef.current = params;
89
+ overlayRef.current.clear();
90
+ });
91
+
92
+ const getRow = useCallback((id: GridRowId): R | undefined => {
93
+ const pending = overlayRef.current.get(id);
94
+ return pending === undefined ? latestRef.current.rowsLookup.get(id) : pending;
95
+ }, []);
96
+
97
+ const store = useCallback((rows: Map<GridRowId, R>) => {
98
+ if (rows.size === 0) return;
99
+ rows.forEach((row, id) => { overlayRef.current.set(id, row); });
100
+ latestRef.current.replaceRows(rows);
101
+ }, []);
102
+
103
+ const storeRow = useCallback((id: GridRowId, row: R) => {
104
+ store(new Map([[id, row]]));
105
+ }, [store]);
106
+
107
+ const applyEdits = useCallback((edits: readonly GridBatchCellEdit[], source: GridEditSource): Promise<GridBatchEditOutcome> => {
108
+ const { columns, processRowUpdate } = latestRef.current;
109
+ const report = (error: unknown) => reportEditError(error, latestRef.current.onProcessRowUpdateError);
110
+ const columnOrder = new Map<string, number>();
111
+ const columnByField = new Map<string, GridColDef<R>>();
112
+ columns.forEach((col, index) => {
113
+ columnOrder.set(col.field, index);
114
+ columnByField.set(col.field, col);
115
+ });
116
+
117
+ const skipped: { id: GridRowId; field: string; reason: GridEditSkipReason }[] = [];
118
+ const groups = new Map<GridRowId, RowGroup<R>>();
119
+ for (const edit of edits) {
120
+ const row = getRow(edit.id);
121
+ if (row === undefined) {
122
+ skipped.push({ id: edit.id, field: edit.field, reason: 'missing' });
123
+ continue;
124
+ }
125
+ const colDef = columnByField.get(edit.field);
126
+ if (edit.expect && !isSameValue(getCellValue(row, edit.field, colDef), edit.expect.value)) {
127
+ skipped.push({ id: edit.id, field: edit.field, reason: 'changed' });
128
+ continue;
129
+ }
130
+ let group = groups.get(edit.id);
131
+ if (!group) {
132
+ group = { id: edit.id, row, cells: [] };
133
+ groups.set(edit.id, group);
134
+ }
135
+ const order = columnOrder.get(edit.field) ?? Number.MAX_SAFE_INTEGER;
136
+ const existing = group.cells.findIndex(cell => cell.field === edit.field);
137
+ const cell = { field: edit.field, value: edit.value, colDef, order };
138
+ // The same cell twice: the last value wins.
139
+ if (existing === -1) group.cells.push(cell);
140
+ else group.cells[existing] = cell;
141
+ }
142
+
143
+ const settled: Settled[] = [];
144
+ const pending: Promise<Settled>[] = [];
145
+ const warned = warnedFieldsRef.current;
146
+ groups.forEach(group => {
147
+ const { id, row } = group;
148
+ // Cells that already hold the value are not written; a row with none left is left alone.
149
+ const cells = group.cells
150
+ .filter(cell => !isSameValue(getCellValue(row, cell.field, cell.colDef), cell.value))
151
+ .sort((a, b) => a.order - b.order);
152
+ group.cells = cells;
153
+ if (cells.length === 0) return;
154
+ const outcome = attempt<unknown>(() => {
155
+ let newRow = row;
156
+ for (const cell of cells) newRow = buildEditedRow(newRow, cell, cell.colDef, warned);
157
+ return processRowUpdate ? processRowUpdate(newRow, row) : newRow;
158
+ });
159
+ if (!outcome.ok) {
160
+ settled.push({ id, ok: false, error: outcome.error });
161
+ return;
162
+ }
163
+ const value = outcome.value;
164
+ if (isPromiseLike(value)) {
165
+ pending.push(Promise.resolve(value).then(
166
+ (processed): Settled => ({ id, ok: true, processed }),
167
+ (error: unknown): Settled => ({ id, ok: false, error }),
168
+ ));
169
+ } else {
170
+ settled.push({ id, ok: true, processed: value });
171
+ }
172
+ });
173
+
174
+ const finish = (all: Settled[]): GridBatchEditOutcome => {
175
+ const byId = new Map<GridRowId, Settled>();
176
+ all.forEach(entry => { byId.set(entry.id, entry); });
177
+ const updated: GridRowId[] = [];
178
+ const failed: { id: GridRowId; error: unknown }[] = [];
179
+ const committed: GridCellChange[] = [];
180
+ const stored = new Map<GridRowId, R>();
181
+ groups.forEach(group => {
182
+ const entry = byId.get(group.id);
183
+ if (!entry) return;
184
+ if (!entry.ok) {
185
+ failed.push({ id: group.id, error: entry.error });
186
+ report(entry.error);
187
+ return;
188
+ }
189
+ const processed = entry.processed;
190
+ if (typeof processed !== 'object' || processed === null) {
191
+ const error = invalidProcessedRowError(processed, 'The row was not saved.');
192
+ failed.push({ id: group.id, error });
193
+ report(error);
194
+ return;
195
+ }
196
+ const storedRow = processed as R;
197
+ stored.set(group.id, storedRow);
198
+ updated.push(group.id);
199
+ for (const cell of group.cells) {
200
+ const before = getCellValue(group.row, cell.field, cell.colDef);
201
+ const after = getCellValue(storedRow, cell.field, cell.colDef);
202
+ if (!isSameValue(before, after)) committed.push({ id: group.id, field: cell.field, before, after });
203
+ }
204
+ });
205
+ store(stored);
206
+ if (committed.length > 0 && source !== 'undo' && source !== 'redo') {
207
+ latestRef.current.onCommitted?.(committed, source);
208
+ }
209
+ return { result: { updated, failed, skipped }, committed };
210
+ };
211
+
212
+ if (pending.length === 0) return Promise.resolve(finish(settled));
213
+ return Promise.all(pending).then(results => finish([...settled, ...results]));
214
+ }, [getRow, store]);
215
+
216
+ return useMemo(() => ({ applyEdits, getRow, storeRow }), [applyEdits, getRow, storeRow]);
217
+ }
@@ -1,6 +1,15 @@
1
1
  import { useState, useCallback, useRef, useEffect, useLayoutEffect, useMemo } from 'react';
2
2
  import type { GridColDef, GridRowId, GridRowModel } from '../../types';
3
3
  import { attempt } from '../../utils/attempt';
4
+ import { getCellValue } from '../../utils/values';
5
+ import {
6
+ buildEditedRow,
7
+ invalidProcessedRowError,
8
+ isPromiseLike,
9
+ isSameValue,
10
+ reportEditError,
11
+ } from '../../utils/editing/commit';
12
+ import type { GridCellChange } from '../../utils/editing/commit';
4
13
 
5
14
  export interface GridEditingState {
6
15
  editingCell: {
@@ -23,6 +32,12 @@ export interface UseGridEditingParams<R extends GridRowModel> {
23
32
  * grid resolved with getRowId), not on a field of the returned row.
24
33
  */
25
34
  onRowChange?: (newRow: R, id: GridRowId) => void;
35
+ /**
36
+ * Receives the cell change once `processRowUpdate` succeeded and the row was stored (undo/redo
37
+ * history): the value before and the stored value, both read through `getCellValue`. Not called
38
+ * when the stored value equals the previous one.
39
+ */
40
+ onCommitted?: (changes: GridCellChange[]) => void;
26
41
  }
27
42
 
28
43
  export interface StopCellEditParams {
@@ -46,18 +61,6 @@ interface PendingCommit {
46
61
 
47
62
  const RESOLVED: Promise<void> = Promise.resolve();
48
63
 
49
- /** A misuse of the editing contract (e.g. processRowUpdate returned nothing), as opposed to a consumer rejection. */
50
- class OpenGridXEditError extends Error {}
51
-
52
- function isSameValue(a: unknown, b: unknown): boolean {
53
- if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
54
- return Object.is(a, b);
55
- }
56
-
57
- function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
58
- return typeof value === 'object' && value !== null && typeof (value as { then?: unknown }).then === 'function';
59
- }
60
-
61
64
  /**
62
65
  * Cell edit sessions and their commits.
63
66
  *
@@ -126,24 +129,21 @@ export function useGridEditing<R extends GridRowModel>(params: UseGridEditingPar
126
129
  return RESOLVED;
127
130
  }
128
131
 
129
- const report = (error: unknown) => {
130
- const onError = latestRef.current.onProcessRowUpdateError;
131
- if (onError) {
132
- onError(error);
133
- } else if (process.env.NODE_ENV !== 'production' && error instanceof OpenGridXEditError) {
134
- console.error(error.message);
135
- }
136
- };
132
+ const report = (error: unknown) => reportEditError(error, latestRef.current.onProcessRowUpdateError);
133
+ const colDef = columns?.find(c => c.field === cell.field);
137
134
 
138
135
  const apply = (processed: unknown) => {
139
136
  if (typeof processed !== 'object' || processed === null) {
140
- report(new OpenGridXEditError(
141
- `[OpenGridX] processRowUpdate must return the updated row object (or a Promise of it); ` +
142
- `it returned ${processed === null ? 'null' : typeof processed}. The edit was not saved and the editor stays open.`
143
- ));
137
+ report(invalidProcessedRowError(processed, 'The edit was not saved and the editor stays open.'));
144
138
  return;
145
139
  }
146
140
  latestRef.current.onRowChange?.(processed as R, cell.id);
141
+ const { onCommitted } = latestRef.current;
142
+ if (onCommitted) {
143
+ const before = getCellValue(existingRow, cell.field, colDef);
144
+ const after = getCellValue(processed as R, cell.field, colDef);
145
+ if (!isSameValue(before, after)) onCommitted([{ id: cell.id, field: cell.field, before, after }]);
146
+ }
147
147
  const now = editingCellRef.current;
148
148
  if (sessionRef.current !== token || !now) return;
149
149
  if (isSameValue(now.value, cell.value)) {
@@ -164,7 +164,6 @@ export function useGridEditing<R extends GridRowModel>(params: UseGridEditingPar
164
164
  if (pendingRef.current.get(token) === entry) pendingRef.current.delete(token);
165
165
  };
166
166
 
167
- const colDef = columns?.find(c => c.field === cell.field);
168
167
  const outcome = attempt<unknown>(() => {
169
168
  const newRow = buildEditedRow(existingRow, cell, colDef, warnedFieldsRef.current);
170
169
  return processRowUpdate ? processRowUpdate(newRow, existingRow) : newRow;
@@ -246,23 +245,3 @@ export function useGridEditing<R extends GridRowModel>(params: UseGridEditingPar
246
245
  setEditCellValue
247
246
  }), [editingCell, startCellEdit, stopCellEdit, setEditCellValue]);
248
247
  }
249
-
250
- function buildEditedRow<R extends GridRowModel>(
251
- row: R,
252
- cell: EditingCell,
253
- colDef: GridColDef<R> | undefined,
254
- warnedFields: Set<string>
255
- ): R {
256
- if (colDef?.valueSetter) {
257
- return colDef.valueSetter({ value: cell.value, row, field: cell.field });
258
- }
259
- if (colDef?.valueGetter && process.env.NODE_ENV !== 'production' && !warnedFields.has(cell.field)) {
260
- warnedFields.add(cell.field);
261
- console.warn(
262
- `[OpenGridX] Column "${cell.field}" is editable and has a valueGetter but no valueSetter. ` +
263
- `The edited value was written to row["${cell.field}"], which the valueGetter may ignore, so the cell ` +
264
- 'can keep showing the old value. Add a valueSetter that maps the value back onto the row.'
265
- );
266
- }
267
- return { ...row, [cell.field]: cell.value };
268
- }
package/lib/index.ts CHANGED
@@ -93,6 +93,7 @@ export type {
93
93
  GridValueGetterParams,
94
94
  GridSortCellParams,
95
95
  GridValueSetterParams,
96
+ GridValueParserParams,
96
97
  GridValueFormatterParams,
97
98
  GridRowParams,
98
99
  GridCellParams,
@@ -108,6 +109,12 @@ export type {
108
109
  GridCellSelectionChangeDetails,
109
110
  GridSelectedCell,
110
111
  GridCellSelectionStatsSlotProps,
112
+ GridEditSkipReason,
113
+ GridBatchEditResult,
114
+ GridClipboardPasteResult,
115
+ GridBeforeClipboardPasteParams,
116
+ GridHistoryChangeParams,
117
+ GridUndoRedoOptions,
111
118
  GridColumnOrder,
112
119
  GridColumnOrderChangeParams,
113
120
  GridRowOrderChangeParams,
@@ -1,5 +1,7 @@
1
1
  @import './base/variables.css';
2
2
  @import './base/reset.css';
3
+ /* Shared primitives first, so component styles that restyle them win on equal specificity. */
4
+ @import '../components/ui/Input.css';
3
5
  @import '../components/DataGrid/DataGrid.css';
4
6
  @import '../components/Header/Header.css';
5
7
  @import '../components/Row/Row.css';
@@ -16,7 +18,6 @@
16
18
  @import '../components/ui/Checkbox.css';
17
19
  @import '../components/ui/DragHandleIcon.css';
18
20
  @import '../components/ui/ExpandIcon.css';
19
- @import '../components/ui/Input.css';
20
21
  @import '../components/Toolbar/GridToolbar.css';
21
22
  @import '../components/Toolbar/PivotPanel.css';
22
23
  @import '../components/FilterPanel/FilterPanel.css';