diff --git a/superset-frontend/packages/superset-ui-core/src/components/CodeSyntaxHighlighter/index.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/CodeSyntaxHighlighter/index.test.tsx
index 8734447c302..c70da8df1c7 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/CodeSyntaxHighlighter/index.test.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/components/CodeSyntaxHighlighter/index.test.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { render, screen } from '../../spec';
+import { render, screen, fireEvent } from '../../spec';
import CodeSyntaxHighlighter from './index';
// Simple mock that just returns the content
@@ -153,4 +153,44 @@ describe('CodeSyntaxHighlighter', () => {
expect(screen.getByText('SELECT * FROM users;')).toBeInTheDocument();
});
+
+ test('shows copy button by default', () => {
+ render(
+ SELECT 1;,
+ );
+
+ expect(screen.getByTitle('Copy to clipboard')).toBeInTheDocument();
+ });
+
+ test('hides copy button when showCopyButton is false', () => {
+ render(
+
+ SELECT 1;
+ ,
+ );
+
+ expect(screen.queryByTitle('Copy to clipboard')).not.toBeInTheDocument();
+ });
+
+ test('copy button does not throw when clipboard API is unavailable', () => {
+ const originalClipboard = navigator.clipboard;
+ Object.defineProperty(navigator, 'clipboard', {
+ value: undefined,
+ configurable: true,
+ });
+ document.execCommand = jest.fn().mockReturnValue(true);
+
+ render(
+ SELECT 1;,
+ );
+
+ expect(() =>
+ fireEvent.click(screen.getByTitle('Copy to clipboard')),
+ ).not.toThrow();
+
+ Object.defineProperty(navigator, 'clipboard', {
+ value: originalClipboard,
+ configurable: true,
+ });
+ });
});
diff --git a/superset-frontend/packages/superset-ui-core/src/components/CodeSyntaxHighlighter/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/CodeSyntaxHighlighter/index.tsx
index 131abad4304..29a035885f4 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/CodeSyntaxHighlighter/index.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/components/CodeSyntaxHighlighter/index.tsx
@@ -16,11 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import SyntaxHighlighterBase from 'react-syntax-highlighter/dist/cjs/light';
import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
import tomorrow from 'react-syntax-highlighter/dist/cjs/styles/hljs/tomorrow-night';
-import { isThemeDark, useTheme } from '@apache-superset/core/theme';
+import { css, isThemeDark, useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import copyTextToClipboard from '../../utils/copy';
+import { Icons } from '../Icons';
export type SupportedLanguage = 'sql' | 'htmlbars' | 'markdown' | 'json';
@@ -31,6 +34,7 @@ export interface CodeSyntaxHighlighterProps {
showLineNumbers?: boolean;
wrapLines?: boolean;
style?: any; // Override theme style if needed
+ showCopyButton?: boolean;
}
// Track which languages have been registered to avoid duplicate registrations
@@ -76,11 +80,14 @@ export const CodeSyntaxHighlighter: React.FC = ({
showLineNumbers = false,
wrapLines = true,
style: overrideStyle,
+ showCopyButton = true,
}) => {
const theme = useTheme();
const [isLanguageReady, setIsLanguageReady] = useState(
registeredLanguages.has(language),
);
+ const [copied, setCopied] = useState(false);
+ const copyTimeoutRef = useRef | null>(null);
useEffect(() => {
const loadLanguage = async () => {
@@ -93,6 +100,21 @@ export const CodeSyntaxHighlighter: React.FC = ({
loadLanguage();
}, [language]);
+ useEffect(
+ () => () => {
+ if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
+ },
+ [],
+ );
+
+ const handleCopy = useCallback(() => {
+ copyTextToClipboard(() => Promise.resolve(children)).then(() => {
+ if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
+ setCopied(true);
+ copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500);
+ });
+ }, [children]);
+
const isDark = isThemeDark(theme);
const themeStyle = overrideStyle || (isDark ? tomorrow : github);
@@ -104,32 +126,79 @@ export const CodeSyntaxHighlighter: React.FC = ({
...customStyle,
};
+ const copyButton = showCopyButton && (
+
+ );
+
// Show a simple pre-formatted text while language is loading
if (!isLanguageReady) {
return (
-
- {children}
-
+ {copyButton}
+
+ {children}
+
+
);
}
return (
-
- {children}
-
+ {copyButton}
+
+ {children}
+
+
);
};
diff --git a/superset-frontend/packages/superset-ui-core/src/utils/copy.ts b/superset-frontend/packages/superset-ui-core/src/utils/copy.ts
new file mode 100644
index 00000000000..f39f5be0b47
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/utils/copy.ts
@@ -0,0 +1,98 @@
+/**
+ * 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.
+ */
+
+const isSafari = (): boolean => {
+ const { userAgent } = navigator;
+ return Boolean(userAgent && /^((?!chrome|android).)*safari/i.test(userAgent));
+};
+
+// Use the new Clipboard API if the browser supports it
+const copyTextWithClipboardApi = async (getText: () => Promise) => {
+ // 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) =>
+ copyTextWithClipboardApi(getText)
+ // If the Clipboard API is not supported, fallback to the older method.
+ .catch(() =>
+ getText().then(
+ text =>
+ new Promise((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;
diff --git a/superset-frontend/packages/superset-ui-core/src/utils/index.ts b/superset-frontend/packages/superset-ui-core/src/utils/index.ts
index 4d6e869cd0c..7f461f28fef 100644
--- a/superset-frontend/packages/superset-ui-core/src/utils/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/utils/index.ts
@@ -17,6 +17,7 @@
* under the License.
*/
export { default as convertKeysToCamelCase } from './convertKeysToCamelCase';
+export { default as copyTextToClipboard } from './copy';
export { default as ensureIsArray } from './ensureIsArray';
export { default as ensureIsInt } from './ensureIsInt';
export { default as isDefined } from './isDefined';
diff --git a/superset-frontend/src/SqlLab/components/HighlightedSql/HighlightedSql.test.tsx b/superset-frontend/src/SqlLab/components/HighlightedSql/HighlightedSql.test.tsx
index 70f63a2d0ff..2b4c841a040 100644
--- a/superset-frontend/src/SqlLab/components/HighlightedSql/HighlightedSql.test.tsx
+++ b/superset-frontend/src/SqlLab/components/HighlightedSql/HighlightedSql.test.tsx
@@ -36,13 +36,40 @@ test('renders a ModalTrigger component with shrink prop and maxWidth prop set to
);
expect(getByTestId('span-modal-trigger')).toBeInTheDocument();
});
-test('renders two code elements in modal when rawSql prop is provided', () => {
- const { getByRole, queryByRole, getByTestId } = render(
- ,
+test('renders single SQL block with no tabs when rawSql equals sql', () => {
+ const { queryByRole, getByTestId, queryByText } = render(
+ ,
);
expect(queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(getByTestId('span-modal-trigger'));
expect(queryByRole('dialog')).toBeInTheDocument();
- const codeElements = getByRole('dialog').getElementsByTagName('code');
- expect(codeElements.length).toEqual(2);
+ expect(queryByText('Executed SQL')).not.toBeInTheDocument();
+ expect(queryByText('Source SQL')).toBeInTheDocument();
+});
+
+test('renders tabs when rawSql differs from sql', () => {
+ const { queryByRole, getByTestId, getByText } = render(
+ ,
+ );
+ expect(queryByRole('dialog')).not.toBeInTheDocument();
+ fireEvent.click(getByTestId('span-modal-trigger'));
+ expect(queryByRole('dialog')).toBeInTheDocument();
+ expect(getByText('Executed SQL')).toBeInTheDocument();
+ expect(getByText('Source SQL')).toBeInTheDocument();
+});
+
+test('renders tabs when rawSql has an added LIMIT', () => {
+ const { queryByRole, getByTestId, getByText } = render(
+ ,
+ );
+ expect(queryByRole('dialog')).not.toBeInTheDocument();
+ fireEvent.click(getByTestId('span-modal-trigger'));
+ expect(queryByRole('dialog')).toBeInTheDocument();
+ expect(getByText('Executed SQL')).toBeInTheDocument();
+ expect(getByText('Source SQL')).toBeInTheDocument();
});
diff --git a/superset-frontend/src/SqlLab/components/HighlightedSql/index.tsx b/superset-frontend/src/SqlLab/components/HighlightedSql/index.tsx
index ef4831a9945..03adf270043 100644
--- a/superset-frontend/src/SqlLab/components/HighlightedSql/index.tsx
+++ b/superset-frontend/src/SqlLab/components/HighlightedSql/index.tsx
@@ -16,9 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { css, styled, useTheme } from '@apache-superset/core/theme';
+import { styled, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
-import { ModalTrigger } from '@superset-ui/core/components';
+import { ModalTrigger, Tabs } from '@superset-ui/core/components';
import CodeSyntaxHighlighter from '@superset-ui/core/components/CodeSyntaxHighlighter';
export interface HighlightedSqlProps {
@@ -63,7 +63,7 @@ const shrinkSql = (sql: string, maxLines: number, maxWidth: number) => {
function TriggerNode({ shrink, sql, maxLines, maxWidth }: TriggerNodeProps) {
return (
-
+
{shrink ? shrinkSql(sql, maxLines, maxWidth) : sql}
);
@@ -80,25 +80,43 @@ function HighlightSqlModal({ rawSql, sql }: HighlightedSqlModalTypes) {
padding: theme.sizeUnit * 2,
};
+ const isDifferent = !!rawSql && rawSql !== sql;
+
+ if (!isDifferent) {
+ return (
+