/** * Test for copy-pasting Grist data. * * TODO Most of the testing for copy-pasting lives in test/nbrowser/CopyPaste.ntest.js. * This file just has some more recent additions to these test. */ import {arrayRepeat} from 'app/common/gutil'; import * as _ from 'lodash'; import {assert, driver, Key, WebElement} from 'mocha-webdriver'; import * as path from 'path'; import {serveStatic} from 'test/nbrowser/customUtil'; import * as gu from 'test/nbrowser/gristUtils'; import {setupTestSuite} from 'test/nbrowser/testUtils'; describe('CopyPaste', 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 allow pasting merged cells', async function() { // Test that we can paste uneven data, i.e. containing merged cells. // Serve a static file with a page containing a table with some merged cells. const serving = await serveStatic(path.join(gu.fixturesRoot, "sites/paste")); await driver.get(`${serving.url}/paste.html`); // Select everything in our little page. await driver.executeScript(` let range = document.createRange(); range.selectNodeContents(document.querySelector('table')); let sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); `); await clipboard.lockAndPerform(async (cb) => { try { await cb.copy(); } finally { await serving?.shutdown(); } const session = await gu.session().login(); await session.tempNewDoc(cleanup, 'CopyPaste'); await gu.getCell({col: 'A', rowNum: 1}).click(); await gu.waitAppFocus(); await cb.paste(); }); await gu.waitForServer(); await gu.checkForErrors(); assert.deepEqual(await gu.getVisibleGridCells({rowNums: [1, 2, 3, 4], cols: ['A', 'B']}), [ 'a', 'b', 'c', '', 'd', 'e', 'f', '', ]); }); it('should parse pasted numbers', async function() { const session = await gu.session().teamSite.login(); await session.tempDoc(cleanup, 'PasteParsing.grist'); await driver.executeScript(createDummyTextArea); await copyAndCheck(clipboard, [ '$1', '1', '(2)', '-2', '3e4', '30000', '5,678.901', '5678.901', '23%', '0.23', '45 678', '45678', // . is a decimal separator in this locale (USA) so this can't be parsed '1.234.567', '1.234.567 INVALID', // Doesn't match the default currency of the document, whereas $ above does '€89', '€89 INVALID', ], true); // Open the side panel for the numeric column. await gu.toggleSidePanel('right', 'open'); await driver.find('.test-right-tab-field').click(); // Switch to currency mode, and check the result. await driver.findContent('.test-numeric-mode .test-select-button', /\$/).click(); // Same data, just formatted differently await checkGridCells([ '$1', '$1.00', '(2)', '-$2.00', '3e4', '$30,000.00', '5,678.901', '$5,678.90', '23%', '$0.23', '45 678', '$45,678.00', '1.234.567', '1.234.567 INVALID', '€89', '€89 INVALID', ]); // Check that currency is set to 'Default currency' by default (where the default is local currency). assert.equal(await driver.find('.test-currency-autocomplete input').value(), 'Default currency (USD)'); // Change column setting for currency to Euros await driver.findWait('.test-currency-autocomplete', 500).click(); await driver.sendKeys("eur", Key.ENTER); await gu.waitForServer(); // Same data, just formatted differently await checkGridCells([ '$1', '€1.00', '(2)', '-€2.00', '3e4', '€30,000.00', '5,678.901', '€5,678.90', '23%', '€0.23', '45 678', '€45,678.00', '1.234.567', '1.234.567 INVALID', '€89', '€89 INVALID', ]); // Copy the numbers column into itself. // Values which were already parsed remain parsed since it copies the underlying numbers. await clipboard.lockAndPerform(async (cb) => { await copy(cb, 'Parsed'); }); await checkGridCells([ '$1', '€1.00', '(2)', '-€2.00', '3e4', '€30,000.00', '5,678.901', '€5,678.90', '23%', '€0.23', '45 678', '€45,678.00', '1.234.567', '1.234.567 INVALID', // This was invalid before, so it was copied as text. // This time it parsed successfully because the currency matches. '€89', '€89.00', ]); await copyAndCheck(clipboard, [ // Now we're copying from the text column so everything is parsed again. // $ can no longer be parsed now the currency is euros. '$1', '$1 INVALID', '(2)', '-€2.00', '3e4', '€30,000.00', '5,678.901', '€5,678.90', '23%', '€0.23', '45 678', '€45,678.00', '1.234.567', '1.234.567 INVALID', '€89', '€89.00', ], true); // Change the document locale await gu.openDocumentSettings(); await driver.findWait('.test-locale-autocomplete', 500).click(); await driver.sendKeys("Germany", Key.ENTER); await gu.waitForServer(); await driver.navigate().back(); // Same data, just formatted differently // Currency sign has moved to the end // Decimal separator is now ',' // Digit group separator is now '.' await checkGridCells([ '$1', '$1 INVALID', '(2)', '-2,00 €', '3e4', '30.000,00 €', '5,678.901', '5.678,90 €', '23%', '0,23 €', '45 678', '45.678,00 €', '1.234.567', '1.234.567 INVALID', '€89', '89,00 €', ]); // Copy the numbers column into itself. // Values which were already parsed don't change since it copies the underlying numbers. await clipboard.lockAndPerform(async (cb) => { await copy(cb, 'Parsed'); }); await checkGridCells([ '$1', '$1 INVALID', '(2)', '-2,00 €', '3e4', '30.000,00 €', '5,678.901', '5.678,90 €', '23%', '0,23 €', '45 678', '45.678,00 €', // This can be parsed for the first time now that '.' // is seen as a digit group separator '1.234.567', '1.234.567,00 €', '€89', '89,00 €', ]); await copyAndCheck(clipboard, [ '$1', '$1 INVALID', '(2)', '-2,00 €', '3e4', '30.000,00 €', // Now we're copying from the text column so everything is parsed again. // The result in this case is not good: // '.' was simply removed because we don't check where it is // ',' is the decimal separator // So this is parsed as 5.678901 // which rounds to 5.68 to two decimal places for the currency format '5,678.901', '5,68 €', '23%', '0,23 €', '45 678', '45.678,00 €', '1.234.567', '1.234.567,00 €', '€89', '89,00 €', ], true); }); it('should parse pasted dates', async function() { await gu.getPageItem("Dates").click(); await copyAndCheck(clipboard, [ '01-02-03', '01-02-2003', '01 02 2003', '01-02-2003', '1/02/03', '01-02-2003', '01/2/03', '01-02-2003', '1/2/03', '01-02-2003', '1/2/3', '1/2/3 INVALID', '20/10/03', '20-10-2003', '10/20/03', '10/20/03 INVALID', ]); await gu.getCell({col: 'Parsed', rowNum: 1}).click(); assert.equal(await gu.getDateFormat(), "DD-MM-YYYY"); await gu.setDateFormat("MM-DD-YYYY"); // Same data, just formatted differently await checkGridCells([ '01-02-03', '02-01-2003', '01 02 2003', '02-01-2003', '1/02/03', '02-01-2003', '01/2/03', '02-01-2003', '1/2/03', '02-01-2003', '1/2/3', '1/2/3 INVALID', '20/10/03', '10-20-2003', '10/20/03', '10/20/03 INVALID', ]); // Copy the parsed column into itself. // Values which were already parsed don't change since it copies the underlying values. await clipboard.lockAndPerform(async (cb) => { await copy(cb, 'Parsed'); }); await checkGridCells([ '01-02-03', '02-01-2003', '01 02 2003', '02-01-2003', '1/02/03', '02-01-2003', '01/2/03', '02-01-2003', '1/2/03', '02-01-2003', '1/2/3', '1/2/3 INVALID', '20/10/03', '10-20-2003', '10/20/03', '10-20-2003', // can be parsed now ]); // Copy from the text column again, things get re-parsed await copyAndCheck(clipboard, [ '01-02-03', '01-02-2003', '01 02 2003', '01-02-2003', '1/02/03', '01-02-2003', '01/2/03', '01-02-2003', '1/2/03', '01-02-2003', '1/2/3', '1/2/3 INVALID', '20/10/03', '20/10/03 INVALID', // newly invalid '10/20/03', '10-20-2003', ]); }); // Note that these tests which reference other tables // assume that the previous tests have run. it('should parse pasted references', async function() { await gu.getPageItem("References").click(); await gu.getCell({col: 'Parsed', rowNum: 1}).click(); assert.equal(await gu.getRefTable(), "Dates"); assert.equal(await gu.getRefShowColumn(), "Text"); // Initially the References.Parsed column is displaying Dates.Text // No date parsing happens, we just see which strings exist in that column await copyAndCheck(clipboard, [ '20/10/03', '20/10/03', '10/20/03', '10/20/03', '1/2/3', '1/2/3', 'foo', 'foo INVALID', '3', '3 INVALID', '-2', '-2 INVALID', '$1', '$1 INVALID', '€89', '€89 INVALID', ], true); await gu.setRefShowColumn("Parsed"); // // Same data, just formatted differently await checkGridCells([ // In the Parsed column, only the second value was parsed as an actual date // The others look invalid in the Dates table, but here they're valid references '20/10/03', '20/10/03', '10/20/03', '10-20-2003', '1/2/3', '1/2/3', 'foo', 'foo INVALID', '3', '3 INVALID', '-2', '-2 INVALID', '$1', '$1 INVALID', '€89', '€89 INVALID', ]); await copyAndCheck(clipboard, [ '20/10/03', '20/10/03', '10/20/03', '10-20-2003', '1/2/3', '1/2/3', 'foo', 'foo INVALID', '3', `3 INVALID`, '-2', `-2 INVALID`, '$1', `$1 INVALID`, '€89', '€89 INVALID', ]); await gu.setRefShowColumn("Row ID"); // Same data, just formatted differently await checkGridCells([ '20/10/03', 'Dates[5]', '10/20/03', 'Dates[6]', '1/2/3', 'Dates[4]', 'foo', 'foo INVALID', '3', `3 INVALID`, '-2', `-2 INVALID`, '$1', `$1 INVALID`, '€89', '€89 INVALID', ]); await copyAndCheck(clipboard, [ '20/10/03', '20/10/03 INVALID', '10/20/03', '10/20/03 INVALID', '1/2/3', '1/2/3 INVALID', 'foo', 'foo INVALID', '3', 'Dates[3]', // 3 is the only valid Row ID '-2', '-2 INVALID', '$1', '$1 INVALID', '€89', '€89 INVALID', ]); await gu.setRefTable("Numbers"); // These checks run with References.Parsed as both a Reference and Reference List column. async function checkRefsToNumbers() { await gu.setRefShowColumn("Row ID"); await copyAndCheck(clipboard, [ '20/10/03', '20/10/03 INVALID', '10/20/03', '10/20/03 INVALID', '1/2/3', '1/2/3 INVALID', 'foo', 'foo INVALID', '3', 'Numbers[3]', '-2', '-2 INVALID', '$1', '$1 INVALID', '€89', '€89 INVALID', ], true); await gu.setRefShowColumn("Text"); await copyAndCheck(clipboard, [ '20/10/03', '20/10/03 INVALID', '10/20/03', '10/20/03 INVALID', '1/2/3', '1/2/3 INVALID', 'foo', 'foo INVALID', '3', '3 INVALID', '-2', '-2 INVALID', // These are the only strings that appear in Numbers.Text verbatim '$1', '$1', '€89', '€89', ]); await gu.setRefShowColumn("Parsed"); // Same data, just formatted differently await checkGridCells([ '20/10/03', '20/10/03 INVALID', '10/20/03', '10/20/03 INVALID', '1/2/3', '1/2/3 INVALID', 'foo', 'foo INVALID', '3', '3 INVALID', '-2', '-2 INVALID', '$1', '$1', '€89', '89,00 €', ]); await copyAndCheck(clipboard, [ '20/10/03', '20/10/03 INVALID', '10/20/03', '10/20/03 INVALID', '1/2/3', '1/2/3 INVALID', 'foo', 'foo INVALID', '3', '3 INVALID', // parsed, but not a valid reference '-2', '-2,00 €', '$1', '$1', // invalid in Numbers.parsed, but a valid reference '€89', '89,00 €', ]); } await checkRefsToNumbers(); // Copy the Parsed column into the same column in a forked document. // Because it's a different document, it uses the display values instead of the raw values (row IDs) // to avoid referencing the wrong rows. await clipboard.lockAndPerform(async (cb) => { await copy(cb, 'Parsed'); await driver.get(await driver.getCurrentUrl() + "/m/fork"); await gu.waitForDocToLoad(); await driver.executeScript(createDummyTextArea); await gu.setRefShowColumn("Text"); await paste(cb); }); await checkGridCells([ '20/10/03', '20/10/03 INVALID', '10/20/03', '10/20/03 INVALID', '1/2/3', '1/2/3 INVALID', 'foo', 'foo INVALID', '3', '3 INVALID', '-2', '-2,00 € INVALID', '$1', '$1', '€89', '89,00 € INVALID', ]); // Test the main copies with the Numbers table data not loaded in the browser // so the lookups get done in the data engine. await checkRefsToNumbers(); // Now test that pasting the same values into a Reference List column // produces the same result (reflists containing a single reference) await gu.setType(/Reference List/, {apply: true}); // Clear the Parsed column. Make sure we don't edit the column header. await gu.getCell({col: "Parsed", rowNum: 1}).click(); await gu.getColumnHeader({col: "Parsed"}).click(); await gu.sendKeys(Key.BACK_SPACE); await gu.waitForServer(); await checkRefsToNumbers(); }); it('should parse pasted reference lists containing multiple values', async function() { async function checkMultiRefs() { await gu.setRefShowColumn("Row ID"); await copyAndCheck(clipboard, [ '"(2)",$1', '"(2)",$1 INVALID', '$1,(2),22', '$1,(2),22 INVALID', '["$1",-2]', '["$1",-2] INVALID', '1,-2', '1,-2 INVALID', '3,5', 'Numbers[3]\nNumbers[5]', // only valid row IDs '-2,30000', '-2,30000 INVALID', '7,0', '7,0 INVALID', // 0 is not a valid row ID '', '', ]); await gu.setRefShowColumn("Text"); await copyAndCheck(clipboard, [ '"(2)",$1', '(2)\n$1', // only verbatim text '$1,(2),22', '$1,(2),22 INVALID', // 22 is invalid so whole thing fails '["$1",-2]', '["$1",-2] INVALID', // -2 is invalid because this is text, not parsed '1,-2', '1,-2 INVALID', '3,5', '3,5 INVALID', '-2,30000', '-2,30000 INVALID', '7,0', '7,0 INVALID', '', '', ]); await gu.setRefShowColumn("Parsed"); await copyAndCheck(clipboard, [ '"(2)",$1', '-2,00 €\n$1', '$1,(2),22', '$1,(2),22 INVALID', '["$1",-2]', '$1\n-2,00 €', '1,-2', '1,-2 INVALID', '3,5', '3,5 INVALID', '-2,30000', '-2,00 €\n30.000,00 €', '7,0', '7,0 INVALID', '', '', ], true); } await gu.getPageItem("Multi-References").click(); await gu.waitForServer(); await gu.getCell({col: 'Parsed', rowNum: 1}).click(); await checkMultiRefs(); // Load the Numbers table data in the browser and check again await gu.getPageItem("Numbers").click(); await gu.getPageItem("Multi-References").click(); await gu.waitForServer(); await checkMultiRefs(); }); it('should parse pasted choice lists', async function() { await gu.getPageItem("ChoiceLists").click(); await gu.waitForServer(); await copyAndCheck(clipboard, [ '', '', 'a', 'a', // On the left, \n in text affects parsing and separates choices // On the right, \n is how choices are separated in .getText() // So the newlines on the two sides match, but also "e,f" -> "e\nf" 'a b\nc d\ne,f', 'a b\nc d\ne\nf', // CSVs 'a,b ', 'a\nb', ' "a ", b,"a,b " ', 'a\nb\na,b', // JSON. Empty strings and null are removed ' ["a","b","a,b", null] ', 'a\nb\na,b', // Nested JSON is formatted as JSON or CSV depending on nesting level '["a","b",["a,b"], [["a,b"]], [["a", "b"], "c", "d"], "", " "]', 'a\nb\n"a,b"\n[["a,b"]]\n[["a", "b"], "c", "d"]', '[]', '', ], true); }); it('should parse pasted datetimes', async function() { await gu.getPageItem("DateTimes").click(); await gu.waitForServer(); await copyAndCheck(clipboard, [ '2021-11-12 22:57:17+03:00', '12-11-2021 21:57 SAST', // note the 1-hour difference '2021-11-12 22:57:17+02:00', '12-11-2021 22:57 SAST', '12-11-2021 22:57:17 SAST', '12-11-2021 22:57 SAST', '12-11-2021 22:57:17', '12-11-2021 22:57 SAST', '12-11-2021 22:57:17 UTC', '13-11-2021 00:57 SAST', // note the 2-hour difference '12-11-2021 22:57:17 Z', '13-11-2021 00:57 SAST', // note the 2-hour difference // EST doesn't match the current timezone so it's rejected '12-11-2021 22:57:17 EST', '12-11-2021 22:57:17 EST INVALID', // Date without time is allowed '12-11-2021', '12-11-2021 00:00 SAST', ]); }); }); // mapper for getVisibleGridCells to get both text and whether the cell is invalid (pink). // Invalid cells mean text that was not parsed to the column type. async function mapper(el: WebElement) { let text = await el.getText(); if (await el.find(".field_clip").matches(".invalid")) { text += " INVALID"; } return text; } // Checks that the full grid is equal to the given argument // The first column never changes, it's only included for readability of the test async function checkGridCells(expected: string[]) { const actual = await gu.getVisibleGridCells({rowNums: _.range(1, 9), cols: ['Text', 'Parsed'], mapper}); assert.deepEqual(actual, expected); } // Paste whatever's in the clipboard into the Parsed column async function paste(cb: gu.IClipboard) { // Click the first cell rather than the column header so that it doesn't try renaming the column await gu.getCell({col: 'Parsed', rowNum: 1}).click(); await cb.paste(); await gu.waitForServer(); await gu.checkForErrors(); } // Copy the contents of fromCol into the Parsed column async function copy(cb: gu.IClipboard, fromCol: 'Text' | 'Parsed') { await gu.getColumnHeader({col: fromCol}).click(); await cb.copy(); await paste(cb); } async function copyAndCheck( clipboard: gu.ILockableClipboard, expected: string[], extraChecks: boolean = false ) { await clipboard.lockAndPerform(async (cb) => { // Copy Text cells into the Parsed column await copy(cb, 'Text'); await checkGridCells(expected); // Tests some extra features of parsing that don't really depend on the column // type and so don't need to be checked with every call to copyAndCheck if (extraChecks) { // With the text cells still in the clipboard, convert the clipboard from // rich data (cells) to plain text and confirm that it gets parsed the same way. // The cells are still selected, clear them all. await gu.sendKeys(Key.BACK_SPACE); await gu.waitForServer(); assert.deepEqual( await gu.getVisibleGridCells({rowNums: _.range(1, 9), cols: ['Parsed']}), arrayRepeat(8, ''), ); // Paste the text cells to the dummy textarea. await driver.find('#dummyText').click(); await gu.waitAppFocus(false); await cb.paste(); } }); if (extraChecks) { await gu.sendKeys(await gu.selectAllKey()); await clipboard.lockAndPerform(async (cb) => { await cb.copy(); await gu.sendKeys(Key.BACK_SPACE); // Paste the now plain text and confirm that the resulting data is still the same. await gu.getCell({col: 'Text', rowNum: 1}).click(); await gu.waitAppFocus(); await paste(cb); }); await checkGridCells(expected); // Check that copying from the Parsed column back into itself doesn't change anything. await clipboard.lockAndPerform(async (cb) => { await copy(cb, 'Parsed'); }); await checkGridCells(expected); } } function createDummyTextArea() { const textarea = document.createElement('textarea'); textarea.style.position = "absolute"; textarea.style.top = "0"; textarea.style.height = "2rem"; textarea.style.width = "16rem"; textarea.id = 'dummyText'; window.document.body.appendChild(textarea); } function removeDummyTextArea() { const textarea = document.getElementById('dummyText'); if (textarea) { window.document.body.removeChild(textarea); } }