diff --git a/superset-frontend/jest.config.js b/superset-frontend/jest.config.js index 2d02d8386..c8196dc62 100644 --- a/superset-frontend/jest.config.js +++ b/superset-frontend/jest.config.js @@ -26,7 +26,10 @@ module.exports = { '^spec/(.*)$': '/spec/$1', }, testEnvironment: 'enzyme', - setupFilesAfterEnv: ['jest-enzyme', '/spec/helpers/shim.ts'], + setupFilesAfterEnv: [ + '/node_modules/jest-enzyme/lib/index.js', + '/spec/helpers/shim.ts', + ], testURL: 'http://localhost', collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'], coverageDirectory: '/coverage/', diff --git a/superset-frontend/src/common/components/index.tsx b/superset-frontend/src/common/components/index.tsx index dbf2a3178..a9bad8e2f 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 0a2313488..85c72de06 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) { 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 000000000..25882eddb --- /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 ( + + { + e.preventDefault(); + e.currentTarget.blur(); + copyToClipboard(children); + }} + /> + + {children} + + + ); +} 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 000000000..41b514591 --- /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({ + 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 774c93b58..008d767be 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 6e8807e59..f03025042 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)>` table .table-cell { @@ -41,13 +45,13 @@ const TopAlignedListView = styled(ListView)>` } `; +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 ( - + + + ); }, @@ -256,7 +253,7 @@ function QueryList({ addDangerToast, addSuccessToast }: QueryListProps) { content={ <> {names.map((name: string) => ( - {name} + {name} ))} } @@ -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 ( - - {shortenSQL(sql)} - +
setQueryCurrentlyPreviewing(original)} + > + + {shortenSQL(original.sql)} + +
); }, }, @@ -331,6 +331,18 @@ function QueryList({ addDangerToast, addSuccessToast }: QueryListProps) { return ( <> + {queryCurrentlyPreviewing && ( + setQueryCurrentlyPreviewing(undefined)} + query={queryCurrentlyPreviewing} + queries={queries} + fetchData={handleQueryPreview} + openInSqlLab={(id: number) => + window.location.assign(`/superset/sqllab?queryId=${id}`) + } + show + /> + )} ); 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 000000000..85fc22a1a --- /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(, { + 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(, { + 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(, { + 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 000000000..19862d3dc --- /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({ + queries, + currentQueryId: query.id, + fetchData, + }); + + const [currentTab, setCurrentTab] = useState<'user' | 'executed'>('user'); + + const { id, sql, executed_sql } = query; + return ( +
+ handleDataChange(true)} + > + {t('Previous')} + , + , + , + ]} + > + {t('Tab Name')} + {query.tab_name} + + setCurrentTab('user')} + > + {t('User query')} + + setCurrentTab('executed')} + > + {t('Executed query')} + + + + {(currentTab === 'user' ? sql : executed_sql) || ''} + + +
+ ); +} + +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 96f0f5f9e..0ecac3a27 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) => ( - {name} + {name} ))} } diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx index e127d1c2b..731d62462 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 = ( 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({ + queries, + currentQueryId: savedQuery.id, + fetchData, }); return ( @@ -136,7 +100,7 @@ const SavedQueryPreviewModal: FunctionComponent = ( , ]} > - query name + {t('Query Name')} {savedQuery.label} - + {savedQuery.sql || ''} - + ); diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts index a00a807ce..48570c141 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 e5feaa9bb..4b2ab1398 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 4aed9ecbd..54d100d5f 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",