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 ( +
+ {t('Source SQL')} + + {sql} + +
+ ); + } + return ( -
- {t('Source SQL')} - - {sql} - - {rawSql && rawSql !== sql && ( -
- {t('Executed SQL')} - - {rawSql} - -
- )} -
+ + {rawSql!} +
+ ), + }, + { + key: 'source', + label: t('Source SQL'), + children: ( + + {sql} + + ), + }, + ]} + /> ); } @@ -121,6 +139,7 @@ function HighlightedSql({ maxWidth={maxWidth} /> } + responsive /> ); } diff --git a/superset-frontend/src/features/queries/SyntaxHighlighterCopy.tsx b/superset-frontend/src/features/queries/SyntaxHighlighterCopy.tsx index 1daecd6d594..f247c51f61f 100644 --- a/superset-frontend/src/features/queries/SyntaxHighlighterCopy.tsx +++ b/superset-frontend/src/features/queries/SyntaxHighlighterCopy.tsx @@ -124,7 +124,11 @@ export default function SyntaxHighlighterCopy({ onClick={handleCopyClick} onKeyDown={handleKeyDown} /> - + {children} diff --git a/superset-frontend/src/utils/copy.ts b/superset-frontend/src/utils/copy.ts index 0980f2ab170..dd2cb7efa20 100644 --- a/superset-frontend/src/utils/copy.ts +++ b/superset-frontend/src/utils/copy.ts @@ -17,79 +17,6 @@ * under the License. */ -import { isSafari } from './common'; - -// 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(); - }), - ), - ); +import copyTextToClipboard from '@superset-ui/core/utils/copy'; export default copyTextToClipboard;