fix: Support the Clipboard API in modern browsers (#20058)

* fix: Support the Clipboard API in modern browsers

* fix tests

* PR comment

* Improvements
This commit is contained in:
Diego Medina 2022-06-03 07:34:00 -04:00 committed by GitHub
parent 92057858c2
commit 0e38c686c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 118 additions and 57 deletions

View File

@ -57,10 +57,10 @@ class CopyToClipboard extends React.Component {
onClick() { onClick() {
if (this.props.getText) { if (this.props.getText) {
this.props.getText(d => { this.props.getText(d => {
this.copyToClipboard(d); this.copyToClipboard(Promise.resolve(d));
}); });
} else { } else {
this.copyToClipboard(this.props.text); this.copyToClipboard(Promise.resolve(this.props.text));
} }
} }
@ -72,7 +72,7 @@ class CopyToClipboard extends React.Component {
} }
copyToClipboard(textToCopy) { copyToClipboard(textToCopy) {
copyTextToClipboard(textToCopy) copyTextToClipboard(() => textToCopy)
.then(() => { .then(() => {
this.props.addSuccessToast(t('Copied to clipboard!')); this.props.addSuccessToast(t('Copied to clipboard!'));
}) })

View File

@ -102,9 +102,10 @@ test('Click on "Copy dashboard URL" and succeed', async () => {
userEvent.click(screen.getByRole('button', { name: 'Copy dashboard URL' })); userEvent.click(screen.getByRole('button', { name: 'Copy dashboard URL' }));
await waitFor(() => { await waitFor(async () => {
expect(spy).toBeCalledTimes(1); expect(spy).toBeCalledTimes(1);
expect(spy).toBeCalledWith('http://localhost/superset/dashboard/p/123/'); const value = await spy.mock.calls[0][0]();
expect(value).toBe('http://localhost/superset/dashboard/p/123/');
expect(props.addSuccessToast).toBeCalledTimes(1); expect(props.addSuccessToast).toBeCalledTimes(1);
expect(props.addSuccessToast).toBeCalledWith('Copied to clipboard!'); expect(props.addSuccessToast).toBeCalledWith('Copied to clipboard!');
expect(props.addDangerToast).toBeCalledTimes(0); expect(props.addDangerToast).toBeCalledTimes(0);
@ -128,9 +129,10 @@ test('Click on "Copy dashboard URL" and fail', async () => {
userEvent.click(screen.getByRole('button', { name: 'Copy dashboard URL' })); userEvent.click(screen.getByRole('button', { name: 'Copy dashboard URL' }));
await waitFor(() => { await waitFor(async () => {
expect(spy).toBeCalledTimes(1); expect(spy).toBeCalledTimes(1);
expect(spy).toBeCalledWith('http://localhost/superset/dashboard/p/123/'); const value = await spy.mock.calls[0][0]();
expect(value).toBe('http://localhost/superset/dashboard/p/123/');
expect(props.addSuccessToast).toBeCalledTimes(0); expect(props.addSuccessToast).toBeCalledTimes(0);
expect(props.addDangerToast).toBeCalledTimes(1); expect(props.addDangerToast).toBeCalledTimes(1);
expect(props.addDangerToast).toBeCalledWith( expect(props.addDangerToast).toBeCalledWith(

View File

@ -64,8 +64,7 @@ const ShareMenuItems = (props: ShareMenuItemProps) => {
async function onCopyLink() { async function onCopyLink() {
try { try {
const url = await generateUrl(); await copyTextToClipboard(generateUrl);
await copyTextToClipboard(url);
addSuccessToast(t('Copied to clipboard!')); addSuccessToast(t('Copied to clipboard!'));
} catch (error) { } catch (error) {
logging.error(error); logging.error(error);

View File

@ -18,7 +18,7 @@
*/ */
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { render, screen } from 'spec/helpers/testing-library'; import { render, screen, waitFor } from 'spec/helpers/testing-library';
import { CopyToClipboardButton } from '.'; import { CopyToClipboardButton } from '.';
test('Render a button', () => { test('Render a button', () => {
@ -28,14 +28,26 @@ test('Render a button', () => {
expect(screen.getByRole('button')).toBeInTheDocument(); expect(screen.getByRole('button')).toBeInTheDocument();
}); });
test('Should copy to clipboard', () => { test('Should copy to clipboard', async () => {
document.execCommand = jest.fn(); const callback = jest.fn();
document.execCommand = callback;
const originalClipboard = { ...global.navigator.clipboard };
// @ts-ignore
global.navigator.clipboard = { write: callback, writeText: callback };
render(<CopyToClipboardButton data={{ copy: 'data', data: 'copy' }} />, { render(<CopyToClipboardButton data={{ copy: 'data', data: 'copy' }} />, {
useRedux: true, useRedux: true,
}); });
expect(document.execCommand).toHaveBeenCalledTimes(0); expect(callback).toHaveBeenCalledTimes(0);
userEvent.click(screen.getByRole('button')); userEvent.click(screen.getByRole('button'));
expect(document.execCommand).toHaveBeenCalledWith('copy');
await waitFor(() => {
expect(callback).toHaveBeenCalled();
});
jest.resetAllMocks();
// @ts-ignore
global.navigator.clipboard = originalClipboard;
}); });

View File

@ -175,9 +175,9 @@ describe('DataTablesPane', () => {
expect(await screen.findByText('1 row')).toBeVisible(); expect(await screen.findByText('1 row')).toBeVisible();
userEvent.click(screen.getByLabelText('Copy')); userEvent.click(screen.getByLabelText('Copy'));
expect(copyToClipboardSpy).toHaveBeenCalledWith( expect(copyToClipboardSpy).toHaveBeenCalledTimes(1);
'2009-01-01 00:00:00\tAction\n', const value = await copyToClipboardSpy.mock.calls[0][0]();
); expect(value).toBe('2009-01-01 00:00:00\tAction\n');
copyToClipboardSpy.mockRestore(); copyToClipboardSpy.mockRestore();
fetchMock.restore(); fetchMock.restore();
}); });

View File

@ -168,8 +168,7 @@ export const useExploreAdditionalActionsMenu = (
if (!latestQueryFormData) { if (!latestQueryFormData) {
throw new Error(); throw new Error();
} }
const url = await getChartPermalink(latestQueryFormData); await copyTextToClipboard(() => getChartPermalink(latestQueryFormData));
await copyTextToClipboard(url);
addSuccessToast(t('Copied to clipboard!')); addSuccessToast(t('Copied to clipboard!'));
} catch (error) { } catch (error) {
addDangerToast(t('Sorry, something went wrong. Try again later.')); addDangerToast(t('Sorry, something went wrong. Try again later.'));

View File

@ -94,7 +94,7 @@ export function prepareCopyToClipboardTabularData(data, columns) {
for (let i = 0; i < data.length; i += 1) { for (let i = 0; i < data.length; i += 1) {
const row = {}; const row = {};
for (let j = 0; j < columns.length; j += 1) { for (let j = 0; j < columns.length; j += 1) {
// JavaScript does not mantain the order of a mixed set of keys (i.e integers and strings) // JavaScript does not maintain the order of a mixed set of keys (i.e integers and strings)
// the below function orders the keys based on the column names. // the below function orders the keys based on the column names.
const key = columns[j].name || columns[j]; const key = columns[j].name || columns[j];
if (data[i][key]) { if (data[i][key]) {
@ -145,4 +145,10 @@ export const detectOS = () => {
return 'Unknown OS'; return 'Unknown OS';
}; };
export const isSafari = () => {
const { userAgent } = navigator;
return userAgent && /^((?!chrome|android).)*safari/i.test(userAgent);
};
export const isNullish = value => value === null || value === undefined; export const isNullish = value => value === null || value === undefined;

View File

@ -17,40 +17,79 @@
* under the License. * under the License.
*/ */
const copyTextToClipboard = async (text: string) => import { isSafari } from './common';
new Promise<void>((resolve, reject) => {
const selection: Selection | null = document.getSelection();
if (selection) {
selection.removeAllRanges();
const range = document.createRange();
const span = document.createElement('span');
span.textContent = text;
span.style.position = 'fixed';
span.style.top = '0';
span.style.clip = 'rect(0, 0, 0, 0)';
span.style.whiteSpace = 'pre';
document.body.appendChild(span); // Use the new Clipboard API if the browser supports it
range.selectNode(span); const copyTextWithClipboardApi = async (getText: () => Promise<string>) => {
selection.addRange(range); // Safari (WebKit) does not support delayed generation of clipboard.
// This means that writing to the clipboard, from the moment the user
try { // interacts with the app, must be instantaneous.
if (!document.execCommand('copy')) { // However, neither writeText nor write accepts a Promise, so
reject(); // we need to create a ClipboardItem that accepts said Promise to
} // delay the text generation, as needed.
} catch (err) { // Source: https://bugs.webkit.org/show_bug.cgi?id=222262P
reject(); if (isSafari()) {
} try {
const clipboardItem = new ClipboardItem({
document.body.removeChild(span); 'text/plain': getText(),
if (selection.removeRange) { });
selection.removeRange(range); await navigator.clipboard.write([clipboardItem]);
} else { } catch {
selection.removeAllRanges(); // Fallback to default clipboard API implementation
} const text = await getText();
await navigator.clipboard.writeText(text);
} }
} else {
// For Blink, the above method won't work, but we can use the
// default (intended) API, since the delayed generation of the
// clipboard is now supported.
// Source: https://bugs.chromium.org/p/chromium/issues/detail?id=1014310
const text = await getText();
await navigator.clipboard.writeText(text);
}
};
resolve(); const copyTextToClipboard = (getText: () => Promise<string>) =>
}); copyTextWithClipboardApi(getText)
// If the Clipboard API is not supported, fallback to the older method.
.catch(() =>
getText().then(
text =>
new Promise<void>((resolve, reject) => {
const selection: Selection | null = document.getSelection();
if (selection) {
selection.removeAllRanges();
const range = document.createRange();
const span = document.createElement('span');
span.textContent = text;
span.style.position = 'fixed';
span.style.top = '0';
span.style.clip = 'rect(0, 0, 0, 0)';
span.style.whiteSpace = 'pre';
document.body.appendChild(span);
range.selectNode(span);
selection.addRange(range);
try {
if (!document.execCommand('copy')) {
reject();
}
} catch (err) {
reject();
}
document.body.removeChild(span);
if (selection.removeRange) {
selection.removeRange(range);
} else {
selection.removeAllRanges();
}
}
resolve();
}),
),
);
export default copyTextToClipboard; export default copyTextToClipboard;

View File

@ -65,7 +65,7 @@ export default function SyntaxHighlighterCopy({
language: 'sql' | 'markdown' | 'html' | 'json'; language: 'sql' | 'markdown' | 'html' | 'json';
}) { }) {
function copyToClipboard(textToCopy: string) { function copyToClipboard(textToCopy: string) {
copyTextToClipboard(textToCopy) copyTextToClipboard(() => Promise.resolve(textToCopy))
.then(() => { .then(() => {
if (addSuccessToast) { if (addSuccessToast) {
addSuccessToast(t('SQL Copied!')); addSuccessToast(t('SQL Copied!'));

View File

@ -210,8 +210,10 @@ function SavedQueryList({
const copyQueryLink = useCallback( const copyQueryLink = useCallback(
(id: number) => { (id: number) => {
copyTextToClipboard( copyTextToClipboard(() =>
`${window.location.origin}/superset/sqllab?savedQueryId=${id}`, Promise.resolve(
`${window.location.origin}/superset/sqllab?savedQueryId=${id}`,
),
) )
.then(() => { .then(() => {
addSuccessToast(t('Link Copied!')); addSuccessToast(t('Link Copied!'));

View File

@ -611,8 +611,10 @@ export const copyQueryLink = (
addDangerToast: (arg0: string) => void, addDangerToast: (arg0: string) => void,
addSuccessToast: (arg0: string) => void, addSuccessToast: (arg0: string) => void,
) => { ) => {
copyTextToClipboard( copyTextToClipboard(() =>
`${window.location.origin}/superset/sqllab?savedQueryId=${id}`, Promise.resolve(
`${window.location.origin}/superset/sqllab?savedQueryId=${id}`,
),
) )
.then(() => { .then(() => {
addSuccessToast(t('Link Copied!')); addSuccessToast(t('Link Copied!'));