feat: SQL preview modal for Query History (#11634)

This commit is contained in:
ʈᵃᵢ 2020-11-20 16:01:06 -08:00 committed by GitHub
parent a3a2a68f01
commit fbe4a6622e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 703 additions and 108 deletions

View File

@ -26,7 +26,10 @@ module.exports = {
'^spec/(.*)$': '<rootDir>/spec/$1',
},
testEnvironment: 'enzyme',
setupFilesAfterEnv: ['jest-enzyme', '<rootDir>/spec/helpers/shim.ts'],
setupFilesAfterEnv: [
'<rootDir>/node_modules/jest-enzyme/lib/index.js',
'<rootDir>/spec/helpers/shim.ts',
],
testURL: 'http://localhost',
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'],
coverageDirectory: '<rootDir>/coverage/',

View File

@ -30,6 +30,7 @@ import { DropDownProps } from 'antd/lib/dropdown';
// eslint-disable-next-line no-restricted-imports
export {
Avatar,
Button,
Card,
Collapse,
DatePicker,

View File

@ -28,6 +28,13 @@ import {
addWarningToast,
} from '../actions';
export interface ToastProps {
addDangerToast: typeof addDangerToast;
addInfoToast: typeof addInfoToast;
addSuccessToast: typeof addSuccessToast;
addWarningToast: typeof addWarningToast;
}
// To work properly the redux state must have a `messageToasts` subtree
export default function withToasts(BaseComponent: ComponentType<any>) {
return connect(null, dispatch =>

View File

@ -0,0 +1,117 @@
/**
* 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 React from 'react';
import { styled, t } from '@superset-ui/core';
import { SyntaxHighlighterProps } from 'react-syntax-highlighter';
import sqlSyntax from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql';
import htmlSyntax from 'react-syntax-highlighter/dist/cjs/languages/hljs/htmlbars';
import markdownSyntax from 'react-syntax-highlighter/dist/cjs/languages/hljs/markdown';
import jsonSyntax from 'react-syntax-highlighter/dist/cjs/languages/hljs/json';
import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light';
import { ToastProps } from 'src/messageToasts/enhancers/withToasts';
import Icon from 'src/components/Icon';
SyntaxHighlighter.registerLanguage('sql', sqlSyntax);
SyntaxHighlighter.registerLanguage('markdown', markdownSyntax);
SyntaxHighlighter.registerLanguage('html', htmlSyntax);
SyntaxHighlighter.registerLanguage('json', jsonSyntax);
const SyntaxHighlighterWrapper = styled.div`
margin-top: -24px;
&:hover {
svg {
visibility: visible;
}
}
svg {
position: relative;
top: 40px;
left: 512px;
visibility: hidden;
margin: -4px;
}
`;
export default function SyntaxHighlighterCopy({
addDangerToast,
addSuccessToast,
children,
...syntaxHighlighterProps
}: SyntaxHighlighterProps & {
children: string;
addDangerToast?: ToastProps['addDangerToast'];
addSuccessToast?: ToastProps['addSuccessToast'];
language: 'sql' | 'markdown' | 'html' | 'json';
}) {
function copyToClipboard(textToCopy: string) {
const selection: Selection | null = document.getSelection();
if (selection) {
selection.removeAllRanges();
const range = document.createRange();
const span = document.createElement('span');
span.textContent = textToCopy;
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')) {
throw new Error(t('Not successful'));
}
} catch (err) {
if (addDangerToast) {
addDangerToast(t('Sorry, your browser does not support copying.'));
}
}
document.body.removeChild(span);
if (selection.removeRange) {
selection.removeRange(range);
} else {
selection.removeAllRanges();
}
if (addSuccessToast) {
addSuccessToast(t('SQL Copied!'));
}
}
}
return (
<SyntaxHighlighterWrapper>
<Icon
tabIndex={0}
name="copy"
role="button"
onClick={e => {
e.preventDefault();
e.currentTarget.blur();
copyToClipboard(children);
}}
/>
<SyntaxHighlighter style={github} {...syntaxHighlighterProps}>
{children}
</SyntaxHighlighter>
</SyntaxHighlighterWrapper>
);
}

View File

@ -0,0 +1,75 @@
/**
* 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 { useState, useEffect } from 'react';
type BaseQueryObject = {
id: number;
};
export function useQueryPreviewState<D extends BaseQueryObject = any>({
queries,
fetchData,
currentQueryId,
}: {
queries: D[];
fetchData: (id: number) => any;
currentQueryId: number;
}) {
const index = queries.findIndex(query => query.id === currentQueryId);
const [currentIndex, setCurrentIndex] = useState(index);
const [disablePrevious, setDisablePrevious] = useState(false);
const [disableNext, setDisableNext] = useState(false);
function checkIndex() {
setDisablePrevious(currentIndex === 0);
setDisableNext(currentIndex === queries.length - 1);
}
function handleDataChange(previous: boolean) {
const offset = previous ? -1 : 1;
const index = currentIndex + offset;
if (index >= 0 && index < queries.length) {
fetchData(queries[index].id);
setCurrentIndex(index);
checkIndex();
}
}
function handleKeyPress(ev: any) {
if (currentIndex >= 0 && currentIndex < queries.length) {
if (ev.key === 'ArrowDown' || ev.key === 'k') {
ev.preventDefault();
handleDataChange(false);
} else if (ev.key === 'ArrowUp' || ev.key === 'j') {
ev.preventDefault();
handleDataChange(true);
}
}
}
useEffect(() => {
checkIndex();
});
return {
handleKeyPress,
handleDataChange,
disablePrevious,
disableNext,
};
}

View File

@ -20,11 +20,14 @@ import React from 'react';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import fetchMock from 'fetch-mock';
import { act } from 'react-dom/test-utils';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import { styledMount as mount } from 'spec/helpers/theming';
import QueryList, { QueryObject } from 'src/views/CRUD/data/query/QueryList';
import QueryList from 'src/views/CRUD/data/query/QueryList';
import QueryPreviewModal from 'src/views/CRUD/data/query/QueryPreviewModal';
import { QueryObject } from 'src/views/CRUD/types';
import ListView from 'src/components/ListView';
import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light';
@ -43,6 +46,7 @@ const mockQueries: QueryObject[] = [...new Array(3)].map((_, i) => ({
},
schema: 'public',
sql: `SELECT ${i} FROM table`,
executed_sql: `SELECT ${i} FROM table`,
sql_tables: [
{ schema: 'foo', table: 'table' },
{ schema: 'bar', table: 'table_2' },
@ -97,4 +101,17 @@ describe('QueryList', () => {
it('renders a SyntaxHighlight', () => {
expect(wrapper.find(SyntaxHighlighter)).toExist();
});
it('opens a query preview', () => {
act(() => {
const props = wrapper
.find('[data-test="open-sql-preview-0"]')
.first()
.props();
if (props.onClick) props.onClick({} as React.MouseEvent);
});
wrapper.update();
expect(wrapper.find(QueryPreviewModal)).toExist();
});
});

View File

@ -16,10 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useMemo } from 'react';
import { t, styled } from '@superset-ui/core';
import React, { useMemo, useState, useCallback } from 'react';
import { SupersetClient, t, styled } from '@superset-ui/core';
import moment from 'moment';
import { createErrorHandler } from 'src/views/CRUD/utils';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import { useListViewResource } from 'src/views/CRUD/hooks';
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
@ -32,8 +33,11 @@ import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light';
import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql';
import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
import { DATETIME_WITH_TIME_ZONE, TIME_WITH_MS } from 'src/constants';
import { QueryObject } from 'src/views/CRUD/types';
SyntaxHighlighter.registerLanguage('sql', sql);
import QueryPreviewModal from './QueryPreviewModal';
const PAGE_SIZE = 25;
const TopAlignedListView = styled(ListView)<ListViewProps<QueryObject>>`
table .table-cell {
@ -41,13 +45,13 @@ const TopAlignedListView = styled(ListView)<ListViewProps<QueryObject>>`
}
`;
SyntaxHighlighter.registerLanguage('sql', sql);
const StyledSyntaxHighlighter = styled(SyntaxHighlighter)`
height: ${({ theme }) => theme.gridUnit * 26}px;
overflow-x: hidden !important; /* needed to override inline styles */
text-overflow: ellipsis;
white-space: nowrap;
`;
const PAGE_SIZE = 25;
const SQL_PREVIEW_MAX_LINES = 4;
function shortenSQL(sql: string) {
let lines: string[] = sql.split('\n');
@ -62,37 +66,6 @@ interface QueryListProps {
addSuccessToast: (msg: string, config?: any) => any;
}
export interface QueryObject {
id: number;
changed_on: string;
database: {
database_name: string;
};
schema: string;
sql: string;
sql_tables?: { catalog?: string; schema: string; table: string }[];
status:
| 'success'
| 'failed'
| 'stopped'
| 'running'
| 'timed_out'
| 'scheduled'
| 'pending';
tab_name: string;
user: {
first_name: string;
id: number;
last_name: string;
username: string;
};
start_time: number;
end_time: number;
rows: number;
tmp_table_name: string;
tracking_url: string;
}
const StyledTableLabel = styled.div`
.count {
margin-left: 5px;
@ -128,6 +101,28 @@ function QueryList({ addDangerToast, addSuccessToast }: QueryListProps) {
false,
);
const [queryCurrentlyPreviewing, setQueryCurrentlyPreviewing] = useState<
QueryObject
>();
const handleQueryPreview = useCallback(
(id: number) => {
SupersetClient.get({
endpoint: `/api/v1/query/${id}`,
}).then(
({ json = {} }) => {
setQueryCurrentlyPreviewing({ ...json.result });
},
createErrorHandler(errMsg =>
addDangerToast(
t('There was an issue previewing the selected query. %s', errMsg),
),
),
);
},
[addDangerToast],
);
const menuData: SubMenuProps = {
activeChild: 'Query History',
...commonMenuData,
@ -174,10 +169,12 @@ function QueryList({ addDangerToast, addSuccessToast }: QueryListProps) {
}
return (
<Tooltip title={statusConfig.label} placement="bottom">
<StatusIcon
name={statusConfig.name as IconName}
status={statusConfig.status}
/>
<span>
<StatusIcon
name={statusConfig.name as IconName}
status={statusConfig.status}
/>
</span>
</Tooltip>
);
},
@ -256,7 +253,7 @@ function QueryList({ addDangerToast, addSuccessToast }: QueryListProps) {
content={
<>
{names.map((name: string) => (
<StyledPopoverItem>{name}</StyledPopoverItem>
<StyledPopoverItem key={name}>{name}</StyledPopoverItem>
))}
</>
}
@ -292,15 +289,18 @@ function QueryList({ addDangerToast, addSuccessToast }: QueryListProps) {
{
accessor: 'sql',
Header: t('SQL'),
Cell: ({
row: {
original: { sql },
},
}: any) => {
Cell: ({ row: { original, id } }: any) => {
return (
<StyledSyntaxHighlighter language="sql" style={github}>
{shortenSQL(sql)}
</StyledSyntaxHighlighter>
<div
tabIndex={0}
role="button"
data-test={`open-sql-preview-${id}`}
onClick={() => setQueryCurrentlyPreviewing(original)}
>
<StyledSyntaxHighlighter language="sql" style={github}>
{shortenSQL(original.sql)}
</StyledSyntaxHighlighter>
</div>
);
},
},
@ -331,6 +331,18 @@ function QueryList({ addDangerToast, addSuccessToast }: QueryListProps) {
return (
<>
<SubMenu {...menuData} />
{queryCurrentlyPreviewing && (
<QueryPreviewModal
onHide={() => setQueryCurrentlyPreviewing(undefined)}
query={queryCurrentlyPreviewing}
queries={queries}
fetchData={handleQueryPreview}
openInSqlLab={(id: number) =>
window.location.assign(`/superset/sqllab?queryId=${id}`)
}
show
/>
)}
<TopAlignedListView
className="query-history-list-view"
columns={columns}
@ -341,6 +353,7 @@ function QueryList({ addDangerToast, addSuccessToast }: QueryListProps) {
initialSort={initialSort}
loading={loading}
pageSize={PAGE_SIZE}
highlightRowId={queryCurrentlyPreviewing?.id}
/>
</>
);

View File

@ -0,0 +1,179 @@
/**
* 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 React from 'react';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import { styledMount as mount } from 'spec/helpers/theming';
import QueryPreviewModal from 'src/views/CRUD/data/query/QueryPreviewModal';
import { QueryObject } from 'src/views/CRUD/types';
import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light';
import { act } from 'react-dom/test-utils';
// store needed for withToasts
const mockStore = configureStore([thunk]);
const store = mockStore({});
const mockQueries: QueryObject[] = [...new Array(3)].map((_, i) => ({
changed_on: new Date().toISOString(),
id: i,
slice_name: `cool chart ${i}`,
database: {
database_name: 'main db',
},
schema: 'public',
sql: `SELECT ${i} FROM table`,
executed_sql: `SELECT ${i} FROM table LIMIT 1000`,
sql_tables: [
{ schema: 'foo', table: 'table' },
{ schema: 'bar', table: 'table_2' },
],
status: 'success',
tab_name: 'Main Tab',
user: {
first_name: 'cool',
last_name: 'dude',
id: 2,
username: 'cooldude',
},
start_time: new Date().valueOf(),
end_time: new Date().valueOf(),
rows: 200,
tmp_table_name: '',
tracking_url: '',
}));
describe('QueryPreviewModal', () => {
let currentIndex = 0;
let currentQuery = mockQueries[currentIndex];
const mockedProps = {
onHide: jest.fn(),
openInSqlLab: jest.fn(),
queries: mockQueries,
query: currentQuery,
fetchData: jest.fn(() => {
currentIndex += 1;
currentQuery = mockQueries[currentIndex];
}),
show: true,
};
const wrapper = mount(<QueryPreviewModal {...mockedProps} />, {
context: { store },
});
beforeAll(async () => {
await waitForComponentToPaint(wrapper);
});
it('renders a SynxHighlighter', () => {
expect(wrapper.find(SyntaxHighlighter)).toExist();
});
it('toggles between user sql and executed sql', () => {
expect(
wrapper.find(SyntaxHighlighter).props().children,
).toMatchInlineSnapshot(`"SELECT 0 FROM table"`);
act(() => {
const props = wrapper
.find('[data-test="toggle-executed-sql"]')
.first()
.props();
if (typeof props.onClick === 'function')
props.onClick({} as React.MouseEvent);
});
wrapper.update();
expect(
wrapper.find(SyntaxHighlighter).props().children,
).toMatchInlineSnapshot(`"SELECT 0 FROM table LIMIT 1000"`);
});
describe('Previous button', () => {
it('disabled when query is the first in list', () => {
expect(
wrapper.find('[data-test="previous-query"]').first().props().disabled,
).toBe(true);
});
it('falls fetchData with previous index', () => {
const mockedProps2 = {
...mockedProps,
query: mockQueries[1],
};
const wrapper2 = mount(<QueryPreviewModal {...mockedProps2} />, {
context: { store },
});
act(() => {
const props = wrapper2
.find('[data-test="previous-query"]')
.first()
.props();
if (typeof props.onClick === 'function')
props.onClick({} as React.MouseEvent);
});
expect(mockedProps2.fetchData).toHaveBeenCalledWith(0);
});
});
describe('Next button', () => {
it('calls fetchData with next index', () => {
act(() => {
const props = wrapper.find('[data-test="next-query"]').first().props();
if (typeof props.onClick === 'function')
props.onClick({} as React.MouseEvent);
});
expect(mockedProps.fetchData).toHaveBeenCalledWith(1);
});
it('disabled when query is last in list', () => {
const mockedProps2 = {
...mockedProps,
query: mockQueries[2],
};
const wrapper2 = mount(<QueryPreviewModal {...mockedProps2} />, {
context: { store },
});
expect(
wrapper2.find('[data-test="next-query"]').first().props().disabled,
).toBe(true);
});
});
describe('Open in SQL Lab button', () => {
it('calls openInSqlLab prop', () => {
const props = wrapper
.find('[data-test="open-in-sql-lab"]')
.first()
.props();
if (typeof props.onClick === 'function')
props.onClick({} as React.MouseEvent);
expect(mockedProps.openInSqlLab).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,179 @@
/**
* 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 React, { useState } from 'react';
import { styled, t } from '@superset-ui/core';
import Modal from 'src/common/components/Modal';
import cx from 'classnames';
import Button from 'src/components/Button';
import withToasts, { ToastProps } from 'src/messageToasts/enhancers/withToasts';
import SyntaxHighlighterCopy from 'src/views/CRUD/data/components/SyntaxHighlighterCopy';
import { useQueryPreviewState } from 'src/views/CRUD/data/hooks';
import { QueryObject } from 'src/views/CRUD/types';
const QueryTitle = styled.div`
color: ${({ theme }) => theme.colors.secondary.light2};
font-size: ${({ theme }) => theme.typography.sizes.s - 1}px;
margin-bottom: 0;
text-transform: uppercase;
`;
const QueryLabel = styled.div`
color: ${({ theme }) => theme.colors.grayscale.dark2};
font-size: ${({ theme }) => theme.typography.sizes.m - 1}px;
padding: 4px 0 24px 0;
`;
const QueryViewToggle = styled.div`
margin: 0 0 ${({ theme }) => theme.gridUnit * 6}px 0;
`;
const TabButton = styled.div`
display: inline;
font-size: ${({ theme }) => theme.typography.sizes.s}px;
padding: ${({ theme }) => theme.gridUnit * 2}px
${({ theme }) => theme.gridUnit * 4}px;
margin-right: ${({ theme }) => theme.gridUnit * 4}px;
color: ${({ theme }) => theme.colors.secondary.dark1};
&.active,
&:focus,
&:hover {
background: ${({ theme }) => theme.colors.secondary.light4};
border-bottom: none;
border-radius: ${({ theme }) => theme.borderRadius}px;
margin-bottom: ${({ theme }) => theme.gridUnit * 2}px;
}
&:hover:not(.active) {
background: ${({ theme }) => theme.colors.secondary.light5};
}
`;
const StyledModal = styled(Modal)`
.ant-modal-body {
padding: ${({ theme }) => theme.gridUnit * 6}px;
}
pre {
font-size: ${({ theme }) => theme.typography.sizes.xs}px;
font-weight: ${({ theme }) => theme.typography.weights.normal};
line-height: ${({ theme }) => theme.typography.sizes.l}px;
height: 375px;
border: none;
}
`;
interface QueryPreviewModalProps extends ToastProps {
onHide: () => void;
openInSqlLab: (id: number) => any;
queries: QueryObject[];
query: QueryObject;
fetchData: (id: number) => any;
show: boolean;
}
function QueryPreviewModal({
onHide,
openInSqlLab,
queries,
query,
fetchData,
show,
addDangerToast,
addSuccessToast,
}: QueryPreviewModalProps) {
const {
handleKeyPress,
handleDataChange,
disablePrevious,
disableNext,
} = useQueryPreviewState<QueryObject>({
queries,
currentQueryId: query.id,
fetchData,
});
const [currentTab, setCurrentTab] = useState<'user' | 'executed'>('user');
const { id, sql, executed_sql } = query;
return (
<div role="none" onKeyUp={handleKeyPress}>
<StyledModal
onHide={onHide}
show={show}
title={t('Query Preview')}
footer={[
<Button
data-test="previous-query"
key="previous-query"
disabled={disablePrevious}
onClick={() => handleDataChange(true)}
>
{t('Previous')}
</Button>,
<Button
data-test="next-query"
key="next-query"
disabled={disableNext}
onClick={() => handleDataChange(false)}
>
{t('Next')}
</Button>,
<Button
data-test="open-in-sql-lab"
key="open-in-sql-lab"
buttonStyle="primary"
onClick={() => openInSqlLab(id)}
>
{t('Open in SQL Lab')}
</Button>,
]}
>
<QueryTitle>{t('Tab Name')}</QueryTitle>
<QueryLabel>{query.tab_name}</QueryLabel>
<QueryViewToggle>
<TabButton
role="button"
data-test="toggle-user-sql"
className={cx({ active: currentTab === 'user' })}
onClick={() => setCurrentTab('user')}
>
{t('User query')}
</TabButton>
<TabButton
role="button"
data-test="toggle-executed-sql"
className={cx({ active: currentTab === 'executed' })}
onClick={() => setCurrentTab('executed')}
>
{t('Executed query')}
</TabButton>
</QueryViewToggle>
<SyntaxHighlighterCopy
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
language="sql"
>
{(currentTab === 'user' ? sql : executed_sql) || ''}
</SyntaxHighlighterCopy>
</StyledModal>
</div>
);
}
export default withToasts(QueryPreviewModal);

View File

@ -262,7 +262,7 @@ function SavedQueryList({
content={
<>
{names.map((name: string) => (
<StyledPopoverItem>{name}</StyledPopoverItem>
<StyledPopoverItem key={name}>{name}</StyledPopoverItem>
))}
</>
}

View File

@ -16,16 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { FunctionComponent, useState, useEffect } from 'react';
import React, { FunctionComponent } from 'react';
import { styled, t } from '@superset-ui/core';
import Modal from 'src/common/components/Modal';
import Button from 'src/components/Button';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light';
import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql';
import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
SyntaxHighlighter.registerLanguage('sql', sql);
import SyntaxHighlighterCopy from 'src/views/CRUD/data/components/SyntaxHighlighterCopy';
import withToasts, { ToastProps } from 'src/messageToasts/enhancers/withToasts';
import { useQueryPreviewState } from 'src/views/CRUD/data/hooks';
const QueryTitle = styled.div`
color: ${({ theme }) => theme.colors.secondary.light2};
@ -42,7 +39,6 @@ const QueryLabel = styled.div`
const StyledModal = styled(Modal)`
.ant-modal-content {
height: 620px;
}
.ant-modal-body {
@ -64,7 +60,7 @@ type SavedQueryObject = {
sql: string;
};
interface SavedQueryPreviewModalProps {
interface SavedQueryPreviewModalProps extends ToastProps {
fetchData: (id: number) => {};
onHide: () => void;
openInSqlLab: (id: number) => {};
@ -80,50 +76,18 @@ const SavedQueryPreviewModal: FunctionComponent<SavedQueryPreviewModalProps> = (
queries,
savedQuery,
show,
addDangerToast,
addSuccessToast,
}) => {
const index = queries.findIndex(query => query.id === savedQuery.id);
const [currentIndex, setCurrentIndex] = useState(index);
const [disbalePrevious, setDisbalePrevious] = useState(false);
const [disbaleNext, setDisbaleNext] = useState(false);
function checkIndex() {
if (currentIndex === 0) {
setDisbalePrevious(true);
} else {
setDisbalePrevious(false);
}
if (currentIndex === queries.length - 1) {
setDisbaleNext(true);
} else {
setDisbaleNext(false);
}
}
function handleDataChange(previous: boolean) {
const offset = previous ? -1 : 1;
const index = currentIndex + offset;
if (index >= 0 && index < queries.length) {
fetchData(queries[index].id);
setCurrentIndex(index);
checkIndex();
}
}
function handleKeyPress(ev: any) {
if (currentIndex >= 0 && currentIndex < queries.length) {
if (ev.key === 'ArrowDown' || ev.key === 'k') {
ev.preventDefault();
handleDataChange(false);
} else if (ev.key === 'ArrowUp' || ev.key === 'j') {
ev.preventDefault();
handleDataChange(true);
}
}
}
useEffect(() => {
checkIndex();
const {
handleKeyPress,
handleDataChange,
disablePrevious,
disableNext,
} = useQueryPreviewState<SavedQueryObject>({
queries,
currentQueryId: savedQuery.id,
fetchData,
});
return (
@ -136,7 +100,7 @@ const SavedQueryPreviewModal: FunctionComponent<SavedQueryPreviewModalProps> = (
<Button
data-test="previous-saved-query"
key="previous-saved-query"
disabled={disbalePrevious}
disabled={disablePrevious}
onClick={() => handleDataChange(true)}
>
{t('Previous')}
@ -144,7 +108,7 @@ const SavedQueryPreviewModal: FunctionComponent<SavedQueryPreviewModalProps> = (
<Button
data-test="next-saved-query"
key="next-saved-query"
disabled={disbaleNext}
disabled={disableNext}
onClick={() => handleDataChange(false)}
>
{t('Next')}
@ -159,11 +123,15 @@ const SavedQueryPreviewModal: FunctionComponent<SavedQueryPreviewModalProps> = (
</Button>,
]}
>
<QueryTitle>query name</QueryTitle>
<QueryTitle>{t('Query Name')}</QueryTitle>
<QueryLabel>{savedQuery.label}</QueryLabel>
<SyntaxHighlighter language="sql" style={github}>
<SyntaxHighlighterCopy
language="sql"
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
>
{savedQuery.sql || ''}
</SyntaxHighlighter>
</SyntaxHighlighterCopy>
</StyledModal>
</div>
);

View File

@ -59,3 +59,35 @@ export type SavedQueryObject = {
sql: string | null;
sql_tables?: { catalog?: string; schema: string; table: string }[];
};
export interface QueryObject {
id: number;
changed_on: string;
database: {
database_name: string;
};
schema: string;
sql: string;
executed_sql: string | null;
sql_tables?: { catalog?: string; schema: string; table: string }[];
status:
| 'success'
| 'failed'
| 'stopped'
| 'running'
| 'timed_out'
| 'scheduled'
| 'pending';
tab_name: string;
user: {
first_name: string;
id: number;
last_name: string;
username: string;
};
start_time: number;
end_time: number;
rows: number;
tmp_table_name: string;
tracking_url: string;
}

View File

@ -41,6 +41,7 @@ class QueryRestApi(BaseSupersetModelRestApi):
"id",
"changed_on",
"database.database_name",
"executed_sql",
"rows",
"schema",
"sql",
@ -57,6 +58,7 @@ class QueryRestApi(BaseSupersetModelRestApi):
"tracking_url",
]
show_columns = [
"id",
"changed_on",
"client_id",
"database.id",

View File

@ -166,6 +166,7 @@ class TestQueryApi(SupersetTestCase):
"end_time",
"start_running_time",
"start_time",
"id",
):
self.assertEqual(value, expected_result[key])
# rollback changes
@ -257,6 +258,7 @@ class TestQueryApi(SupersetTestCase):
"changed_on",
"database",
"end_time",
"executed_sql",
"id",
"rows",
"schema",