From f798148a5ce312639919ee64233aad5dd3a099e2 Mon Sep 17 00:00:00 2001 From: Kalyan Kanuri Date: Mon, 1 Jun 2026 19:23:54 -0700 Subject: [PATCH 1/3] [ZEPPELIN-4407] Add copy to clipboard (TSV/CSV) for table results Adds "Copy as TSV" and "Copy as CSV" options to the table result export dropdown in both the Angular (zeppelin-web-angular) and classic AngularJS (zeppelin-web) UIs. - Header row (column names) is always included in the copied text - Cell values containing the delimiter, double-quotes, or newlines are RFC 4180 quoted automatically - Uses navigator.clipboard.writeText with a document.execCommand fallback for older browsers - In the Angular table visualization, "Copy visible data" copies only the currently filtered/paginated rows, matching the existing "Export visible" behaviour - Existing "CSV" / "TSV" download items renamed to "Download as CSV/TSV" for clarity Closes #3496 (original AngularJS-only implementation by @amakaur) --- .../paragraph/copy-to-clipboard.spec.ts | 123 +++++++++++++++ .../share/result/result.component.html | 7 +- .../share/result/result.component.ts | 27 ++++ .../table/table-visualization.component.html | 13 ++ .../table/table-visualization.component.ts | 24 +++ .../result/result-chart-selector.html | 9 +- .../paragraph/result/result.controller.js | 43 ++++++ .../result/result.controller.test.js | 143 ++++++++++++++++++ 8 files changed, 384 insertions(+), 5 deletions(-) create mode 100644 zeppelin-web-angular/e2e/tests/notebook/paragraph/copy-to-clipboard.spec.ts 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 00000000000..83d8e74315d --- /dev/null +++ b/zeppelin-web-angular/e2e/tests/notebook/paragraph/copy-to-clipboard.spec.ts @@ -0,0 +1,123 @@ +/* + * 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 }) => { + // 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 }) => { + test.skip(!!process.env.CI, 'Requires a running shell interpreter — skipped on CI'); + + // 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 }) => { + test.skip(!!process.env.CI, 'Requires a running shell interpreter — skipped on CI'); + + 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 }) => { + test.skip(!!process.env.CI, 'Requires a running shell interpreter — skipped on CI'); + + 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'); + }); +}); 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 ce70756b3ec..878bd2efdc4 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 @@ 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 04b994911b6..8f8a6b00bcd 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,33 @@ 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'); + navigator.clipboard.writeText(text).catch(() => { + 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); + }); + } + 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 7ea134b803c..f5213cc831c 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 @@
  • Export visible data as excel
  • +
  • +
  • + Copy all data as TSV +
  • +
  • + Copy all data as CSV +
  • +
  • + Copy visible data as TSV +
  • +
  • + Copy visible data as CSV +
  • { + 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'); + navigator.clipboard.writeText(text).catch(() => { + 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); + }); + } + 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 6b34977f8f9..1e78a6e3ee4 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 @@ Toggle Dropdown -