Compare commits

...

7 Commits

Author SHA1 Message Date
Evan Rusackas
257f1a40fe fix: move imports before jest.mock to fix lint error
Moves the DataTablesPane and fixture imports before the jest.mock()
call to comply with eslint-plugin-import(first) rule.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 00:34:56 -08:00
Evan Rusackas
b29fa0851f fix: regenerate package-lock.json after rebase
Sync package-lock.json with current package.json dependencies.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 15:57:15 -08:00
Evan Rusackas
8616f2bd43 refactor: move clipboard utility to @apache-superset/core
Per reviewer feedback, moves copyTextToClipboard from @superset-ui/core
to @apache-superset/core package.

Changes:
- Move clipboard.ts to @apache-superset/core/src/utils/
- Make isSafari private (internal implementation detail)
- Refactor for improved readability (extract copyWithExecCommand)
- Add JSDoc examples for better documentation
- Update src/utils/copy.ts to re-export from new location
- Remove clipboard.ts from @superset-ui/core

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 15:52:05 -08:00
Evan Rusackas
2a1d13bb9b fix: revert CopyToClipboard move, keep clipboard utility consolidation
The CopyToClipboard component consolidation revealed that the lib build
artifacts are significantly out of date (missing translation exports from
@apache-superset/core). This commit reverts the component move while
keeping the clipboard utility consolidation in @superset-ui/core.

The component move can be revisited once the lib build artifacts are
regenerated.

Changes:
- Revert src/components/CopyToClipboard to original implementation
- Restore types.ts file
- Remove CopyToClipboard from @superset-ui/core/components
- Keep clipboard utility (isSafari, copyTextToClipboard) in @superset-ui/core
- Keep test fixes for clipboard mock typing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 15:52:05 -08:00
Evan Rusackas
6db37572e6 fix(tests): properly type clipboard mock functions for Docker TS build
- Use jest.fn<ReturnType, Args>() generic syntax to type mocks
- Replace spread of unknown[] with properly typed function signatures
- Use non-null assertion for mock.calls access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 15:52:04 -08:00
Evan Rusackas
34936ffcda fix(tests): update clipboard utility mocks for re-exported modules
The clipboard utilities are now re-exported from @superset-ui/core,
making the default export non-configurable for Jest spies. Updated
tests to use jest.mock() with explicit mock functions instead of
jest.spyOn() on the default export.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 15:52:04 -08:00
Evan Rusackas
43174fdc09 chore: consolidate CopyToClipboard and clipboard utilities to @superset-ui/core
This change:
- Creates a generic CopyToClipboard component in @superset-ui/core
  - Uses onSuccess/onError callbacks (not tied to any toast system)
  - Includes tooltip, text display, and async getText support
- Creates clipboard utilities in @superset-ui/core
  - copyTextToClipboard with Safari/Chrome compatibility
  - isSafari browser detection utility
- Creates ErrorBoundary in @superset-ui/core (removes react-error-boundary dep)
- Updates src/components/CopyToClipboard to be a thin wrapper that:
  - Integrates with Superset's toast notification system
  - Delegates to the core component for actual functionality
- Updates src/utils/copy.ts to re-export from core for backward compatibility

Developers can now:
- Use the generic CopyToClipboard from @superset-ui/core/components directly
- Use the toast-integrated version from src/components for Superset-specific use

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 15:52:04 -08:00
11 changed files with 6928 additions and 1102 deletions

File diff suppressed because it is too large Load Diff

View 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)),
);

View File

@@ -18,3 +18,4 @@
*/
export { default as logging } from './logging';
export { copyTextToClipboard } from './clipboard';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
});

View File

@@ -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 () => {

View File

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