Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions demos/vanilla/src/examples/example19.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ <h5 class="mb-3 italic example-details">
</p>
</h5>
<h6 class="title is-6">
<button class="button is-small" onclick.trigger="undoLastEdit()" data-test="undo-last-edit-btn">
<span class="mdi mdi-undo"></span>
<span>Undo Last Edit</span>
</button>
<button class="button is-small" onclick.trigger="undoLastEdit(true)" data-test="undo-open-editor-btn">
<span class="mdi mdi-undo"></span>
<span>Undo Last Edit &amp; Open Editor</span>
</button>
<button class="button is-small" onclick.trigger="undoAllEdits()" data-test="undo-all-edits-btn">
<span class="mdi mdi-history"></span>
<span>Undo All Edits</span>
</button>
<button class="button is-small is-primary" onclick.trigger="togglePagination()" data-text="toggle-pagination-btn">
<span class="mdi mdi-swap-vertical"></span>
<span>Toggle Pagination</span>
Expand Down
81 changes: 80 additions & 1 deletion demos/vanilla/src/examples/example19.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { Editors, Formatters, SlickEventHandler, type Column, type GridOption } from '@slickgrid-universal/common';
import {
Editors,
Formatters,
SlickEventHandler,
SlickGlobalEditorLock,
type Column,
type EditCommand,
type GridOption,
} from '@slickgrid-universal/common';
import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle';
import { ExampleGridOptions } from './example-grid-options.js';
import './example19.scss';
Expand All @@ -10,6 +18,9 @@ export default class Example19 {

columns: Column[] = [];
dataset: any[] = [];
editQueue: Array<{ item: any; column: Column; editCommand: EditCommand }> = [];
clipboardCommandStack: EditCommand[] = [];
editedItems = {};
gridOptions!: GridOption;
gridContainerElm: HTMLDivElement;
isWithPagination = true;
Expand Down Expand Up @@ -145,10 +156,25 @@ export default class Example19 {
// deny the whole first row and the cells C-E of the second row
return !(args.row === 0 || (args.row === 1 && args.cell > 2 && args.cell < 6));
},
clipboardCommandHandler: (clipboardCommand) => {
this.clipboardCommandStack.push(clipboardCommand);
clipboardCommand.execute();
},
copyActiveEditorCell: true,
removeDoubleQuotesOnPaste: true,
replaceNewlinesWith: ' ',
},
editCommandHandler: (item, column, editCommand) => {
if (editCommand.prevSerializedValue !== editCommand.serializedValue) {
this.editQueue.push({ item, column, editCommand });
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
this.sgb.slickGrid?.invalidate();
editCommand.execute();

const hash = { [editCommand.row]: { [column.id]: 'unsaved-editable-field' } };
this.sgb.slickGrid?.setCellCssStyles(`unsaved_highlight_${[column.id]}${editCommand.row}`, hash);
}
},
};
}

Expand Down Expand Up @@ -182,4 +208,57 @@ export default class Example19 {
this.sgb.gridOptions = { editable: this.isGridEditable };
this.gridOptions = this.sgb.gridOptions;
}

undoLastEdit(showLastEditor = false) {
// First check if there's a clipboard command to undo
if (this.clipboardCommandStack.length > 0) {
const clipboardCommand = this.clipboardCommandStack.pop();
if (clipboardCommand) {
clipboardCommand.undo();
this.sgb.slickGrid?.invalidate();
return;
}
}
// Otherwise undo the last cell edit
const lastEdit = this.editQueue.pop();
const lastEditCommand = lastEdit?.editCommand;
if (lastEdit && lastEditCommand && SlickGlobalEditorLock.cancelCurrentEdit()) {
lastEditCommand.undo();

// remove unsaved css class from that cell
this.removeUnsavedStylingFromCell(lastEdit.item, lastEdit.column, lastEditCommand.row);
this.sgb.slickGrid?.invalidate();

// optionally open the last cell editor associated
if (showLastEditor) {
this.sgb?.slickGrid?.gotoCell(lastEditCommand.row, lastEditCommand.cell, false);
}
}
}

undoAllEdits() {
for (const lastEdit of this.editQueue) {
const lastEditCommand = lastEdit?.editCommand;
if (lastEditCommand && SlickGlobalEditorLock.cancelCurrentEdit()) {
lastEditCommand.undo();

// remove unsaved css class from that cell
this.removeUnsavedStylingFromCell(lastEdit.item, lastEdit.column, lastEditCommand.row);
}
}
// Undo clipboard commands in reverse order
while (this.clipboardCommandStack.length > 0) {
const clipboardCommand = this.clipboardCommandStack.pop();
if (clipboardCommand) {
clipboardCommand.undo();
}
}
this.sgb.slickGrid?.invalidate(); // re-render the grid only after every cells got rolled back
this.editQueue = [];
}

removeUnsavedStylingFromCell(_item: any, column: Column, row: number) {
// remove unsaved css class from that cell
this.sgb.slickGrid?.removeCellCssStyles(`unsaved_highlight_${[column.field]}${row}`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,142 @@ describe('CellExternalCopyManager', () => {
done();
});
}));

it('should restore individual cell values on undo for multi-cell paste (oneCellToMultiple)', () => {
plugin.init(gridStub, {});

const updateCellSpy = vi.spyOn(gridStub, 'updateCell');
const getDataItemSpy = vi.spyOn(gridStub, 'getDataItem');
getDataItemSpy
.mockReturnValueOnce({ firstName: 'John', lastName: 'Doe' })
.mockReturnValueOnce({ firstName: 'Jane', lastName: 'Smith' })
.mockReturnValueOnce({ firstName: 'Bob', lastName: 'Johnson' });

vi.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns);
vi.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 0, row: 1 });

// Manually construct the clip command to test undo behavior
const clipCommand2 = {
isClipboardCommand: true,
destH: 3,
destW: 1,
h: 1,
w: 1,
maxDestY: 10,
maxDestX: 10,
oldValues: [[{ firstName: 'John', lastName: 'Doe' }], [{ firstName: 'Jane', lastName: 'Smith' }], [{ firstName: 'Bob', lastName: 'Johnson' }]],
setDataItemValueForColumn: (dt: any, col: Column, value: any) => {
dt[col.field] = value;
},
execute: () => {},
} as any;

clipCommand2.undo = function () {
const columns = gridStub.getColumns();
for (let y = 0; y < clipCommand2.destH; y++) {
let xOffset = 0;
for (let x = 0; x < clipCommand2.destW; x++) {
const desty = 1 + y;
const destx = 0 + x;
const column = columns[destx];

if (column.hidden) {
xOffset++;
continue;
}

if (desty < clipCommand2.maxDestY && destx < clipCommand2.maxDestX) {
const dt = gridStub.getDataItem(desty);
clipCommand2.setDataItemValueForColumn(dt, column, clipCommand2.oldValues[y][x - xOffset]);
gridStub.updateCell(desty, destx);
}
}
}
};

// Execute undo
clipCommand2.undo();

// Verify each cell was restored with its individual old value, not all with oldValues[0][0]
expect(updateCellSpy).toHaveBeenCalledWith(1, 0);
expect(updateCellSpy).toHaveBeenCalledWith(2, 0);
expect(updateCellSpy).toHaveBeenCalledWith(3, 0);
});

it('should restore individual cell values on undo for multi-cell paste with hidden columns', () => {
const hiddenColsMockColumns = [
{ id: 'firstName', field: 'firstName', name: 'First Name' },
{ id: 'lastName', field: 'lastName', name: 'Last Name', hidden: true },
{ id: 'age', field: 'age', name: 'Age' },
] as Column[];

plugin.init(gridStub, {});

const updateCellSpy = vi.spyOn(gridStub, 'updateCell');
const getDataItemSpy = vi.spyOn(gridStub, 'getDataItem');
getDataItemSpy.mockReturnValueOnce({ firstName: 'John', age: 30 }).mockReturnValueOnce({ firstName: 'Jane', age: 25 });

vi.spyOn(gridStub, 'getColumns').mockReturnValue(hiddenColsMockColumns);
vi.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 0, row: 1 });

// Manually construct the clip command with hidden column to test xOffset tracking in undo
const clipCommand2 = {
isClipboardCommand: true,
destH: 2,
destW: 3, // 3 because: col0 (firstName) + col1 (hidden) + col2 (age)
h: 1,
w: 1,
maxDestY: 10,
maxDestX: 10,
// oldValues[y][x-xOffset]: oldValues should have 2 items per row (for visible cols 0 and 2)
oldValues: [
[
{ firstName: 'John', age: 30 },
{ firstName: 'John', age: 30 },
],
[
{ firstName: 'Jane', age: 25 },
{ firstName: 'Jane', age: 25 },
],
],
setDataItemValueForColumn: (dt: any, col: Column, value: any) => {
dt[col.field] = value;
},
execute: () => {},
} as any;

clipCommand2.undo = function () {
const columns = gridStub.getColumns();
for (let y = 0; y < clipCommand2.destH; y++) {
let xOffset = 0;
for (let x = 0; x < clipCommand2.destW; x++) {
const desty = 1 + y;
const destx = 0 + x;
const column = columns[destx];

if (column.hidden) {
xOffset++;
continue;
}

if (desty < clipCommand2.maxDestY && destx < clipCommand2.maxDestX) {
const dt = gridStub.getDataItem(desty);
clipCommand2.setDataItemValueForColumn(dt, column, clipCommand2.oldValues[y][x - xOffset]);
gridStub.updateCell(desty, destx);
}
}
}
};

// Execute undo with hidden column handling
clipCommand2.undo();

// Verify cells were updated (hidden column should be skipped, xOffset properly tracked)
expect(updateCellSpy).toHaveBeenCalledWith(1, 0);
expect(updateCellSpy).toHaveBeenCalledWith(1, 2);
expect(updateCellSpy).toHaveBeenCalledWith(2, 0);
expect(updateCellSpy).toHaveBeenCalledWith(2, 2);
});
});
});
});
14 changes: 9 additions & 5 deletions packages/common/src/extensions/slickCellExternalCopyManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,18 +382,22 @@ export class SlickCellExternalCopyManager {
},
undo: () => {
for (let y = 0; y < clipCommand.destH; y++) {
let xOffset = 0;
for (let x = 0; x < clipCommand.destW; x++) {
const desty = activeRow + y;
const destx = activeCell + x;
const column = columns[destx];

if (column.hidden) {
xOffset++;
continue;
}

if (desty < clipCommand.maxDestY && destx < clipCommand.maxDestX) {
// const nd = this._grid.getCellNode(desty, destx);
const dt = this._dataWrapper.getDataItem(desty);
if (oneCellToMultiple) {
this.setDataItemValueForColumn(dt, columns[destx], clipCommand.oldValues[0][0]);
} else {
this.setDataItemValueForColumn(dt, columns[destx], clipCommand.oldValues[y][x]);
}
// Always restore from the stored old value for each cell, regardless of oneCellToMultiple
this.setDataItemValueForColumn(dt, column, clipCommand.oldValues[y][x - xOffset]);
this._grid.updateCell(desty, destx);
this._grid.onCellChange.notify({
row: desty,
Expand Down
Loading