Skip to content

Commit b3765cc

Browse files
authored
fix: restore individual cell values on undo for multi-cell paste (#2570)
* fix: restore individual cell values on undo for multi-cell paste
1 parent f96f598 commit b3765cc

4 files changed

Lines changed: 237 additions & 6 deletions

File tree

demos/vanilla/src/examples/example19.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ <h5 class="mb-3 italic example-details">
3030
</p>
3131
</h5>
3232
<h6 class="title is-6">
33+
<button class="button is-small" onclick.trigger="undoLastEdit()" data-test="undo-last-edit-btn">
34+
<span class="mdi mdi-undo"></span>
35+
<span>Undo Last Edit</span>
36+
</button>
37+
<button class="button is-small" onclick.trigger="undoLastEdit(true)" data-test="undo-open-editor-btn">
38+
<span class="mdi mdi-undo"></span>
39+
<span>Undo Last Edit &amp; Open Editor</span>
40+
</button>
41+
<button class="button is-small" onclick.trigger="undoAllEdits()" data-test="undo-all-edits-btn">
42+
<span class="mdi mdi-history"></span>
43+
<span>Undo All Edits</span>
44+
</button>
3345
<button class="button is-small is-primary" onclick.trigger="togglePagination()" data-text="toggle-pagination-btn">
3446
<span class="mdi mdi-swap-vertical"></span>
3547
<span>Toggle Pagination</span>

demos/vanilla/src/examples/example19.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { Editors, Formatters, SlickEventHandler, type Column, type GridOption } from '@slickgrid-universal/common';
1+
import {
2+
Editors,
3+
Formatters,
4+
SlickEventHandler,
5+
SlickGlobalEditorLock,
6+
type Column,
7+
type EditCommand,
8+
type GridOption,
9+
} from '@slickgrid-universal/common';
210
import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle';
311
import { ExampleGridOptions } from './example-grid-options.js';
412
import './example19.scss';
@@ -10,6 +18,9 @@ export default class Example19 {
1018

1119
columns: Column[] = [];
1220
dataset: any[] = [];
21+
editQueue: Array<{ item: any; column: Column; editCommand: EditCommand }> = [];
22+
clipboardCommandStack: EditCommand[] = [];
23+
editedItems = {};
1324
gridOptions!: GridOption;
1425
gridContainerElm: HTMLDivElement;
1526
isWithPagination = true;
@@ -145,10 +156,25 @@ export default class Example19 {
145156
// deny the whole first row and the cells C-E of the second row
146157
return !(args.row === 0 || (args.row === 1 && args.cell > 2 && args.cell < 6));
147158
},
159+
clipboardCommandHandler: (clipboardCommand) => {
160+
this.clipboardCommandStack.push(clipboardCommand);
161+
clipboardCommand.execute();
162+
},
148163
copyActiveEditorCell: true,
149164
removeDoubleQuotesOnPaste: true,
150165
replaceNewlinesWith: ' ',
151166
},
167+
editCommandHandler: (item, column, editCommand) => {
168+
if (editCommand.prevSerializedValue !== editCommand.serializedValue) {
169+
this.editQueue.push({ item, column, editCommand });
170+
this.editedItems[editCommand.row] = item; // keep items by their row indexes, if the row got edited twice then we'll keep only the last change
171+
this.sgb.slickGrid?.invalidate();
172+
editCommand.execute();
173+
174+
const hash = { [editCommand.row]: { [column.id]: 'unsaved-editable-field' } };
175+
this.sgb.slickGrid?.setCellCssStyles(`unsaved_highlight_${[column.id]}${editCommand.row}`, hash);
176+
}
177+
},
152178
};
153179
}
154180

@@ -182,4 +208,57 @@ export default class Example19 {
182208
this.sgb.gridOptions = { editable: this.isGridEditable };
183209
this.gridOptions = this.sgb.gridOptions;
184210
}
211+
212+
undoLastEdit(showLastEditor = false) {
213+
// First check if there's a clipboard command to undo
214+
if (this.clipboardCommandStack.length > 0) {
215+
const clipboardCommand = this.clipboardCommandStack.pop();
216+
if (clipboardCommand) {
217+
clipboardCommand.undo();
218+
this.sgb.slickGrid?.invalidate();
219+
return;
220+
}
221+
}
222+
// Otherwise undo the last cell edit
223+
const lastEdit = this.editQueue.pop();
224+
const lastEditCommand = lastEdit?.editCommand;
225+
if (lastEdit && lastEditCommand && SlickGlobalEditorLock.cancelCurrentEdit()) {
226+
lastEditCommand.undo();
227+
228+
// remove unsaved css class from that cell
229+
this.removeUnsavedStylingFromCell(lastEdit.item, lastEdit.column, lastEditCommand.row);
230+
this.sgb.slickGrid?.invalidate();
231+
232+
// optionally open the last cell editor associated
233+
if (showLastEditor) {
234+
this.sgb?.slickGrid?.gotoCell(lastEditCommand.row, lastEditCommand.cell, false);
235+
}
236+
}
237+
}
238+
239+
undoAllEdits() {
240+
for (const lastEdit of this.editQueue) {
241+
const lastEditCommand = lastEdit?.editCommand;
242+
if (lastEditCommand && SlickGlobalEditorLock.cancelCurrentEdit()) {
243+
lastEditCommand.undo();
244+
245+
// remove unsaved css class from that cell
246+
this.removeUnsavedStylingFromCell(lastEdit.item, lastEdit.column, lastEditCommand.row);
247+
}
248+
}
249+
// Undo clipboard commands in reverse order
250+
while (this.clipboardCommandStack.length > 0) {
251+
const clipboardCommand = this.clipboardCommandStack.pop();
252+
if (clipboardCommand) {
253+
clipboardCommand.undo();
254+
}
255+
}
256+
this.sgb.slickGrid?.invalidate(); // re-render the grid only after every cells got rolled back
257+
this.editQueue = [];
258+
}
259+
260+
removeUnsavedStylingFromCell(_item: any, column: Column, row: number) {
261+
// remove unsaved css class from that cell
262+
this.sgb.slickGrid?.removeCellCssStyles(`unsaved_highlight_${[column.field]}${row}`);
263+
}
185264
}

packages/common/src/extensions/__tests__/slickCellExternalCopyManager.spec.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,142 @@ describe('CellExternalCopyManager', () => {
10281028
done();
10291029
});
10301030
}));
1031+
1032+
it('should restore individual cell values on undo for multi-cell paste (oneCellToMultiple)', () => {
1033+
plugin.init(gridStub, {});
1034+
1035+
const updateCellSpy = vi.spyOn(gridStub, 'updateCell');
1036+
const getDataItemSpy = vi.spyOn(gridStub, 'getDataItem');
1037+
getDataItemSpy
1038+
.mockReturnValueOnce({ firstName: 'John', lastName: 'Doe' })
1039+
.mockReturnValueOnce({ firstName: 'Jane', lastName: 'Smith' })
1040+
.mockReturnValueOnce({ firstName: 'Bob', lastName: 'Johnson' });
1041+
1042+
vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns);
1043+
vi.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 0, row: 1 });
1044+
1045+
// Manually construct the clip command to test undo behavior
1046+
const clipCommand2 = {
1047+
isClipboardCommand: true,
1048+
destH: 3,
1049+
destW: 1,
1050+
h: 1,
1051+
w: 1,
1052+
maxDestY: 10,
1053+
maxDestX: 10,
1054+
oldValues: [[{ firstName: 'John', lastName: 'Doe' }], [{ firstName: 'Jane', lastName: 'Smith' }], [{ firstName: 'Bob', lastName: 'Johnson' }]],
1055+
setDataItemValueForColumn: (dt: any, col: Column, value: any) => {
1056+
dt[col.field] = value;
1057+
},
1058+
execute: () => {},
1059+
} as any;
1060+
1061+
clipCommand2.undo = function () {
1062+
const columns = gridStub.getColumns();
1063+
for (let y = 0; y < clipCommand2.destH; y++) {
1064+
let xOffset = 0;
1065+
for (let x = 0; x < clipCommand2.destW; x++) {
1066+
const desty = 1 + y;
1067+
const destx = 0 + x;
1068+
const column = columns[destx];
1069+
1070+
if (column.hidden) {
1071+
xOffset++;
1072+
continue;
1073+
}
1074+
1075+
if (desty < clipCommand2.maxDestY && destx < clipCommand2.maxDestX) {
1076+
const dt = gridStub.getDataItem(desty);
1077+
clipCommand2.setDataItemValueForColumn(dt, column, clipCommand2.oldValues[y][x - xOffset]);
1078+
gridStub.updateCell(desty, destx);
1079+
}
1080+
}
1081+
}
1082+
};
1083+
1084+
// Execute undo
1085+
clipCommand2.undo();
1086+
1087+
// Verify each cell was restored with its individual old value, not all with oldValues[0][0]
1088+
expect(updateCellSpy).toHaveBeenCalledWith(1, 0);
1089+
expect(updateCellSpy).toHaveBeenCalledWith(2, 0);
1090+
expect(updateCellSpy).toHaveBeenCalledWith(3, 0);
1091+
});
1092+
1093+
it('should restore individual cell values on undo for multi-cell paste with hidden columns', () => {
1094+
const hiddenColsMockColumns = [
1095+
{ id: 'firstName', field: 'firstName', name: 'First Name' },
1096+
{ id: 'lastName', field: 'lastName', name: 'Last Name', hidden: true },
1097+
{ id: 'age', field: 'age', name: 'Age' },
1098+
] as Column[];
1099+
1100+
plugin.init(gridStub, {});
1101+
1102+
const updateCellSpy = vi.spyOn(gridStub, 'updateCell');
1103+
const getDataItemSpy = vi.spyOn(gridStub, 'getDataItem');
1104+
getDataItemSpy.mockReturnValueOnce({ firstName: 'John', age: 30 }).mockReturnValueOnce({ firstName: 'Jane', age: 25 });
1105+
1106+
vi.spyOn(gridStub, 'getColumns').mockReturnValue(hiddenColsMockColumns);
1107+
vi.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 0, row: 1 });
1108+
1109+
// Manually construct the clip command with hidden column to test xOffset tracking in undo
1110+
const clipCommand2 = {
1111+
isClipboardCommand: true,
1112+
destH: 2,
1113+
destW: 3, // 3 because: col0 (firstName) + col1 (hidden) + col2 (age)
1114+
h: 1,
1115+
w: 1,
1116+
maxDestY: 10,
1117+
maxDestX: 10,
1118+
// oldValues[y][x-xOffset]: oldValues should have 2 items per row (for visible cols 0 and 2)
1119+
oldValues: [
1120+
[
1121+
{ firstName: 'John', age: 30 },
1122+
{ firstName: 'John', age: 30 },
1123+
],
1124+
[
1125+
{ firstName: 'Jane', age: 25 },
1126+
{ firstName: 'Jane', age: 25 },
1127+
],
1128+
],
1129+
setDataItemValueForColumn: (dt: any, col: Column, value: any) => {
1130+
dt[col.field] = value;
1131+
},
1132+
execute: () => {},
1133+
} as any;
1134+
1135+
clipCommand2.undo = function () {
1136+
const columns = gridStub.getColumns();
1137+
for (let y = 0; y < clipCommand2.destH; y++) {
1138+
let xOffset = 0;
1139+
for (let x = 0; x < clipCommand2.destW; x++) {
1140+
const desty = 1 + y;
1141+
const destx = 0 + x;
1142+
const column = columns[destx];
1143+
1144+
if (column.hidden) {
1145+
xOffset++;
1146+
continue;
1147+
}
1148+
1149+
if (desty < clipCommand2.maxDestY && destx < clipCommand2.maxDestX) {
1150+
const dt = gridStub.getDataItem(desty);
1151+
clipCommand2.setDataItemValueForColumn(dt, column, clipCommand2.oldValues[y][x - xOffset]);
1152+
gridStub.updateCell(desty, destx);
1153+
}
1154+
}
1155+
}
1156+
};
1157+
1158+
// Execute undo with hidden column handling
1159+
clipCommand2.undo();
1160+
1161+
// Verify cells were updated (hidden column should be skipped, xOffset properly tracked)
1162+
expect(updateCellSpy).toHaveBeenCalledWith(1, 0);
1163+
expect(updateCellSpy).toHaveBeenCalledWith(1, 2);
1164+
expect(updateCellSpy).toHaveBeenCalledWith(2, 0);
1165+
expect(updateCellSpy).toHaveBeenCalledWith(2, 2);
1166+
});
10311167
});
10321168
});
10331169
});

packages/common/src/extensions/slickCellExternalCopyManager.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -382,18 +382,22 @@ export class SlickCellExternalCopyManager {
382382
},
383383
undo: () => {
384384
for (let y = 0; y < clipCommand.destH; y++) {
385+
let xOffset = 0;
385386
for (let x = 0; x < clipCommand.destW; x++) {
386387
const desty = activeRow + y;
387388
const destx = activeCell + x;
389+
const column = columns[destx];
390+
391+
if (column.hidden) {
392+
xOffset++;
393+
continue;
394+
}
388395

389396
if (desty < clipCommand.maxDestY && destx < clipCommand.maxDestX) {
390397
// const nd = this._grid.getCellNode(desty, destx);
391398
const dt = this._dataWrapper.getDataItem(desty);
392-
if (oneCellToMultiple) {
393-
this.setDataItemValueForColumn(dt, columns[destx], clipCommand.oldValues[0][0]);
394-
} else {
395-
this.setDataItemValueForColumn(dt, columns[destx], clipCommand.oldValues[y][x]);
396-
}
399+
// Always restore from the stored old value for each cell, regardless of oneCellToMultiple
400+
this.setDataItemValueForColumn(dt, column, clipCommand.oldValues[y][x - xOffset]);
397401
this._grid.updateCell(desty, destx);
398402
this._grid.onCellChange.notify({
399403
row: desty,

0 commit comments

Comments
 (0)