Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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()">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,12 @@
<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>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
Loading
Loading