Skip to content

[ZEPPELIN-4407] Add copy to clipboard (TSV/CSV) for table results#5261

Open
kkalyan wants to merge 3 commits into
apache:masterfrom
kkalyan:master
Open

[ZEPPELIN-4407] Add copy to clipboard (TSV/CSV) for table results#5261
kkalyan wants to merge 3 commits into
apache:masterfrom
kkalyan:master

Conversation

@kkalyan
Copy link
Copy Markdown
Contributor

@kkalyan kkalyan commented Jun 2, 2026

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 @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

image image

Kalyan Kanuri added 2 commits June 2, 2026 11:55
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 apache#3496 (original AngularJS-only implementation by @amakaur)
…s in tests

Karma tests failed because spyOn(navigator.clipboard, 'writeText') was
called twice — once in beforeEach and again inside two test bodies.
Playwright tests failed because grantPermissions ran before the CI skip,
and Firefox/WebKit reject unknown permissions (clipboard-read/clipboard-write).
Copy link
Copy Markdown
Contributor

@tbonelee tbonelee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion.
Overall logics looks good to me, and I only left a few feedbacks.
You could also add a test cases which handles double quote delimiter in both zeppelin-web and zeppelin-web-angular

Comment on lines +289 to +298
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);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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);
});
// 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();
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — extracted fallbackCopy as a named function and added the if (navigator.clipboard) guard before calling writeText, so the fallback triggers correctly in non-HTTPS contexts too. Applied the same pattern to both result.component.ts and table-visualization.component.ts.

Comment on lines +90 to +99
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);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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);
});
// 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();
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — same change applied here in table-visualization.component.ts: extracted fallbackCopy and added the navigator.clipboard existence guard.

let text = '';
for (let titleIndex in tableData.columns) {
if (tableData.columns.hasOwnProperty(titleIndex)) {
text += tableData.columns[titleIndex].name + delimiter;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should escape not only the row value but the header value also. You could extract the escape function inside the closure and use that for this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — extracted an escape() closure that is now shared by both the header row and cell values, so header names containing the delimiter, double quotes, or newlines are properly RFC 4180 quoted.

let dsvRow = '';
for (let index in row) {
if (row.hasOwnProperty(index)) {
let stringValue = (row[index]).toString();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null check is needed at here.

Suggested change
let stringValue = (row[index]).toString();
let stringValue = (value === null || value === undefined) ? '' : String(value);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the escape() function now does (value === null || value === undefined) ? '' : String(value) before checking for special characters.

- Extract fallbackCopy function and guard navigator.clipboard existence
  in both result.component.ts and table-visualization.component.ts so
  the fallback works in plain HTTP (non-secure) contexts
- Refactor result.controller.js: extract escape() closure shared by
  headers and cells; fix null/undefined cell value handling
- Add test: header values containing double quotes are RFC 4180 quoted
  (classic UI Karma test + Angular E2E Playwright test)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants