chore(explore): Add format sql and view in SQL Lab option in View Query (#33341)

This commit is contained in:
JUST.in DO IT
2025-06-09 15:11:54 -07:00
committed by GitHub
parent d11b6d557e
commit 3a3984006c
3 changed files with 294 additions and 23 deletions

View File

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

View File

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

View File

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