mirror of
https://github.com/apache/superset.git
synced 2026-04-19 16:14:52 +00:00
chore(explore): Add format sql and view in SQL Lab option in View Query (#33341)
This commit is contained in:
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user