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",