This is an automated email from the ASF dual-hosted git repository.
justinpark pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 3a3984006c chore(explore): Add format sql and view in SQL Lab option
in View Query (#33341)
3a3984006c is described below
commit 3a3984006cfd506449323125f0a3138fe7650964
Author: JUST.in DO IT <[email protected]>
AuthorDate: Mon Jun 9 15:11:54 2025 -0700
chore(explore): Add format sql and view in SQL Lab option in View Query
(#33341)
---
.../explore/components/controls/ViewQuery.test.tsx | 158 +++++++++++++++++++++
.../src/explore/components/controls/ViewQuery.tsx | 144 ++++++++++++++++---
.../explore/components/controls/ViewQueryModal.tsx | 15 +-
3 files changed, 294 insertions(+), 23 deletions(-)
diff --git
a/superset-frontend/src/explore/components/controls/ViewQuery.test.tsx
b/superset-frontend/src/explore/components/controls/ViewQuery.test.tsx
new file mode 100644
index 0000000000..9a18020c34
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/ViewQuery.test.tsx
@@ -0,0 +1,158 @@
+/**
+ * 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 {
+ screen,
+ render,
+ fireEvent,
+ waitFor,
+} from 'spec/helpers/testing-library';
+import fetchMock from 'fetch-mock';
+import copyTextToClipboard from 'src/utils/copy';
+import ViewQuery, { ViewQueryProps } from './ViewQuery';
+
+const mockHistoryPush = jest.fn();
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useHistory: () => ({
+ push: mockHistoryPush,
+ }),
+}));
+
+jest.mock('src/utils/copy');
+
+function setup(props: ViewQueryProps) {
+ return render(<ViewQuery {...props} />, { useRouter: true, useRedux: true });
+}
+
+const mockProps = {
+ sql: 'select * from table',
+ datasource: '1__table',
+};
+
+const datasetApiEndpoint = 'glob:*/api/v1/dataset/1?**';
+const formatSqlEndpoint = 'glob:*/api/v1/sqllab/format_sql/';
+const formattedSQL = 'SELECT * FROM table;';
+
+beforeEach(() => {
+ fetchMock.get(datasetApiEndpoint, {
+ result: {
+ database: {
+ backend: 'sqlite',
+ },
+ },
+ });
+ fetchMock.post(formatSqlEndpoint, {
+ result: formattedSQL,
+ });
+});
+
+afterEach(() => {
+ jest.resetAllMocks();
+ fetchMock.restore();
+});
+
+const getFormatSwitch = () =>
+ screen.getByRole('switch', { name: 'Show original SQL' });
+
+test('renders the component with Formatted SQL and buttons', async () => {
+ const { container } = setup(mockProps);
+ expect(screen.getByText('Copy')).toBeInTheDocument();
+ expect(getFormatSwitch()).toBeInTheDocument();
+ expect(screen.getByText('View in SQL Lab')).toBeInTheDocument();
+
+ await waitFor(() =>
+ expect(fetchMock.calls(formatSqlEndpoint)).toHaveLength(1),
+ );
+
+ expect(container).toHaveTextContent(formattedSQL);
+});
+
+test('copies the SQL to the clipboard when Copy button is clicked', async ()
=> {
+ setup(mockProps);
+
+ (copyTextToClipboard as jest.Mock).mockResolvedValue('');
+ const copyButton = screen.getByText('Copy');
+ expect(copyTextToClipboard as jest.Mock).not.toHaveBeenCalled();
+ fireEvent.click(copyButton);
+ expect(copyTextToClipboard as jest.Mock).toHaveBeenCalled();
+});
+
+test('shows the original SQL when Format switch is unchecked', async () => {
+ const { container } = setup(mockProps);
+ const formatButton = getFormatSwitch();
+
+ await waitFor(() =>
+ expect(fetchMock.calls(formatSqlEndpoint)).toHaveLength(1),
+ );
+
+ fireEvent.click(formatButton);
+
+ expect(container).toHaveTextContent(mockProps.sql);
+});
+
+test('toggles back to formatted SQL when Format switch is clicked', async ()
=> {
+ const { container } = setup(mockProps);
+ const formatButton = getFormatSwitch();
+
+ await waitFor(() =>
+ expect(fetchMock.calls(formatSqlEndpoint)).toHaveLength(1),
+ );
+
+ // Click to format SQL
+ fireEvent.click(formatButton);
+
+ await waitFor(() => expect(container).toHaveTextContent(mockProps.sql));
+
+ // Toggle format switch
+ fireEvent.click(formatButton);
+
+ await waitFor(() => expect(container).toHaveTextContent(formattedSQL));
+});
+
+test('navigates to SQL Lab when View in SQL Lab button is clicked', () => {
+ setup(mockProps);
+
+ const viewInSQLLabButton = screen.getByText('View in SQL Lab');
+ fireEvent.click(viewInSQLLabButton);
+
+ expect(mockHistoryPush).toHaveBeenCalledWith('/sqllab', {
+ state: {
+ requestedQuery: {
+ datasourceKey: mockProps.datasource,
+ sql: mockProps.sql,
+ },
+ },
+ });
+});
+
+test('opens SQL Lab in a new tab when View in SQL Lab button is clicked with
meta key', () => {
+ window.open = jest.fn();
+
+ setup(mockProps);
+ const viewInSQLLabButton = screen.getByText('View in SQL Lab');
+
+ fireEvent.click(viewInSQLLabButton, { metaKey: true });
+
+ const { datasource, sql } = mockProps;
+ expect(window.open).toHaveBeenCalledWith(
+ `/sqllab?datasourceKey=${datasource}&sql=${sql}`,
+ '_blank',
+ );
+});
diff --git a/superset-frontend/src/explore/components/controls/ViewQuery.tsx
b/superset-frontend/src/explore/components/controls/ViewQuery.tsx
index 1b3e359ce1..804ca87997 100644
--- a/superset-frontend/src/explore/components/controls/ViewQuery.tsx
+++ b/superset-frontend/src/explore/components/controls/ViewQuery.tsx
@@ -16,10 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
-// TODO: Remove fa-icon
-/* eslint-disable icons/no-fa-icons-usage */
-import { FC } from 'react';
-import { styled } from '@superset-ui/core';
+import {
+ FC,
+ KeyboardEvent,
+ MouseEvent,
+ useCallback,
+ useEffect,
+ useState,
+} from 'react';
+import rison from 'rison';
+import { styled, SupersetClient, t } from '@superset-ui/core';
import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light';
import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
import CopyToClipboard from 'src/components/CopyToClipboard';
@@ -28,6 +34,9 @@ import markdownSyntax from
'react-syntax-highlighter/dist/cjs/languages/hljs/mar
import htmlSyntax from
'react-syntax-highlighter/dist/cjs/languages/hljs/htmlbars';
import sqlSyntax from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql';
import jsonSyntax from 'react-syntax-highlighter/dist/cjs/languages/hljs/json';
+import { useHistory } from 'react-router-dom';
+import { Switch } from 'src/components/Switch';
+import { Button, Skeleton } from 'src/components';
const CopyButtonViewQuery = styled(CopyButton)`
&& {
@@ -40,8 +49,9 @@ SyntaxHighlighter.registerLanguage('html', htmlSyntax);
SyntaxHighlighter.registerLanguage('sql', sqlSyntax);
SyntaxHighlighter.registerLanguage('json', jsonSyntax);
-interface ViewQueryProps {
+export interface ViewQueryProps {
sql: string;
+ datasource: string;
language?: string;
}
@@ -51,26 +61,124 @@ const StyledSyntaxContainer = styled.div`
flex-direction: column;
`;
+const StyledHeaderMenuContainer = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ margin-top: ${({ theme }) => -theme.gridUnit * 4}px;
+ align-items: flex-end;
+`;
+
+const StyledHeaderActionContainer = styled.div`
+ display: flex;
+ flex-direction: row;
+ column-gap: ${({ theme }) => theme.gridUnit * 2}px;
+`;
+
const StyledSyntaxHighlighter = styled(SyntaxHighlighter)`
flex: 1;
`;
+const StyledLabel = styled.label`
+ font-size: ${({ theme }) => theme.typography.sizes.m}px;
+`;
+
+const DATASET_BACKEND_QUERY = {
+ keys: ['none'],
+ columns: ['database.backend'],
+};
+
const ViewQuery: FC<ViewQueryProps> = props => {
- const { sql, language = 'sql' } = props;
+ const { sql, language = 'sql', datasource } = props;
+ const datasetId = datasource.split('__')[0];
+ const [formattedSQL, setFormattedSQL] = useState<string>();
+ const [showFormatSQL, setShowFormatSQL] = useState(true);
+ const history = useHistory();
+ const currentSQL = (showFormatSQL ? formattedSQL : sql) ?? sql;
+
+ const formatCurrentQuery = useCallback(() => {
+ if (formattedSQL) {
+ setShowFormatSQL(val => !val);
+ } else {
+ const queryParams = rison.encode(DATASET_BACKEND_QUERY);
+ SupersetClient.get({
+ endpoint: `/api/v1/dataset/${datasetId}?q=${queryParams}`,
+ })
+ .then(({ json }) =>
+ SupersetClient.post({
+ endpoint: `/api/v1/sqllab/format_sql/`,
+ body: JSON.stringify({
+ sql,
+ engine: json.result.database.backend,
+ }),
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ .then(({ json }) => {
+ setFormattedSQL(json.result);
+ setShowFormatSQL(true);
+ })
+ .catch(() => {
+ setShowFormatSQL(true);
+ });
+ }
+ }, [sql, datasetId, formattedSQL]);
+
+ const navToSQLLab = useCallback(
+ (domEvent: KeyboardEvent<HTMLElement> | MouseEvent<HTMLElement>) => {
+ const requestedQuery = {
+ datasourceKey: datasource,
+ sql: currentSQL,
+ };
+ if (domEvent.metaKey || domEvent.ctrlKey) {
+ domEvent.preventDefault();
+ window.open(
+ `/sqllab?datasourceKey=${datasource}&sql=${currentSQL}`,
+ '_blank',
+ );
+ } else {
+ history.push('/sqllab', { state: { requestedQuery } });
+ }
+ },
+ [history, datasource, currentSQL],
+ );
+
+ useEffect(() => {
+ formatCurrentQuery();
+ }, [sql]);
+
return (
<StyledSyntaxContainer key={sql}>
- <CopyToClipboard
- text={sql}
- shouldShowText={false}
- copyNode={
- <CopyButtonViewQuery buttonSize="xsmall">
- <i className="fa fa-clipboard" />
- </CopyButtonViewQuery>
- }
- />
- <StyledSyntaxHighlighter language={language} style={github}>
- {sql}
- </StyledSyntaxHighlighter>
+ <StyledHeaderMenuContainer>
+ <StyledHeaderActionContainer>
+ <CopyToClipboard
+ text={currentSQL}
+ shouldShowText={false}
+ copyNode={
+ <CopyButtonViewQuery buttonSize="small">
+ {t('Copy')}
+ </CopyButtonViewQuery>
+ }
+ />
+ <Button onClick={navToSQLLab}>{t('View in SQL Lab')}</Button>
+ </StyledHeaderActionContainer>
+ <StyledHeaderActionContainer>
+ <Switch
+ id="formatSwitch"
+ checked={!showFormatSQL}
+ onChange={formatCurrentQuery}
+ />
+ <StyledLabel htmlFor="formatSwitch">
+ {t('Show original SQL')}
+ </StyledLabel>
+ </StyledHeaderActionContainer>
+ </StyledHeaderMenuContainer>
+ {!formattedSQL && <Skeleton active />}
+ {formattedSQL && (
+ <StyledSyntaxHighlighter language={language} style={github}>
+ {currentSQL}
+ </StyledSyntaxHighlighter>
+ )}
</StyledSyntaxContainer>
);
};
diff --git
a/superset-frontend/src/explore/components/controls/ViewQueryModal.tsx
b/superset-frontend/src/explore/components/controls/ViewQueryModal.tsx
index ab4093d4bb..4d25b22b2a 100644
--- a/superset-frontend/src/explore/components/controls/ViewQueryModal.tsx
+++ b/superset-frontend/src/explore/components/controls/ViewQueryModal.tsx
@@ -23,13 +23,14 @@ import {
ensureIsArray,
t,
getClientErrorObject,
+ QueryFormData,
} from '@superset-ui/core';
import Loading from 'src/components/Loading';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import ViewQuery from 'src/explore/components/controls/ViewQuery';
interface Props {
- latestQueryFormData: object;
+ latestQueryFormData: QueryFormData;
}
type Result = {
@@ -43,7 +44,7 @@ const ViewQueryModalContainer = styled.div`
flex-direction: column;
`;
-const ViewQueryModal: FC<Props> = props => {
+const ViewQueryModal: FC<Props> = ({ latestQueryFormData }) => {
const [result, setResult] = useState<Result[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -51,7 +52,7 @@ const ViewQueryModal: FC<Props> = props => {
const loadChartData = (resultType: string) => {
setIsLoading(true);
getChartDataRequest({
- formData: props.latestQueryFormData,
+ formData: latestQueryFormData,
resultFormat: 'json',
resultType,
})
@@ -74,7 +75,7 @@ const ViewQueryModal: FC<Props> = props => {
};
useEffect(() => {
loadChartData('query');
- }, [JSON.stringify(props.latestQueryFormData)]);
+ }, [JSON.stringify(latestQueryFormData)]);
if (isLoading) {
return <Loading />;
@@ -87,7 +88,11 @@ const ViewQueryModal: FC<Props> = props => {
<ViewQueryModalContainer>
{result.map(item =>
item.query ? (
- <ViewQuery sql={item.query} language={item.language || undefined} />
+ <ViewQuery
+ datasource={latestQueryFormData.datasource}
+ sql={item.query}
+ language={item.language || undefined}
+ />
) : null,
)}
</ViewQueryModalContainer>