Skip to content

Commit

Permalink
feat: paste multiline content as single cell (#1563)
Browse files Browse the repository at this point in the history
* feat: paste multiline content as single cell
  • Loading branch information
zewa666 committed Jun 15, 2024
1 parent cbd6ae4 commit 4398f1d
Show file tree
Hide file tree
Showing 7 changed files with 130 additions and 5 deletions.
11 changes: 10 additions & 1 deletion docs/grid-functionalities/excel-copy-buffer.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,13 @@ this.gridOptions = {
};
```

This way you have full control, via the args parameters, to get a ref to the current row and cell being updated. Also keep in mind that while performing a buffered table paste (e.g three cols at once) and only one of them has a denying condition, the other cells paste will pass successfully.
This way you have full control, via the args parameters, to get a ref to the current row and cell being updated. Also keep in mind that while performing a buffered table paste (e.g three cols at once) and only one of them has a denying condition, the other cells paste will pass successfully.

### Handling quoted multiline pastes
When copying Excel cells, where the value is a multiline text, the copy buffer will result in a quoted text buffer. Slickgrid will properly handle the case when this content itself contains `\n` characters, to not break onto multiple rows as well as not split on the next column if `\t` characters are contained.

You can control if the newlines should be replaced with a specific value by defining it in `replaceNewlinesWith`. As an example you could set it to `' '`.

Additionally, you can define that the quoted string is pasted without the quotes by setting `removeDoubleQuotesOnPaste` to true.

> Note: requires v5.2.0 and higher
2 changes: 2 additions & 0 deletions examples/vite-demo-vanilla-bundle/src/examples/example19.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ export default class Example19 {
return !(args.row === 0 || (args.row === 1 && args.cell > 2 && args.cell < 6));
},
copyActiveEditorCell: true,
removeDoubleQuotesOnPaste: true,
replaceNewlinesWith: ' ',
}
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ describe('CellExcelCopyManager', () => {
newRowCreator: expect.anything(),
includeHeaderWhenCopying: false,
readOnlyMode: false,
removeDoubleQuotesOnPaste: false,
replaceNewlinesWith: false
};
expect(plugin.addonOptions).toEqual(expectedAddonOptions);
expect(plugin.gridOptions).toEqual(gridOptionsMock);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,104 @@ describe('CellExternalCopyManager', () => {
}, 2);
});

it('should remove quotes on multiline content pastes', (done) => {
jest.spyOn(gridStub.getSelectionModel() as SelectionModel, 'getSelectedRanges').mockReturnValueOnce([new SlickRange(0, 1, 1, 2)]).mockReturnValueOnce([new SlickRange(0, 1, 1, 2)]);
let clipCommand;
const clipboardCommandHandler = (cmd) => {
clipCommand = cmd;
cmd.execute();
};

plugin.init(gridStub, { clipboardPasteDelay: 1, clearCopySelectionDelay: 1, includeHeaderWhenCopying: true, clipboardCommandHandler, removeDoubleQuotesOnPaste: true });

const keyDownCtrlCopyEvent = new Event('keydown');
Object.defineProperty(keyDownCtrlCopyEvent, 'ctrlKey', { writable: true, configurable: true, value: true });
Object.defineProperty(keyDownCtrlCopyEvent, 'key', { writable: true, configurable: true, value: 'c' });
Object.defineProperty(keyDownCtrlCopyEvent, 'isPropagationStopped', { writable: true, configurable: true, value: jest.fn() });
Object.defineProperty(keyDownCtrlCopyEvent, 'isImmediatePropagationStopped', { writable: true, configurable: true, value: jest.fn() });
gridStub.onKeyDown.notify({ cell: 0, row: 0, grid: gridStub }, keyDownCtrlCopyEvent, gridStub);

const updateCellSpy = jest.spyOn(gridStub, 'updateCell');
const onCellChangeSpy = jest.spyOn(gridStub.onCellChange, 'notify');
const getActiveCellSpy = jest.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 0, row: 1 });
const keyDownCtrlPasteEvent = new Event('keydown');
jest.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns);
Object.defineProperty(keyDownCtrlPasteEvent, 'ctrlKey', { writable: true, configurable: true, value: true });
Object.defineProperty(keyDownCtrlPasteEvent, 'key', { writable: true, configurable: true, value: 'v' });
Object.defineProperty(keyDownCtrlPasteEvent, 'isPropagationStopped', { writable: true, configurable: true, value: jest.fn() });
Object.defineProperty(keyDownCtrlPasteEvent, 'isImmediatePropagationStopped', { writable: true, configurable: true, value: jest.fn() });
gridStub.onKeyDown.notify({ cell: 0, row: 0, grid: gridStub }, keyDownCtrlPasteEvent, gridStub);
document.querySelector('textarea')!.value = `"Smith"`;

setTimeout(() => {
expect(getActiveCellSpy).toHaveBeenCalled();
expect(updateCellSpy).toHaveBeenCalledWith(0, 1);
expect(updateCellSpy).toHaveBeenCalledWith(0, 2);
expect(onCellChangeSpy).toHaveBeenCalledWith({ row: 1, cell: 2, item: { firstName: 'John', lastName: 'Smith' }, grid: gridStub, column: {} });

const getDataItemSpy = jest.spyOn(gridStub, 'getDataItem');
const updateCell2Spy = jest.spyOn(gridStub, 'updateCell');
jest.spyOn(gridStub.onCellChange, 'notify');
const setDataItemValSpy = jest.spyOn(plugin, 'setDataItemValueForColumn');
clipCommand.undo();
expect(getDataItemSpy).toHaveBeenCalled();
expect(updateCell2Spy).toHaveBeenCalled();
expect(onCellChangeSpy).toHaveBeenCalled();
// expect(onCellChange2Spy).toHaveBeenCalledWith({ row: 1, cell: 2, item: { firstName: 'John', lastName: 'Smith' }, grid: gridStub, column: {} });
expect(setDataItemValSpy).toHaveBeenCalled();
done();
}, 2);
});

it('should replace newlines with configured characters for multiline content', (done) => {
jest.spyOn(gridStub.getSelectionModel() as SelectionModel, 'getSelectedRanges').mockReturnValueOnce([new SlickRange(0, 1, 1, 2)]).mockReturnValueOnce([new SlickRange(0, 1, 1, 2)]);
let clipCommand;
const clipboardCommandHandler = (cmd) => {
clipCommand = cmd;
cmd.execute();
};

plugin.init(gridStub, { clipboardPasteDelay: 1, clearCopySelectionDelay: 1, includeHeaderWhenCopying: true, clipboardCommandHandler, replaceNewlinesWith: '🥳' });

const keyDownCtrlCopyEvent = new Event('keydown');
Object.defineProperty(keyDownCtrlCopyEvent, 'ctrlKey', { writable: true, configurable: true, value: true });
Object.defineProperty(keyDownCtrlCopyEvent, 'key', { writable: true, configurable: true, value: 'c' });
Object.defineProperty(keyDownCtrlCopyEvent, 'isPropagationStopped', { writable: true, configurable: true, value: jest.fn() });
Object.defineProperty(keyDownCtrlCopyEvent, 'isImmediatePropagationStopped', { writable: true, configurable: true, value: jest.fn() });
gridStub.onKeyDown.notify({ cell: 0, row: 0, grid: gridStub }, keyDownCtrlCopyEvent, gridStub);

const updateCellSpy = jest.spyOn(gridStub, 'updateCell');
const onCellChangeSpy = jest.spyOn(gridStub.onCellChange, 'notify');
const getActiveCellSpy = jest.spyOn(gridStub, 'getActiveCell').mockReturnValue({ cell: 0, row: 1 });
const keyDownCtrlPasteEvent = new Event('keydown');
jest.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns);
Object.defineProperty(keyDownCtrlPasteEvent, 'ctrlKey', { writable: true, configurable: true, value: true });
Object.defineProperty(keyDownCtrlPasteEvent, 'key', { writable: true, configurable: true, value: 'v' });
Object.defineProperty(keyDownCtrlPasteEvent, 'isPropagationStopped', { writable: true, configurable: true, value: jest.fn() });
Object.defineProperty(keyDownCtrlPasteEvent, 'isImmediatePropagationStopped', { writable: true, configurable: true, value: jest.fn() });
gridStub.onKeyDown.notify({ cell: 0, row: 0, grid: gridStub }, keyDownCtrlPasteEvent, gridStub);
document.querySelector('textarea')!.value = `"Smith\nDoe"`;

setTimeout(() => {
expect(getActiveCellSpy).toHaveBeenCalled();
expect(updateCellSpy).toHaveBeenCalledWith(0, 1);
expect(updateCellSpy).toHaveBeenCalledWith(0, 2);
expect(onCellChangeSpy).toHaveBeenCalledWith({ row: 1, cell: 2, item: { firstName: 'John', lastName: '"Smith🥳Doe"' }, grid: gridStub, column: {} });

const getDataItemSpy = jest.spyOn(gridStub, 'getDataItem');
const updateCell2Spy = jest.spyOn(gridStub, 'updateCell');
jest.spyOn(gridStub.onCellChange, 'notify');
const setDataItemValSpy = jest.spyOn(plugin, 'setDataItemValueForColumn');
clipCommand.undo();
expect(getDataItemSpy).toHaveBeenCalled();
expect(updateCell2Spy).toHaveBeenCalled();
expect(onCellChangeSpy).toHaveBeenCalled();
// expect(onCellChange2Spy).toHaveBeenCalledWith({ row: 1, cell: 2, item: { firstName: 'John', lastName: 'Smith' }, grid: gridStub, column: {} });
expect(setDataItemValSpy).toHaveBeenCalled();
done();
}, 2);
});

it('should Copy, Paste but not execute run clipCommandHandler when defined', (done) => {
const mockClipboardCommandHandler = jest.fn();
jest.spyOn(gridStub.getSelectionModel() as SelectionModel, 'getSelectedRanges').mockReturnValueOnce([new SlickRange(0, 1, 2, 2)]).mockReturnValueOnce(null as any);
Expand Down
4 changes: 3 additions & 1 deletion packages/common/src/extensions/slickCellExcelCopyManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,9 @@ export class SlickCellExcelCopyManager {
for (let i = 0; i < count; i++) {
this._grid.getData<SlickDataView>().addItem({ [this.gridOptions.datasetIdPropertyName || 'id']: `newRow_${newRowIds++}` });
}
}
},
replaceNewlinesWith: false,
removeDoubleQuotesOnPaste: false
};
}

Expand Down
12 changes: 10 additions & 2 deletions packages/common/src/extensions/slickCellExternalCopyManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@ export class SlickCellExternalCopyManager {
protected decodeTabularData(grid: SlickGrid, textAreaElement: HTMLTextAreaElement): void {
const columns = grid.getColumns();
const clipText = textAreaElement.value;
const clipRows = clipText.split(/[\n\f\r]/);
const clipRows = clipText.split(/[\n\f\r](?=(?:[^"]*"[^"]*")*[^"]*$)/);

// trim trailing CR if present
if (clipRows[clipRows.length - 1] === '') {
clipRows.pop();
Expand All @@ -239,7 +240,14 @@ export class SlickCellExternalCopyManager {
this._bodyElement.removeChild(textAreaElement);

for (const clipRow of clipRows) {
clippedRange[j++] = clipRow !== '' ? clipRow.split('\t') : [''];
if (clipRow.startsWith('"') && clipRow.endsWith('"')) {
clippedRange[j++] = [clipRow
.replaceAll('\n', this._addonOptions.replaceNewlinesWith || '\n')
.replaceAll('\r', '')
.replaceAll('"', this._addonOptions.removeDoubleQuotesOnPaste ? '' : '"')];
} else {
clippedRange[j++] = clipRow.split('\t');
}
}
const selectedCell = this._grid.getActiveCell();
const ranges = this._grid.getSelectionModel()?.getSelectedRanges();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

import type { Column, FormatterResultWithHtml, FormatterResultWithText, OnEventArgs, } from './index';
import type { SlickCellExcelCopyManager, } from '../extensions/slickCellExcelCopyManager';
import type { SlickEventData, SlickRange } from '../core/index';
Expand Down Expand Up @@ -53,6 +52,11 @@ export interface ExcelCopyBufferOption<T = any> {
/** option to specify a custom column header value extractor function */
headerColumnValueExtractor?: (columnDef: Column<T>) => string | HTMLElement | DocumentFragment;

/** if the copied text starts and ends with a double-quote (Excel multiline string) replace newlines with the defined character. (default: false) */
replaceNewlinesWith?: string | false;

/** multiline strings copied from Excel are pasted with double quotes. Should those be removed? (default: false) */
removeDoubleQuotesOnPaste?: boolean;
// --
// Events
// ------------
Expand Down

0 comments on commit 4398f1d

Please sign in to comment.