diff --git a/CHANGELOG.md b/CHANGELOG.md index 54bb6d4df9..3eccf04b3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#2152](https://github.com/plotly/dash/pull/2152) Fix bug [#2128](https://github.com/plotly/dash/issues/2128) preventing rendering of multiple components inside a dictionary. - [#2187](https://github.com/plotly/dash/pull/2187) Fix confusing error message when trying to use pytest fixtures but `dash[testing]` is not installed. +- [#2202](https://github.com/plotly/dash/pull/2202) Fix bug [#2185](https://github.com/plotly/dash/issues/2185) when you copy text with multiple quotes into a table ## [2.6.1] - 2022-08-01 diff --git a/components/dash-table/src/dash-table/utils/TableClipboardHelper.ts b/components/dash-table/src/dash-table/utils/TableClipboardHelper.ts index ce9eddc69e..a6682bb43e 100644 --- a/components/dash-table/src/dash-table/utils/TableClipboardHelper.ts +++ b/components/dash-table/src/dash-table/utils/TableClipboardHelper.ts @@ -98,7 +98,7 @@ export default class TableClipboardHelper { ? TableClipboardHelper.localCopyWithoutHeaders : TableClipboardHelper.lastLocalCopy; const values = - localDf === text ? localCopy : SheetClip.prototype.parse(text); + localDf === text ? localCopy : TableClipboardHelper.parse(text); return applyClipboardToData( values, @@ -111,4 +111,63 @@ export default class TableClipboardHelper { overflowRows ); } + + private static parse(str: string) { + let r, + rlen, + a = 0, + c, + clen, + multiline, + last, + arr: string[][] = [[]]; + const rows = str.split('\n'); + if (rows.length > 1 && rows[rows.length - 1] === '') { + rows.pop(); + } + arr = []; + for (r = 0, rlen = rows.length; r < rlen; r += 1) { + const row = rows[r].split('\t'); + for (c = 0, clen = row.length; c < clen; c += 1) { + if (!arr[a]) { + arr[a] = []; + } + if (multiline && c === 0) { + last = arr[a].length - 1; + arr[a][last] = + arr[a][last] + '\n' + row[0].replace(/""/g, '"'); + if ( + multiline && + TableClipboardHelper.countQuotes(row[0]) & 1 + ) { + multiline = false; + arr[a][last] = arr[a][last].substring( + 0, + arr[a][last].length - 1 + ); + } + } else { + if ( + c === clen - 1 && + row[c].indexOf('"') === 0 && + TableClipboardHelper.countQuotes(row[c]) & 1 + ) { + arr[a].push(row[c].substring(1).replace(/""/g, '"')); + multiline = true; + } else { + arr[a].push(row[c]); + multiline = false; + } + } + } + if (!multiline) { + a += 1; + } + } + return arr; + } + + private static countQuotes(str: string) { + return str.split('"').length - 1; + } } diff --git a/components/dash-table/tests/js-unit/table_clipboard_helper_test.ts b/components/dash-table/tests/js-unit/table_clipboard_helper_test.ts new file mode 100644 index 0000000000..442c1f695a --- /dev/null +++ b/components/dash-table/tests/js-unit/table_clipboard_helper_test.ts @@ -0,0 +1,49 @@ +import {expect} from 'chai'; + +import TableClipboardHelper from 'dash-table/utils/TableClipboardHelper'; + +describe('table clipboard helper tests', () => { + it('test parse basic', () => { + const res = TableClipboardHelper.parse('abc\tefg\n123\t456'); + expect(res.length).to.equal(2); + expect(res[0].length).to.equal(2); + expect(res[1].length).to.equal(2); + expect(res[0][0]).to.equal('abc'); + expect(res[0][1]).to.equal('efg'); + expect(res[1][0]).to.equal('123'); + expect(res[1][1]).to.equal('456'); + }); + + it('test parse with double quotes', () => { + const res = TableClipboardHelper.parse('a""bc\tefg\n123\t456'); + expect(res.length).to.equal(2); + expect(res[0].length).to.equal(2); + expect(res[1].length).to.equal(2); + expect(res[0][0]).to.equal('a""bc'); + expect(res[0][1]).to.equal('efg'); + expect(res[1][0]).to.equal('123'); + expect(res[1][1]).to.equal('456'); + }); + + it('test with multiline', () => { + const res = TableClipboardHelper.parse('"a\nb\nc"\tefg\n123\t456'); + expect(res.length).to.equal(2); + expect(res[0].length).to.equal(2); + expect(res[1].length).to.equal(2); + expect(res[0][0]).to.equal('a\nb\nc'); + expect(res[0][1]).to.equal('efg'); + expect(res[1][0]).to.equal('123'); + expect(res[1][1]).to.equal('456'); + }); + + it('test with multiline and double quotes', () => { + const res = TableClipboardHelper.parse('"a\nb""c"\te""fg\n123\t456'); + expect(res.length).to.equal(2); + expect(res[0].length).to.equal(2); + expect(res[1].length).to.equal(2); + expect(res[0][0]).to.equal('a\nb"c'); + expect(res[0][1]).to.equal('e""fg'); + expect(res[1][0]).to.equal('123'); + expect(res[1][1]).to.equal('456'); + }); +}); diff --git a/components/dash-table/tests/selenium/conftest.py b/components/dash-table/tests/selenium/conftest.py index 5be45ce543..a171c681cc 100644 --- a/components/dash-table/tests/selenium/conftest.py +++ b/components/dash-table/tests/selenium/conftest.py @@ -26,6 +26,8 @@ _ANY = ".dash-spreadsheet" _TIMEOUT = 10 +CMD = Keys.COMMAND if platform.system() == "Darwin" else Keys.CONTROL + class HoldKeyContext: @preconditions(_validate_mixin, _validate_key) @@ -268,8 +270,6 @@ def filter(self): ) def filter_clear(self): - CMD = Keys.COMMAND if platform.system() == "Darwin" else Keys.CONTROL - self.filter().find_element(By.CSS_SELECTOR, "input").click() ac = ActionChains(self.mixin.driver) ac.key_down(CMD) @@ -518,11 +518,11 @@ def get_table_ids(self): ) def copy(self): - with self.hold(Keys.CONTROL): + with self.hold(CMD): self.send_keys("c") def paste(self): - with self.hold(Keys.CONTROL): + with self.hold(CMD): self.send_keys("v") @preconditions(_validate_key) diff --git a/components/dash-table/tests/selenium/test_basic_copy_paste.py b/components/dash-table/tests/selenium/test_basic_copy_paste.py index 813b36a6a7..10076cc97e 100644 --- a/components/dash-table/tests/selenium/test_basic_copy_paste.py +++ b/components/dash-table/tests/selenium/test_basic_copy_paste.py @@ -51,6 +51,20 @@ def get_app(): cell_selectable=False, sort_action="native", ), + DataTable( + id="table4", + data=[ + {"string": 'a""b', "int": 10}, + {"string": 'hello\n""hi', "int": 11}, + ], + columns=[ + {"name": "string", "id": "string"}, + {"name": "int", "id": "int"}, + ], + editable=True, + sort_action="native", + include_headers_on_copy_paste=True, + ), ] ) @@ -322,7 +336,7 @@ def test_tbcp010_copy_from_unselectable_cells_table(test): source.cell(2, 2).double_click() assert source.cell(2, 2).get_text() == test.get_selected_text() - # copy the source text to clipboard using CTRL+C + # copy the source text to clipboard using CTRL+C or COMMAND+C test.copy() # assert the target cell value is different before paste @@ -334,3 +348,45 @@ def test_tbcp010_copy_from_unselectable_cells_table(test): assert target.cell(1, 1).get_text() == source.cell(2, 2).get_text() assert test.get_log_errors() == [] + + +def test_tbcp011_copy_double_quotes(test): + test.start_server(get_app()) + + source = test.table("table4") + target = test.table("table2") + + source.cell(0, 0).click() + with test.hold(Keys.SHIFT): + source.cell(0, 1).click() + + test.copy() + target.cell(0, 0).click() + test.paste() + + for row in range(1): + for col in range(2): + assert target.cell(row, col).get_text() == source.cell(row, col).get_text() + + assert test.get_log_errors() == [] + + +def test_tbcp011_copy_multiline(test): + test.start_server(get_app()) + + source = test.table("table4") + target = test.table("table2") + + source.cell(1, 0).click() + with test.hold(Keys.SHIFT): + source.cell(1, 1).click() + + test.copy() + target.cell(1, 0).click() + test.paste() + + for row in range(1, 2): + for col in range(2): + assert target.cell(row, col).get_text() == source.cell(row, col).get_text() + + assert test.get_log_errors() == []