You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ta...@apache.org on 2020/11/21 00:01:34 UTC
[incubator-superset] branch master updated: feat: SQL preview modal
for Query History (#11634)
This is an automated email from the ASF dual-hosted git repository.
tai pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
The following commit(s) were added to refs/heads/master by this push:
new fbe4a66 feat: SQL preview modal for Query History (#11634)
fbe4a66 is described below
commit fbe4a6622e0affd6568ba0d5c429dc6bec18f626
Author: ʈᵃᵢ <td...@gmail.com>
AuthorDate: Fri Nov 20 16:01:06 2020 -0800
feat: SQL preview modal for Query History (#11634)
---
superset-frontend/jest.config.js | 5 +-
superset-frontend/src/common/components/index.tsx | 1 +
.../src/messageToasts/enhancers/withToasts.tsx | 7 +
.../components/SyntaxHighlighterCopy/index.tsx | 117 ++++++++++++++
superset-frontend/src/views/CRUD/data/hooks.ts | 75 +++++++++
.../src/views/CRUD/data/query/QueryList.test.tsx | 19 ++-
.../src/views/CRUD/data/query/QueryList.tsx | 109 +++++++------
.../CRUD/data/query/QueryPreviewModal.test.tsx | 179 +++++++++++++++++++++
.../views/CRUD/data/query/QueryPreviewModal.tsx | 179 +++++++++++++++++++++
.../views/CRUD/data/savedquery/SavedQueryList.tsx | 2 +-
.../data/savedquery/SavedQueryPreviewModal.tsx | 82 +++-------
superset-frontend/src/views/CRUD/types.ts | 32 ++++
superset/queries/api.py | 2 +
tests/queries/api_tests.py | 2 +
14 files changed, 703 insertions(+), 108 deletions(-)
diff --git a/superset-frontend/jest.config.js b/superset-frontend/jest.config.js
index 2d02d83..c8196dc 100644
--- a/superset-frontend/jest.config.js
+++ b/superset-frontend/jest.config.js
@@ -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/',
diff --git a/superset-frontend/src/common/components/index.tsx b/superset-frontend/src/common/components/index.tsx
index dbf2a31..a9bad8e 100644
--- a/superset-frontend/src/common/components/index.tsx
+++ b/superset-frontend/src/common/components/index.tsx
@@ -30,6 +30,7 @@ import { DropDownProps } from 'antd/lib/dropdown';
// eslint-disable-next-line no-restricted-imports
export {
Avatar,
+ Button,
Card,
Collapse,
DatePicker,
diff --git a/superset-frontend/src/messageToasts/enhancers/withToasts.tsx b/superset-frontend/src/messageToasts/enhancers/withToasts.tsx
index 0a23134..85c72de 100644
--- a/superset-frontend/src/messageToasts/enhancers/withToasts.tsx
+++ b/superset-frontend/src/messageToasts/enhancers/withToasts.tsx
@@ -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 =>
diff --git a/superset-frontend/src/views/CRUD/data/components/SyntaxHighlighterCopy/index.tsx b/superset-frontend/src/views/CRUD/data/components/SyntaxHighlighterCopy/index.tsx
new file mode 100644
index 0000000..25882ed
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/data/components/SyntaxHighlighterCopy/index.tsx
@@ -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>
+ );
+}
diff --git a/superset-frontend/src/views/CRUD/data/hooks.ts b/superset-frontend/src/views/CRUD/data/hooks.ts
new file mode 100644
index 0000000..41b5145
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/data/hooks.ts
@@ -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,
+ };
+}
diff --git a/superset-frontend/src/views/CRUD/data/query/QueryList.test.tsx b/superset-frontend/src/views/CRUD/data/query/QueryList.test.tsx
index 774c93b..008d767 100644
--- a/superset-frontend/src/views/CRUD/data/query/QueryList.test.tsx
+++ b/superset-frontend/src/views/CRUD/data/query/QueryList.test.tsx
@@ -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();
+ });
});
diff --git a/superset-frontend/src/views/CRUD/data/query/QueryList.tsx b/superset-frontend/src/views/CRUD/data/query/QueryList.tsx
index 6e8807e..f030250 100644
--- a/superset-frontend/src/views/CRUD/data/query/QueryList.tsx
+++ b/superset-frontend/src/views/CRUD/data/query/QueryList.tsx
@@ -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}
/>
</>
);
diff --git a/superset-frontend/src/views/CRUD/data/query/QueryPreviewModal.test.tsx b/superset-frontend/src/views/CRUD/data/query/QueryPreviewModal.test.tsx
new file mode 100644
index 0000000..85fc22a
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/data/query/QueryPreviewModal.test.tsx
@@ -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();
+ });
+ });
+});
diff --git a/superset-frontend/src/views/CRUD/data/query/QueryPreviewModal.tsx b/superset-frontend/src/views/CRUD/data/query/QueryPreviewModal.tsx
new file mode 100644
index 0000000..19862d3
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/data/query/QueryPreviewModal.tsx
@@ -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);
diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx
index 96f0f5f..0ecac3a 100644
--- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx
+++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx
@@ -262,7 +262,7 @@ function SavedQueryList({
content={
<>
{names.map((name: string) => (
- <StyledPopoverItem>{name}</StyledPopoverItem>
+ <StyledPopoverItem key={name}>{name}</StyledPopoverItem>
))}
</>
}
diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx
index e127d1c..731d624 100644
--- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx
+++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx
@@ -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>
);
diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts
index a00a807..48570c1 100644
--- a/superset-frontend/src/views/CRUD/types.ts
+++ b/superset-frontend/src/views/CRUD/types.ts
@@ -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;
+}
diff --git a/superset/queries/api.py b/superset/queries/api.py
index e5feaa9..4b2ab13 100644
--- a/superset/queries/api.py
+++ b/superset/queries/api.py
@@ -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",
diff --git a/tests/queries/api_tests.py b/tests/queries/api_tests.py
index 4aed9ec..54d100d 100644
--- a/tests/queries/api_tests.py
+++ b/tests/queries/api_tests.py
@@ -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",