This is an automated email from the ASF dual-hosted git repository.
tbonelee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new 15966b53d8 [ZEPPELIN-4407] Add copy to clipboard (TSV/CSV) for table
results
15966b53d8 is described below
commit 15966b53d8a96a00fc6f4752516b6edd037b8c1a
Author: Kalyan <[email protected]>
AuthorDate: Wed Jun 3 07:46:00 2026 -0700
[ZEPPELIN-4407] Add copy to clipboard (TSV/CSV) for table results
## What is this PR for?
I've seen users downloading CSV and opening in spreadsheet viewer and
copying the text.
Adding way to copy CSV/TSV directly. This is orginally implemented by
<at>amakaur #3496 but it was closed due to lack of tests. I'm picking it up
now.
### Changes
**Angular UI (`zeppelin-web-angular`)**
- `result.component` — paragraph toolbar dropdown: renamed existing items
to "Download as CSV/TSV", added divider, then "Copy as TSV" and "Copy as CSV"
- `table-visualization.component` — inner table Export menu: added "Copy
all data as TSV/CSV" and "Copy visible data as TSV/CSV" (mirrors the existing
"Export visible" scope)
**Classic AngularJS UI (`zeppelin-web`)**
- `result-chart-selector.html` — same dropdown restructure: Download /
divider / Copy
- `result.controller.js` — new `$scope.copyToClipboard(delimiter)` function
### Behaviour
- Header row (column names) is always included in the copied text
- Cell values containing the delimiter, double-quotes, or newlines are RFC
4180 quoted
- Uses `navigator.clipboard.writeText` with a
`document.execCommand('copy')` fallback for older browsers
## What type of PR is it?
Feature
## What is the Jira issue?
https://issues.apache.org/jira/browse/ZEPPELIN-4407
## How should this be tested?
1. Run a paragraph that outputs a TABLE (e.g. `%sh printf
"col1\tcol2\na\t1\nb\t2\n"`)
2. Click the **▾** next to the download button in the paragraph toolbar
3. Verify the menu shows: **Download as CSV**, **Download as TSV**,
*(divider)*, **Copy as TSV**, **Copy as CSV**
4. Click **Copy as TSV** → paste into a spreadsheet app or text editor —
expect headers + rows, tab-delimited
5. Click **Copy as CSV** → paste → expect headers + rows, comma-delimited
6. Test with a cell value containing a comma, e.g. `"hello, world"` → the
CSV copy should quote it correctly
## Tests
- **Classic UI (Karma/Jasmine):**
`zeppelin-web/src/app/notebook/paragraph/result/result.controller.test.js` — 4
new specs covering TSV copy, CSV copy, delimiter quoting, and double-quote
escaping
- **Angular UI (Playwright E2E):**
`zeppelin-web-angular/e2e/tests/notebook/paragraph/copy-to-clipboard.spec.ts` —
3 new specs (skipped on CI, require a live interpreter)
## Questions
- Does the license file need update? No
- Is there a breaking change for older versions? No — existing Download as
CSV/TSV behaviour is unchanged
- Does this need documentation? No
## screenshots
<img width="1022" height="766" alt="image"
src="https://github.com/user-attachments/assets/2cac3b76-ccb3-4ddc-b5dd-1637a98bfe07"
/>
<img width="1106" height="797" alt="image"
src="https://github.com/user-attachments/assets/9c610ece-042e-422c-b3f8-2b363b87399d"
/>
Closes #5261 from kkalyan/master.
Signed-off-by: ChanHo Lee <[email protected]>
---
.../notebook/paragraph/copy-to-clipboard.spec.ts | 145 ++++++++++++++++++
.../workspace/share/result/result.component.html | 7 +-
.../workspace/share/result/result.component.ts | 35 +++++
.../table/table-visualization.component.html | 13 ++
.../table/table-visualization.component.ts | 32 ++++
.../paragraph/result/result-chart-selector.html | 9 +-
.../notebook/paragraph/result/result.controller.js | 44 ++++++
.../paragraph/result/result.controller.test.js | 163 +++++++++++++++++++++
8 files changed, 443 insertions(+), 5 deletions(-)
diff --git
a/zeppelin-web-angular/e2e/tests/notebook/paragraph/copy-to-clipboard.spec.ts
b/zeppelin-web-angular/e2e/tests/notebook/paragraph/copy-to-clipboard.spec.ts
new file mode 100644
index 0000000000..dc13297dd4
--- /dev/null
+++
b/zeppelin-web-angular/e2e/tests/notebook/paragraph/copy-to-clipboard.spec.ts
@@ -0,0 +1,145 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { expect, test } from '@playwright/test';
+import { NotebookParagraphPage } from 'e2e/models/notebook-paragraph-page';
+import { NotebookKeyboardPage } from 'e2e/models/notebook-keyboard-page';
+import {
+ addPageAnnotationBeforeEach,
+ performLoginIfRequired,
+ waitForZeppelinReady,
+ PAGES,
+ createTestNotebook
+} from '../../../utils';
+
+test.describe('Copy table result to clipboard', () => {
+ addPageAnnotationBeforeEach(PAGES.SHARE.SHARE_RESULT);
+
+ let paragraphPage: NotebookParagraphPage;
+ let testNotebook: { noteId: string; paragraphId: string };
+
+ test.beforeEach(async ({ page, context }, testInfo) => {
+ testInfo.skip(!!process.env.CI, 'Requires a running shell interpreter —
skipped on CI');
+ // Grant clipboard permissions so navigator.clipboard.writeText works in
tests
+ await context.grantPermissions(['clipboard-read', 'clipboard-write']);
+
+ await page.goto('/#/');
+ await waitForZeppelinReady(page);
+ await performLoginIfRequired(page);
+
+ testNotebook = await createTestNotebook(page);
+ paragraphPage = new NotebookParagraphPage(page);
+
+ await page.goto(`/#/notebook/${testNotebook.noteId}`);
+ await page.waitForLoadState('networkidle');
+
+ // Type a paragraph that outputs a TABLE result using the %sh interpreter
+ await paragraphPage.doubleClickToEdit();
+ await expect(paragraphPage.codeEditor).toBeVisible();
+
+ const codeEditor = paragraphPage.codeEditor.locator('textarea,
.monaco-editor .input-area').first();
+ await expect(codeEditor).toBeAttached({ timeout: 10000 });
+ await codeEditor.focus();
+
+ const keyboard = new NotebookKeyboardPage(page);
+ await keyboard.pressSelectAll();
+ await page.keyboard.type('%sh\nprintf
"name\\tcount\\na\\t12\\nb\\t24\\n"');
+
+ await paragraphPage.runParagraph();
+ await expect(paragraphPage.resultDisplay).toBeVisible({ timeout: 30000 });
+ });
+
+ test('export dropdown should contain Copy as TSV and Copy as CSV options',
async ({ page }) => {
+ // Open the export dropdown (down-arrow button next to the download icon)
+ const exportDropdownTrigger = page
+ .locator('.export-dropdown .export-dropdown-icon-btn, .export-dropdown
button:last-child')
+ .first();
+ await expect(exportDropdownTrigger).toBeVisible({ timeout: 10000 });
+ await exportDropdownTrigger.click();
+
+ const menu = page.locator('.ant-dropdown-menu');
+ await expect(menu).toBeVisible({ timeout: 5000 });
+
+ await expect(menu.locator('li:has-text("Download as CSV")')).toBeVisible();
+ await expect(menu.locator('li:has-text("Download as TSV")')).toBeVisible();
+ await expect(menu.locator('li:has-text("Copy as TSV")')).toBeVisible();
+ await expect(menu.locator('li:has-text("Copy as CSV")')).toBeVisible();
+ });
+
+ test('Copy as TSV should write tab-delimited data with headers to
clipboard', async ({ page }) => {
+ const exportDropdownTrigger = page
+ .locator('.export-dropdown .export-dropdown-icon-btn, .export-dropdown
button:last-child')
+ .first();
+ await expect(exportDropdownTrigger).toBeVisible({ timeout: 10000 });
+ await exportDropdownTrigger.click();
+
+ const menu = page.locator('.ant-dropdown-menu');
+ await expect(menu).toBeVisible({ timeout: 5000 });
+ await menu.locator('li:has-text("Copy as TSV")').click();
+
+ // Read back what was written to the clipboard
+ const clipboardText = await page.evaluate(() =>
navigator.clipboard.readText());
+ const lines = clipboardText.split('\n').filter(l => l.trim().length > 0);
+
+ // First line must be the header row
+ expect(lines[0]).toBe('name\tcount');
+ // Data rows follow
+ expect(lines[1]).toBe('a\t12');
+ expect(lines[2]).toBe('b\t24');
+ });
+
+ test('Copy as CSV should write comma-delimited data with headers to
clipboard', async ({ page }) => {
+ const exportDropdownTrigger = page
+ .locator('.export-dropdown .export-dropdown-icon-btn, .export-dropdown
button:last-child')
+ .first();
+ await expect(exportDropdownTrigger).toBeVisible({ timeout: 10000 });
+ await exportDropdownTrigger.click();
+
+ const menu = page.locator('.ant-dropdown-menu');
+ await expect(menu).toBeVisible({ timeout: 5000 });
+ await menu.locator('li:has-text("Copy as CSV")').click();
+
+ const clipboardText = await page.evaluate(() =>
navigator.clipboard.readText());
+ const lines = clipboardText.split('\n').filter(l => l.trim().length > 0);
+
+ expect(lines[0]).toBe('name,count');
+ expect(lines[1]).toBe('a,12');
+ expect(lines[2]).toBe('b,24');
+ });
+
+ test('Copy as CSV should quote cell values that contain double quotes',
async ({ page }) => {
+ // Re-run the paragraph with a value containing a double quote
+ const codeEditor = page.locator('.monaco-editor .input-area,
textarea').first();
+ await codeEditor.focus();
+ const keyboard = new NotebookKeyboardPage(page);
+ await keyboard.pressSelectAll();
+ await page.keyboard.type('%sh\nprintf "col1\\tcol2\\nsay
\\"hi\\"\\t1\\n"');
+ await new NotebookParagraphPage(page).runParagraph();
+ await page.waitForLoadState('networkidle');
+
+ const exportDropdownTrigger = page
+ .locator('.export-dropdown .export-dropdown-icon-btn, .export-dropdown
button:last-child')
+ .first();
+ await expect(exportDropdownTrigger).toBeVisible({ timeout: 10000 });
+ await exportDropdownTrigger.click();
+
+ const menu = page.locator('.ant-dropdown-menu');
+ await expect(menu).toBeVisible({ timeout: 5000 });
+ await menu.locator('li:has-text("Copy as CSV")').click();
+
+ const clipboardText = await page.evaluate(() =>
navigator.clipboard.readText());
+ const lines = clipboardText.split('\n').filter(l => l.trim().length > 0);
+
+ // 'say "hi"' contains double quotes — must be RFC 4180 quoted in CSV
output
+ expect(lines[1]).toBe('"say ""hi""",1');
+ });
+});
diff --git
a/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.html
b/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.html
index ce70756b3e..878bd2efdc 100644
---
a/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.html
+++
b/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.html
@@ -42,8 +42,11 @@
</nz-space-compact>
<nz-dropdown-menu #exportMenu="nzDropdownMenu">
<ul nz-menu>
- <li nz-menu-item (click)="exportFile('csv')">CSV</li>
- <li nz-menu-item (click)="exportFile('tsv')">TSV</li>
+ <li nz-menu-item (click)="exportFile('csv')">Download as CSV</li>
+ <li nz-menu-item (click)="exportFile('tsv')">Download as TSV</li>
+ <li nz-menu-divider></li>
+ <li nz-menu-item (click)="copyToClipboard('tsv')">Copy as TSV</li>
+ <li nz-menu-item (click)="copyToClipboard('csv')">Copy as CSV</li>
</ul>
</nz-dropdown-menu>
<a class="setting-trigger" tabindex="-1" (click)="switchSetting()">
diff --git
a/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.ts
b/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.ts
index 04b994911b..85f9715c7f 100644
---
a/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.ts
+++
b/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.ts
@@ -271,6 +271,41 @@ export class NotebookParagraphResultComponent implements
OnInit, AfterViewInit,
}
}
+ copyToClipboard(type: 'tsv' | 'csv'): void {
+ if (!this.tableData || !this.tableData.rows) {
+ return;
+ }
+ const delimiter = type === 'tsv' ? '\t' : ',';
+ const { columns, rows } = this.tableData;
+ const escape = (value: unknown): string => {
+ const str = String(value ?? '');
+ return str.includes(delimiter) || str.includes('"') ||
str.includes('\n') ? `"${str.replace(/"/g, '""')}"` : str;
+ };
+ const lines = [
+ columns.map(escape).join(delimiter),
+ ...rows.map(row => columns.map(col => escape(row[col])).join(delimiter))
+ ];
+ const text = lines.join('\n');
+ // TODO: Refactor the duplicated copy-to-clipboard logics
+ const fallbackCopy = () => {
+ const el = document.createElement('textarea');
+ el.value = text;
+ el.style.position = 'absolute';
+ el.style.left = '-9999px';
+ document.body.appendChild(el);
+ el.select();
+ document.execCommand('copy');
+ document.body.removeChild(el);
+ };
+ // navigator.clipboard is undefined in non-secure contexts (e.g. plain
HTTP),
+ // where writeText would throw synchronously before the catch could run.
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(text).catch(fallbackCopy);
+ } else {
+ fallbackCopy();
+ }
+ }
+
switchMode(mode: VisualizationMode) {
if (!this.config) {
throw new Error('config is not defined');
diff --git
a/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html
b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html
index 7ea134b803..f5213cc831 100644
---
a/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html
+++
b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.html
@@ -28,6 +28,19 @@
<li nz-menu-item>
<a (click)="exportFile('xlsx', false)">Export visible data as excel</a>
</li>
+ <li nz-menu-divider></li>
+ <li nz-menu-item>
+ <a (click)="copyToClipboard('tsv')">Copy all data as TSV</a>
+ </li>
+ <li nz-menu-item>
+ <a (click)="copyToClipboard('csv')">Copy all data as CSV</a>
+ </li>
+ <li nz-menu-item>
+ <a (click)="copyToClipboard('tsv', false)">Copy visible data as TSV</a>
+ </li>
+ <li nz-menu-item>
+ <a (click)="copyToClipboard('csv', false)">Copy visible data as CSV</a>
+ </li>
</ul>
</nz-dropdown-menu>
<nz-table
diff --git
a/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.ts
b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.ts
index af24b7193b..0eefb93ce7 100644
---
a/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.ts
+++
b/zeppelin-web-angular/src/app/visualizations/table/table-visualization.component.ts
@@ -75,6 +75,38 @@ export class TableVisualizationComponent implements OnInit {
writeFile(wb, `export.${type}`);
}
+ copyToClipboard(type: 'tsv' | 'csv', all = true) {
+ const delimiter = type === 'tsv' ? '\t' : ',';
+ const sourceRows = all ? this.rows : [...this.nzTable.data];
+ const escape = (value: unknown): string => {
+ const str = String(value ?? '');
+ return str.includes(delimiter) || str.includes('"') ||
str.includes('\n') ? `"${str.replace(/"/g, '""')}"` : str;
+ };
+ const lines = [
+ this.columns.map(escape).join(delimiter),
+ ...sourceRows.map(row => this.columns.map(col =>
escape(row[col])).join(delimiter))
+ ];
+ const text = lines.join('\n');
+ // TODO: Refactor the duplicated copy-to-clipboard logics
+ const fallbackCopy = () => {
+ const el = document.createElement('textarea');
+ el.value = text;
+ el.style.position = 'absolute';
+ el.style.left = '-9999px';
+ document.body.appendChild(el);
+ el.select();
+ document.execCommand('copy');
+ document.body.removeChild(el);
+ };
+ // navigator.clipboard is undefined in non-secure contexts (e.g. plain
HTTP),
+ // where writeText would throw synchronously before the catch could run.
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(text).catch(fallbackCopy);
+ } else {
+ fallbackCopy();
+ }
+ }
+
onChangeType(type: ColType, col: string) {
this.getColOptionOrThrow(col).type = type;
this.filterRows();
diff --git
a/zeppelin-web/src/app/notebook/paragraph/result/result-chart-selector.html
b/zeppelin-web/src/app/notebook/paragraph/result/result-chart-selector.html
index 6b34977f8f..1e78a6e3ee 100644
--- a/zeppelin-web/src/app/notebook/paragraph/result/result-chart-selector.html
+++ b/zeppelin-web/src/app/notebook/paragraph/result/result-chart-selector.html
@@ -87,9 +87,12 @@ limitations under the License.
<span class="caret" style="margin: 0px;"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
- <ul class="dropdown-menu" role="menu" style="min-width: 70px;">
- <li ng-click="exportToDSV(',')"><a>CSV</a></li>
- <li ng-click="exportToDSV('\t')"><a>TSV</a></li>
+ <ul class="dropdown-menu" role="menu" style="min-width: 120px;">
+ <li ng-click="exportToDSV(',')"><a>Download as CSV</a></li>
+ <li ng-click="exportToDSV('\t')"><a>Download as TSV</a></li>
+ <li class="divider"></li>
+ <li ng-click="copyToClipboard('\t')"><a>Copy as TSV</a></li>
+ <li ng-click="copyToClipboard(',')"><a>Copy as CSV</a></li>
</ul>
</div>
diff --git
a/zeppelin-web/src/app/notebook/paragraph/result/result.controller.js
b/zeppelin-web/src/app/notebook/paragraph/result/result.controller.js
index d55c51e025..bd850d0ba3 100644
--- a/zeppelin-web/src/app/notebook/paragraph/result/result.controller.js
+++ b/zeppelin-web/src/app/notebook/paragraph/result/result.controller.js
@@ -914,6 +914,50 @@ function ResultCtrl($scope, $rootScope, $route, $window,
$routeParams, $location
saveAsService.saveAs(dsv, exportedFileName, extension);
};
+ $scope.copyToClipboard = function(delimiter) {
+ const escape = function(value) {
+ let stringValue = (value === null || value === undefined) ? '' :
String(value);
+ let hasDelimiter = stringValue.indexOf(delimiter) > -1;
+ let hasQuote = stringValue.indexOf('"') > -1;
+ let hasNewline = stringValue.indexOf('\n') > -1;
+ if (hasDelimiter || hasQuote || hasNewline) {
+ return '"' + stringValue.replaceAll('"', '""') + '"';
+ }
+ return stringValue;
+ };
+ let headerParts = [];
+ for (let titleIndex in tableData.columns) {
+ if (tableData.columns.hasOwnProperty(titleIndex)) {
+ headerParts.push(escape(tableData.columns[titleIndex].name));
+ }
+ }
+ let text = headerParts.join(delimiter) + '\n';
+ for (let r in tableData.rows) {
+ if (tableData.rows.hasOwnProperty(r)) {
+ let row = tableData.rows[r];
+ let dsvRow = '';
+ for (let index in row) {
+ if (row.hasOwnProperty(index)) {
+ dsvRow += escape(row[index]) + delimiter;
+ }
+ }
+ text += dsvRow.substring(0, dsvRow.length - 1) + '\n';
+ }
+ }
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(text);
+ } else {
+ let el = document.createElement('textarea');
+ el.value = text;
+ el.style.position = 'absolute';
+ el.style.left = '-9999px';
+ document.body.appendChild(el);
+ el.select();
+ document.execCommand('copy');
+ document.body.removeChild(el);
+ }
+ };
+
$scope.getBase64ImageSrc = function(base64Data) {
return 'data:image/png;base64,' + base64Data;
};
diff --git
a/zeppelin-web/src/app/notebook/paragraph/result/result.controller.test.js
b/zeppelin-web/src/app/notebook/paragraph/result/result.controller.test.js
index c299973cc8..0086ad80d1 100644
--- a/zeppelin-web/src/app/notebook/paragraph/result/result.controller.test.js
+++ b/zeppelin-web/src/app/notebook/paragraph/result/result.controller.test.js
@@ -38,4 +38,167 @@ describe('Controller: ResultCtrl', function() {
expect(scope).toBeDefined();
expect(controller).toBeDefined();
});
+
+ describe('copyToClipboard', function() {
+ let tableResultMock;
+ let tableConfigMock;
+ let tableParagraphMock;
+ let clipboardText;
+
+ beforeEach(inject(function($controller, $rootScope) {
+ tableResultMock = {
+ type: 'TABLE',
+ data: 'name\tcount\na\t12\nb\t24\n',
+ };
+ tableConfigMock = {
+ graph: {
+ mode: 'table',
+ height: 300,
+ optionOpen: false,
+ setting: {},
+ },
+ };
+ tableParagraphMock = {
+ id: 'p2',
+ results: {
+ msg: [tableResultMock],
+ },
+ };
+
+ scope = $rootScope.$new();
+ scope.$parent = $rootScope.$new(true, $rootScope);
+ scope.$parent.paragraph = tableParagraphMock;
+
+ controller = $controller('ResultCtrl', {
+ $scope: scope,
+ $route: route,
+ });
+
+ scope.init(tableResultMock, tableConfigMock, tableParagraphMock, 0);
+
+ clipboardText = null;
+ spyOn(navigator.clipboard, 'writeText').and.callFake(function(text) {
+ clipboardText = text;
+ return Promise.resolve();
+ });
+ }));
+
+ it('should copy TSV with header row to clipboard', function(done) {
+ scope.copyToClipboard('\t');
+ setTimeout(function() {
+ expect(navigator.clipboard.writeText).toHaveBeenCalled();
+ let lines = clipboardText.split('\n').filter(function(l) {
+ return l.length > 0;
+ });
+ expect(lines[0]).toBe('name\tcount');
+ expect(lines[1]).toBe('a\t12');
+ expect(lines[2]).toBe('b\t24');
+ done();
+ }, 0);
+ });
+
+ it('should copy CSV with header row to clipboard', function(done) {
+ scope.copyToClipboard(',');
+ setTimeout(function() {
+ expect(navigator.clipboard.writeText).toHaveBeenCalled();
+ let lines = clipboardText.split('\n').filter(function(l) {
+ return l.length > 0;
+ });
+ expect(lines[0]).toBe('name,count');
+ expect(lines[1]).toBe('a,12');
+ done();
+ }, 0);
+ });
+
+ it('should quote cell values that contain the delimiter', function(done) {
+ let specialResultMock = {
+ type: 'TABLE',
+ data: 'col1\tcol2\nhello,world\t42\n',
+ };
+ let specialParagraphMock = {
+ id: 'p3',
+ results: {
+ msg: [specialResultMock],
+ },
+ };
+
+ inject(function($controller, $rootScope) {
+ let specialScope = $rootScope.$new();
+ specialScope.$parent = $rootScope.$new(true, $rootScope);
+ specialScope.$parent.paragraph = specialParagraphMock;
+ $controller('ResultCtrl', {$scope: specialScope, $route: route});
+ specialScope.init(specialResultMock, tableConfigMock,
specialParagraphMock, 0);
+
+ specialScope.copyToClipboard(',');
+ setTimeout(function() {
+ let lines = clipboardText.split('\n').filter(function(l) {
+ return l.length > 0;
+ });
+ // "hello,world" contains comma — must be quoted for CSV
+ expect(lines[1]).toBe('"hello,world",42');
+ done();
+ }, 0);
+ });
+ });
+
+ it('should quote cell values that contain double quotes', function(done) {
+ let quoteResultMock = {
+ type: 'TABLE',
+ data: 'col1\tcol2\nsay "hi"\t1\n',
+ };
+ let quoteParagraphMock = {
+ id: 'p4',
+ results: {
+ msg: [quoteResultMock],
+ },
+ };
+
+ inject(function($controller, $rootScope) {
+ let quoteScope = $rootScope.$new();
+ quoteScope.$parent = $rootScope.$new(true, $rootScope);
+ quoteScope.$parent.paragraph = quoteParagraphMock;
+ $controller('ResultCtrl', {$scope: quoteScope, $route: route});
+ quoteScope.init(quoteResultMock, tableConfigMock, quoteParagraphMock,
0);
+
+ quoteScope.copyToClipboard('\t');
+ setTimeout(function() {
+ let lines = clipboardText.split('\n').filter(function(l) {
+ return l.length > 0;
+ });
+ expect(lines[1]).toBe('"say ""hi"""\t1');
+ done();
+ }, 0);
+ });
+ });
+
+ it('should quote header values that contain double quotes', function(done)
{
+ let headerQuoteResultMock = {
+ type: 'TABLE',
+ data: 'col "A"\tcol2\nval1\t1\n',
+ };
+ let headerQuoteParagraphMock = {
+ id: 'p5',
+ results: {
+ msg: [headerQuoteResultMock],
+ },
+ };
+
+ inject(function($controller, $rootScope) {
+ let headerScope = $rootScope.$new();
+ headerScope.$parent = $rootScope.$new(true, $rootScope);
+ headerScope.$parent.paragraph = headerQuoteParagraphMock;
+ $controller('ResultCtrl', {$scope: headerScope, $route: route});
+ headerScope.init(headerQuoteResultMock, tableConfigMock,
headerQuoteParagraphMock, 0);
+
+ headerScope.copyToClipboard('\t');
+ setTimeout(function() {
+ let lines = clipboardText.split('\n').filter(function(l) {
+ return l.length > 0;
+ });
+ expect(lines[0]).toBe('"col ""A"""\tcol2');
+ done();
+ }, 0);
+ });
+ });
+ });
});