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);
+      });
+    });
+  });
 });

Reply via email to