mirror of
https://github.com/apache/superset.git
synced 2024-09-06 13:57:40 -04:00
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:
parent
92057858c2
commit
0e38c686c6
@ -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!'));
|
||||||
})
|
})
|
||||||
|
@ -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(
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
});
|
});
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
|
@ -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.'));
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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!'));
|
||||||
|
@ -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!'));
|
||||||
|
@ -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!'));
|
||||||
|
Loading…
Reference in New Issue
Block a user