mirror of
https://github.com/apache/superset.git
synced 2026-07-27 09:02:29 +00:00
Compare commits
7 Commits
dependabot
...
chore/cons
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
257f1a40fe | ||
|
|
b29fa0851f | ||
|
|
8616f2bd43 | ||
|
|
2a1d13bb9b | ||
|
|
6db37572e6 | ||
|
|
34936ffcda | ||
|
|
43174fdc09 |
7653
superset-frontend/package-lock.json
generated
7653
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
128
superset-frontend/packages/superset-core/src/utils/clipboard.ts
Normal file
128
superset-frontend/packages/superset-core/src/utils/clipboard.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Detects if the browser is Safari (WebKit without Chrome).
|
||||
*/
|
||||
const isSafari = (): boolean => {
|
||||
const { userAgent } = navigator;
|
||||
return Boolean(userAgent && /^((?!chrome|android).)*safari/i.test(userAgent));
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy text to clipboard using the modern Clipboard API.
|
||||
* Handles Safari's requirement for synchronous clipboard access.
|
||||
*/
|
||||
const copyWithClipboardApi = async (
|
||||
getText: () => Promise<string>,
|
||||
): Promise<void> => {
|
||||
// Safari (WebKit) does not support delayed generation of clipboard.
|
||||
// This means that writing to the clipboard, from the moment the user
|
||||
// interacts with the app, must be instantaneous.
|
||||
// However, neither writeText nor write accepts a Promise, so
|
||||
// we need to create a ClipboardItem that accepts said Promise to
|
||||
// delay the text generation, as needed.
|
||||
// Source: https://bugs.webkit.org/show_bug.cgi?id=222262P
|
||||
if (isSafari()) {
|
||||
try {
|
||||
const clipboardItem = new ClipboardItem({
|
||||
'text/plain': getText(),
|
||||
});
|
||||
await navigator.clipboard.write([clipboardItem]);
|
||||
} catch {
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy text to clipboard using the legacy execCommand API.
|
||||
* Used as fallback for browsers that don't support the Clipboard API.
|
||||
*/
|
||||
const copyWithExecCommand = (text: string): Promise<void> =>
|
||||
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(new Error('execCommand copy failed'));
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
|
||||
document.body.removeChild(span);
|
||||
if (selection.removeRange) {
|
||||
selection.removeRange(range);
|
||||
} else {
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
/**
|
||||
* Copy text to clipboard with automatic fallback for older browsers.
|
||||
*
|
||||
* Uses the modern Clipboard API when available, falling back to
|
||||
* document.execCommand('copy') for legacy browser support.
|
||||
*
|
||||
* @param getText - Function that returns a Promise resolving to the text to copy
|
||||
* @returns Promise that resolves when copy succeeds, rejects on failure
|
||||
*
|
||||
* @example
|
||||
* // Copy static text
|
||||
* await copyTextToClipboard(() => Promise.resolve('Hello, World!'));
|
||||
*
|
||||
* @example
|
||||
* // Copy dynamic text (e.g., from API)
|
||||
* await copyTextToClipboard(async () => {
|
||||
* const response = await fetch('/api/text');
|
||||
* return response.text();
|
||||
* });
|
||||
*/
|
||||
export const copyTextToClipboard = (
|
||||
getText: () => Promise<string>,
|
||||
): Promise<void> =>
|
||||
copyWithClipboardApi(getText).catch(() =>
|
||||
getText().then(text => copyWithExecCommand(text)),
|
||||
);
|
||||
@@ -18,3 +18,4 @@
|
||||
*/
|
||||
|
||||
export { default as logging } from './logging';
|
||||
export { copyTextToClipboard } from './clipboard';
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you 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 { Component, ComponentType, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
export interface FallbackProps {
|
||||
error: Error;
|
||||
resetErrorBoundary: () => void;
|
||||
}
|
||||
|
||||
export interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
FallbackComponent?: ComponentType<FallbackProps>;
|
||||
fallbackRender?: (props: FallbackProps) => ReactNode;
|
||||
onError?: (error: Error, info: ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
this.props.onError?.(error, info);
|
||||
}
|
||||
|
||||
resetErrorBoundary = (): void => {
|
||||
this.setState({ error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
const { children, FallbackComponent, fallbackRender } = this.props;
|
||||
|
||||
if (error) {
|
||||
const fallbackProps: FallbackProps = {
|
||||
error,
|
||||
resetErrorBoundary: this.resetErrorBoundary,
|
||||
};
|
||||
|
||||
if (fallbackRender) {
|
||||
return fallbackRender(fallbackProps);
|
||||
}
|
||||
|
||||
if (FallbackComponent) {
|
||||
return <FallbackComponent {...fallbackProps} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -27,9 +27,9 @@ import {
|
||||
|
||||
import {
|
||||
ErrorBoundary,
|
||||
ErrorBoundaryProps,
|
||||
FallbackProps,
|
||||
} from 'react-error-boundary';
|
||||
type ErrorBoundaryProps,
|
||||
type FallbackProps,
|
||||
} from './ErrorBoundary';
|
||||
import { ParentSize } from '@visx/responsive';
|
||||
import { createSelector } from 'reselect';
|
||||
import { withTheme } from '@emotion/react';
|
||||
|
||||
@@ -26,6 +26,11 @@ export { ChartProps };
|
||||
export type { ChartPropsConfig };
|
||||
|
||||
export { default as createLoadableRenderer } from './components/createLoadableRenderer';
|
||||
export {
|
||||
ErrorBoundary,
|
||||
type ErrorBoundaryProps,
|
||||
type FallbackProps,
|
||||
} from './components/ErrorBoundary';
|
||||
export { default as reactify } from './components/reactify';
|
||||
export { default as SuperChart } from './components/SuperChart';
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@superset-ui/core/spec';
|
||||
import mockConsole, { RestoreConsole } from 'jest-mock-console';
|
||||
import { triggerResizeObserver } from 'resize-observer-polyfill';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { ErrorBoundary } from '../../../src/chart/components/ErrorBoundary';
|
||||
|
||||
import { promiseTimeout, SuperChart } from '@superset-ui/core';
|
||||
import { WrapperProps } from '../../../src/chart/components/SuperChart';
|
||||
|
||||
@@ -24,12 +24,20 @@ import {
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import * as copyTextToClipboard from 'src/utils/copy';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { ComponentProps } from 'react';
|
||||
import { useShareMenuItems, ShareMenuItemProps } from '.';
|
||||
|
||||
const spy = jest.spyOn(copyTextToClipboard, 'default');
|
||||
// Mock the clipboard utility
|
||||
const mockCopyTextToClipboard = jest.fn<Promise<void>, [() => Promise<string>]>(
|
||||
() => Promise.resolve(),
|
||||
);
|
||||
jest.mock('src/utils/copy', () => ({
|
||||
__esModule: true,
|
||||
default: (getText: () => Promise<string>) => mockCopyTextToClipboard(getText),
|
||||
copyTextToClipboard: (getText: () => Promise<string>) =>
|
||||
mockCopyTextToClipboard(getText),
|
||||
}));
|
||||
|
||||
const DASHBOARD_ID = '26';
|
||||
const createProps = () => ({
|
||||
@@ -94,7 +102,7 @@ test('Should render menu items', () => {
|
||||
});
|
||||
|
||||
test('Click on "Copy dashboard URL" and succeed', async () => {
|
||||
spy.mockResolvedValue(undefined);
|
||||
mockCopyTextToClipboard.mockResolvedValue(undefined);
|
||||
const props = createProps();
|
||||
render(
|
||||
<MenuWrapper
|
||||
@@ -108,7 +116,7 @@ test('Click on "Copy dashboard URL" and succeed', async () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
expect(mockCopyTextToClipboard).toHaveBeenCalledTimes(0);
|
||||
expect(props.addSuccessToast).toHaveBeenCalledTimes(0);
|
||||
expect(props.addDangerToast).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -116,8 +124,9 @@ test('Click on "Copy dashboard URL" and succeed', async () => {
|
||||
userEvent.click(screen.getByText('Copy dashboard URL'));
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const value = await spy.mock.calls[0][0]();
|
||||
expect(mockCopyTextToClipboard).toHaveBeenCalledTimes(1);
|
||||
const getText = mockCopyTextToClipboard.mock.calls[0]![0];
|
||||
const value = await getText();
|
||||
expect(value).toBe('http://localhost/superset/dashboard/p/123/');
|
||||
expect(props.addSuccessToast).toHaveBeenCalledTimes(1);
|
||||
expect(props.addSuccessToast).toHaveBeenCalledWith('Copied to clipboard!');
|
||||
@@ -126,7 +135,7 @@ test('Click on "Copy dashboard URL" and succeed', async () => {
|
||||
});
|
||||
|
||||
test('Click on "Copy dashboard URL" and fail', async () => {
|
||||
spy.mockRejectedValue(undefined);
|
||||
mockCopyTextToClipboard.mockRejectedValue(undefined);
|
||||
const props = createProps();
|
||||
render(
|
||||
<MenuWrapper
|
||||
@@ -140,7 +149,7 @@ test('Click on "Copy dashboard URL" and fail', async () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
expect(mockCopyTextToClipboard).toHaveBeenCalledTimes(0);
|
||||
expect(props.addSuccessToast).toHaveBeenCalledTimes(0);
|
||||
expect(props.addDangerToast).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -148,8 +157,9 @@ test('Click on "Copy dashboard URL" and fail', async () => {
|
||||
userEvent.click(screen.getByText('Copy dashboard URL'));
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const value = await spy.mock.calls[0][0]();
|
||||
expect(mockCopyTextToClipboard).toHaveBeenCalledTimes(1);
|
||||
const getText = mockCopyTextToClipboard.mock.calls[0]![0];
|
||||
const value = await getText();
|
||||
expect(value).toBe('http://localhost/superset/dashboard/p/123/');
|
||||
expect(props.addSuccessToast).toHaveBeenCalledTimes(0);
|
||||
expect(props.addDangerToast).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { FeatureFlag } from '@superset-ui/core';
|
||||
import * as copyUtils from 'src/utils/copy';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
@@ -29,6 +28,17 @@ import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
|
||||
import { DataTablesPane } from '..';
|
||||
import { createDataTablesPaneProps } from './fixture';
|
||||
|
||||
// Mock the clipboard utility
|
||||
const mockCopyTextToClipboard = jest.fn<Promise<void>, [() => Promise<string>]>(
|
||||
() => Promise.resolve(),
|
||||
);
|
||||
jest.mock('src/utils/copy', () => ({
|
||||
__esModule: true,
|
||||
default: (getText: () => Promise<string>) => mockCopyTextToClipboard(getText),
|
||||
copyTextToClipboard: (getText: () => Promise<string>) =>
|
||||
mockCopyTextToClipboard(getText),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('DataTablesPane', () => {
|
||||
// Collapsed/expanded state depends on local storage
|
||||
@@ -104,7 +114,7 @@ describe('DataTablesPane', () => {
|
||||
],
|
||||
},
|
||||
);
|
||||
const copyToClipboardSpy = jest.spyOn(copyUtils, 'default');
|
||||
mockCopyTextToClipboard.mockClear();
|
||||
const props = createDataTablesPaneProps(456);
|
||||
render(<DataTablesPane {...props} />, {
|
||||
useRedux: true,
|
||||
@@ -113,10 +123,10 @@ describe('DataTablesPane', () => {
|
||||
expect(await screen.findByText('1 row')).toBeVisible();
|
||||
|
||||
userEvent.click(screen.getByLabelText('Copy'));
|
||||
expect(copyToClipboardSpy).toHaveBeenCalledTimes(1);
|
||||
const value = await copyToClipboardSpy.mock.calls[0][0]();
|
||||
expect(mockCopyTextToClipboard).toHaveBeenCalledTimes(1);
|
||||
const getText = mockCopyTextToClipboard.mock.calls[0]![0];
|
||||
const value = await getText();
|
||||
expect(value).toBe('__timestamp\tgenre\n2009-01-01 00:00:00\tAction\n');
|
||||
copyToClipboardSpy.mockRestore();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import ViewQuery, { ViewQueryProps } from './ViewQuery';
|
||||
|
||||
@@ -36,7 +35,15 @@ jest.mock('react-router-dom', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('src/utils/copy');
|
||||
// Mock the clipboard utility
|
||||
const mockCopyTextToClipboard = jest.fn<Promise<void>, [string]>(() =>
|
||||
Promise.resolve(),
|
||||
);
|
||||
jest.mock('src/utils/copy', () => ({
|
||||
__esModule: true,
|
||||
default: (text: string) => mockCopyTextToClipboard(text),
|
||||
copyTextToClipboard: (text: string) => mockCopyTextToClipboard(text),
|
||||
}));
|
||||
|
||||
const mockState = (
|
||||
roles: Record<string, [string, string][]> = {
|
||||
@@ -115,13 +122,14 @@ test('renders the component with Formatted SQL and buttons', async () => {
|
||||
});
|
||||
|
||||
test('copies the SQL to the clipboard when Copy button is clicked', async () => {
|
||||
mockCopyTextToClipboard.mockClear();
|
||||
mockCopyTextToClipboard.mockResolvedValue(undefined);
|
||||
setup(mockProps);
|
||||
|
||||
(copyTextToClipboard as jest.Mock).mockResolvedValue('');
|
||||
const copyButton = screen.getByText('Copy');
|
||||
expect(copyTextToClipboard as jest.Mock).not.toHaveBeenCalled();
|
||||
expect(mockCopyTextToClipboard).not.toHaveBeenCalled();
|
||||
fireEvent.click(copyButton);
|
||||
expect(copyTextToClipboard as jest.Mock).toHaveBeenCalled();
|
||||
expect(mockCopyTextToClipboard).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shows the original SQL when Format switch is unchecked', async () => {
|
||||
|
||||
@@ -17,79 +17,11 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { isSafari } from './common';
|
||||
|
||||
// Use the new Clipboard API if the browser supports it
|
||||
const copyTextWithClipboardApi = async (getText: () => Promise<string>) => {
|
||||
// Safari (WebKit) does not support delayed generation of clipboard.
|
||||
// This means that writing to the clipboard, from the moment the user
|
||||
// interacts with the app, must be instantaneous.
|
||||
// However, neither writeText nor write accepts a Promise, so
|
||||
// we need to create a ClipboardItem that accepts said Promise to
|
||||
// delay the text generation, as needed.
|
||||
// Source: https://bugs.webkit.org/show_bug.cgi?id=222262P
|
||||
if (isSafari()) {
|
||||
try {
|
||||
const clipboardItem = new ClipboardItem({
|
||||
'text/plain': getText(),
|
||||
});
|
||||
await navigator.clipboard.write([clipboardItem]);
|
||||
} catch {
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
/**
|
||||
* Re-export clipboard utilities from @apache-superset/core for backward compatibility.
|
||||
*
|
||||
* For new code, prefer importing directly from:
|
||||
* import { copyTextToClipboard } from '@apache-superset/core';
|
||||
*/
|
||||
export { copyTextToClipboard } from '@apache-superset/core';
|
||||
export { copyTextToClipboard as default } from '@apache-superset/core';
|
||||
|
||||
Reference in New Issue
Block a user