diff --git a/app/client/components/Clipboard.js b/app/client/components/Clipboard.js index c196cd81..decd3f16 100644 --- a/app/client/components/Clipboard.js +++ b/app/client/components/Clipboard.js @@ -107,6 +107,7 @@ Base.setBaseFor(Clipboard); Clipboard.commands = { contextMenuCopy: function() { this._doContextMenuCopy(); }, + contextMenuCopyWithHeaders: function() { this._doContextMenuCopyWithHeaders(); }, contextMenuCut: function() { this._doContextMenuCut(); }, contextMenuPaste: function() { this._doContextMenuPaste(); }, }; @@ -126,7 +127,13 @@ Clipboard.prototype._onCopy = function(elem, event) { Clipboard.prototype._doContextMenuCopy = function() { let pasteObj = commands.allCommands.copy.run(); - this._copyToClipboard(pasteObj, 'copy'); + this._copyToClipboard(pasteObj, 'copy', false); +}; + +Clipboard.prototype._doContextMenuCopyWithHeaders = function() { + let pasteObj = commands.allCommands.copy.run(); + + this._copyToClipboard(pasteObj, 'copy', true); }; Clipboard.prototype._onCut = function(elem, event) { @@ -146,21 +153,21 @@ Clipboard.prototype._doContextMenuCut = function() { Clipboard.prototype._setCBdata = function(pasteObj, clipboardData) { if (!pasteObj) { return; } - const plainText = tableUtil.makePasteText(pasteObj.data, pasteObj.selection); + const plainText = tableUtil.makePasteText(pasteObj.data, pasteObj.selection, false); clipboardData.setData('text/plain', plainText); - const htmlText = tableUtil.makePasteHtml(pasteObj.data, pasteObj.selection); + const htmlText = tableUtil.makePasteHtml(pasteObj.data, pasteObj.selection, false); clipboardData.setData('text/html', htmlText); this._setCutCallback(pasteObj, plainText); }; -Clipboard.prototype._copyToClipboard = async function(pasteObj, action) { +Clipboard.prototype._copyToClipboard = async function(pasteObj, action, includeColHeaders) { if (!pasteObj) { return; } - const plainText = tableUtil.makePasteText(pasteObj.data, pasteObj.selection); + const plainText = tableUtil.makePasteText(pasteObj.data, pasteObj.selection, includeColHeaders); let data; if (typeof ClipboardItem === 'function') { - const htmlText = tableUtil.makePasteHtml(pasteObj.data, pasteObj.selection); + const htmlText = tableUtil.makePasteHtml(pasteObj.data, pasteObj.selection, includeColHeaders); // eslint-disable-next-line no-undef data = new ClipboardItem({ // eslint-disable-next-line no-undef diff --git a/app/client/components/commandList.ts b/app/client/components/commandList.ts index 532b7cab..00a9e027 100644 --- a/app/client/components/commandList.ts +++ b/app/client/components/commandList.ts @@ -63,6 +63,7 @@ export type CommandName = | 'cut' | 'paste' | 'contextMenuCopy' + | 'contextMenuCopyWithHeaders' | 'contextMenuCut' | 'contextMenuPaste' | 'fillSelectionDown' @@ -470,6 +471,10 @@ export const groups: CommendGroupDef[] = [{ keys: ['Mod+C'], desc: 'Copy current selection to clipboard', bindKeys: false, + }, { + name: 'contextMenuCopyWithHeaders', + keys: [], + desc: 'Copy current selection to clipboard including headers', }, { name: 'contextMenuCut', keys: ['Mod+X'], diff --git a/app/client/lib/tableUtil.ts b/app/client/lib/tableUtil.ts index 5db8153a..4bec1ce6 100644 --- a/app/client/lib/tableUtil.ts +++ b/app/client/lib/tableUtil.ts @@ -30,12 +30,16 @@ export function fieldInsertPositions(viewFields: KoArray, index: n * @param {CopySelection} selection - a CopySelection instance * @return {String} **/ -export function makePasteText(tableData: TableData, selection: CopySelection) { +export function makePasteText(tableData: TableData, selection: CopySelection, includeColHeaders: boolean) { // tsvEncode expects data as a 2-d array with each a array representing a row // i.e. [["1-1", "1-2", "1-3"],["2-1", "2-2", "2-3"]] - const values = selection.rowIds.map(rowId => - selection.columns.map(col => col.fmtGetter(rowId))); - return tsvEncode(values); + const result = []; + if (includeColHeaders) { + result.push(selection.fields.map(f => f.label())); + } + result.push(...selection.rowIds.map(rowId => + selection.columns.map(col => col.fmtGetter(rowId)))); + return tsvEncode(result); } /** @@ -70,7 +74,7 @@ export function makePasteHtml(tableData: TableData, selection: CopySelection, in )), // Include column headers if requested. (includeColHeaders ? - dom('tr', selection.colIds.map(colId => dom('th', colId))) : + dom('tr', selection.fields.map(field => dom('th', field.label()))) : null ), // Fill with table cells. diff --git a/app/client/ui/CellContextMenu.ts b/app/client/ui/CellContextMenu.ts index 9bb13702..8e4efbbb 100644 --- a/app/client/ui/CellContextMenu.ts +++ b/app/client/ui/CellContextMenu.ts @@ -38,6 +38,7 @@ export function CellContextMenu(cellOptions: ICellContextMenu, colOptions: IMult result.push( menuItemCmd(allCommands.contextMenuCut, t('Cut'), disableForReadonlyColumn), menuItemCmd(allCommands.contextMenuCopy, t('Copy')), + menuItemCmd(allCommands.contextMenuCopyWithHeaders, t('Copy with headers')), menuItemCmd(allCommands.contextMenuPaste, t('Paste'), disableForReadonlyColumn), menuDivider(), colOptions.isFormula ? diff --git a/test/nbrowser/CopyPaste.ts b/test/nbrowser/CopyPaste.ts index 1da7857f..16c1c395 100644 --- a/test/nbrowser/CopyPaste.ts +++ b/test/nbrowser/CopyPaste.ts @@ -637,7 +637,7 @@ async function copyAndCheck( } } -function createDummyTextArea() { +export function createDummyTextArea() { const textarea = document.createElement('textarea'); textarea.style.position = "absolute"; textarea.style.top = "0"; @@ -647,7 +647,7 @@ function createDummyTextArea() { window.document.body.appendChild(textarea); } -function removeDummyTextArea() { +export function removeDummyTextArea() { const textarea = document.getElementById('dummyText'); if (textarea) { window.document.body.removeChild(textarea); diff --git a/test/nbrowser/CopyWithHeaders.ts b/test/nbrowser/CopyWithHeaders.ts new file mode 100644 index 00000000..852de7d1 --- /dev/null +++ b/test/nbrowser/CopyWithHeaders.ts @@ -0,0 +1,59 @@ +/** + * Test for copying Grist data with headers. + */ + +import {assert, driver, Key} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {setupTestSuite} from 'test/nbrowser/testUtils'; +import {createDummyTextArea, removeDummyTextArea} from 'test/nbrowser/CopyPaste'; + +describe("CopyWithHeaders", function() { + this.timeout(90000); + const cleanup = setupTestSuite(); + const clipboard = gu.getLockableClipboard(); + afterEach(() => gu.checkForErrors()); + gu.bigScreen(); + + after(async function() { + await driver.executeScript(removeDummyTextArea); + }); + + it('should copy headers', async function() { + const session = await gu.session().teamSite.login(); + await session.tempDoc(cleanup, 'Hello.grist'); + await driver.executeScript(createDummyTextArea); + + await clipboard.lockAndPerform(async (cb) => { + // Select all + await gu.sendKeys(Key.chord(Key.CONTROL, 'a')); + await gu.rightClick(gu.getCell({rowNum: 1, col: 'A'})); + await driver.findContent('.grist-floating-menu li', 'Copy with headers').click(); + + await pasteAndCheck(cb, ["A", "B", "C", "D", "E"], 5); + }); + + await clipboard.lockAndPerform(async (cb) => { + // Select a single cell. + await gu.getCell({rowNum: 2, col: 'D'}).click(); + await gu.rightClick(gu.getCell({rowNum: 2, col: 'D'})); + await driver.findContent('.grist-floating-menu li', 'Copy with headers').click(); + + await pasteAndCheck(cb, ["D"], 2); + }); + }); +}); + +async function pasteAndCheck(cb: gu.IClipboard, headers: string[], rows: number) { + // Paste into the dummy textarea. + await driver.find('#dummyText').click(); + await gu.waitAppFocus(false); + await cb.paste(); + + const textarea = await driver.find('#dummyText'); + const text = await textarea.getAttribute('value'); + const lines = text.split('\n'); + const regex = new RegExp(`^${headers.join('\\s+')}$`); + assert.match(lines[0], regex); + assert.equal(lines.length, rows); + await textarea.clear(); +}