feat: SQL preview modal for Query History (#11634)
This commit is contained in:
parent
a3a2a68f01
commit
fbe4a6622e
|
|
@ -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/',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { DropDownProps } from 'antd/lib/dropdown';
|
|||
// eslint-disable-next-line no-restricted-imports
|
||||
export {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
DatePicker,
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -262,7 +262,7 @@ function SavedQueryList({
|
|||
content={
|
||||
<>
|
||||
{names.map((name: string) => (
|
||||
<StyledPopoverItem>{name}</StyledPopoverItem>
|
||||
<StyledPopoverItem key={name}>{name}</StyledPopoverItem>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue