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/10/05 23:22:36 UTC

[incubator-superset] branch master updated: feat: saved query preview modal (#11135)

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 152315d  feat: saved query preview modal (#11135)
152315d is described below

commit 152315d0f45f9d2474682f5c7636c58541cbf517
Author: Lily Kuang <li...@preset.io>
AuthorDate: Mon Oct 5 16:21:59 2020 -0700

    feat: saved query preview modal (#11135)
---
 .../savedquery/SavedQueryPreviewModal_spec.jsx     | 139 +++++++++++++++++
 superset-frontend/src/common/components/Modal.tsx  |  39 +++--
 .../src/components/ListView/ListView.tsx           |   3 +
 .../src/components/ListView/TableCollection.tsx    |   6 +-
 .../views/CRUD/data/savedquery/SavedQueryList.tsx  |  45 +++++-
 .../data/savedquery/SavedQueryPreviewModal.tsx     | 172 +++++++++++++++++++++
 6 files changed, 385 insertions(+), 19 deletions(-)

diff --git a/superset-frontend/spec/javascripts/views/CRUD/data/savedquery/SavedQueryPreviewModal_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/data/savedquery/SavedQueryPreviewModal_spec.jsx
new file mode 100644
index 0000000..4a82609
--- /dev/null
+++ b/superset-frontend/spec/javascripts/views/CRUD/data/savedquery/SavedQueryPreviewModal_spec.jsx
@@ -0,0 +1,139 @@
+/**
+ * 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 fetchMock from 'fetch-mock';
+import { styledMount as mount } from 'spec/helpers/theming';
+import SavedQueryPreviewModal from 'src/views/CRUD/data/savedquery/SavedQueryPreviewModal';
+import Button from 'src/components/Button';
+import Modal from 'src/common/components/Modal';
+import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
+import { act } from 'react-dom/test-utils';
+
+// store needed for withToasts(DatabaseList)
+const mockStore = configureStore([thunk]);
+const store = mockStore({});
+
+const mockqueries = [...new Array(3)].map((_, i) => ({
+  created_by: {
+    id: i,
+    first_name: `user`,
+    last_name: `${i}`,
+  },
+  created_on: `${i}-2020`,
+  database: {
+    database_name: `db ${i}`,
+    id: i,
+  },
+  changed_on_delta_humanized: '1 day ago',
+  db_id: i,
+  description: `SQL for ${i}`,
+  id: i,
+  label: `query ${i}`,
+  schema: 'public',
+  sql: `SELECT ${i} FROM table`,
+  sql_tables: [
+    {
+      catalog: null,
+      schema: null,
+      table: `${i}`,
+    },
+  ],
+}));
+
+const mockedProps = {
+  fetchData: jest.fn(() => {}),
+  openInSqlLab: jest.fn(() => {}),
+  onHide: () => {},
+  queries: mockqueries,
+  savedQuery: mockqueries[1],
+  show: true,
+};
+
+const FETCH_SAVED_QUERY_ENDPOINT = 'glob:*/api/v1/saved_query/*';
+const SAVED_QUERY_PAYLOAD = { result: mockqueries[1] };
+
+fetchMock.get(FETCH_SAVED_QUERY_ENDPOINT, SAVED_QUERY_PAYLOAD);
+
+async function mountAndWait(props = mockedProps) {
+  const mounted = mount(<SavedQueryPreviewModal {...props} />, {
+    context: { store },
+  });
+  await waitForComponentToPaint(mounted);
+
+  return mounted;
+}
+
+describe('SavedQueryPreviewModal', () => {
+  let wrapper;
+
+  beforeAll(async () => {
+    wrapper = await mountAndWait();
+  });
+
+  it('renders', () => {
+    expect(wrapper.find(SavedQueryPreviewModal)).toExist();
+  });
+
+  it('renders a Modal', () => {
+    expect(wrapper.find(Modal)).toExist();
+  });
+
+  it('renders sql from saved query', () => {
+    expect(wrapper.find('pre').text()).toEqual('SELECT 1 FROM table');
+  });
+
+  it('renders buttons with correct text', () => {
+    expect(wrapper.find(Button).contains('Previous')).toBe(true);
+    expect(wrapper.find(Button).contains('Next')).toBe(true);
+    expect(wrapper.find(Button).contains('Open in SQL Lab')).toBe(true);
+  });
+
+  it('handle next save query', () => {
+    const button = wrapper.find('button[data-test="next-saved-query"]');
+    expect(button.props().disabled).toBe(false);
+    act(() => {
+      button.props().onClick(false);
+    });
+    expect(mockedProps.fetchData).toHaveBeenCalled();
+    expect(mockedProps.fetchData.mock.calls[0][0]).toEqual(2);
+  });
+
+  it('handle previous save query', () => {
+    const button = wrapper
+      .find('[data-test="previous-saved-query"]')
+      .find(Button);
+    expect(button.props().disabled).toBe(false);
+    act(() => {
+      button.props().onClick(true);
+    });
+    wrapper.update();
+    expect(mockedProps.fetchData).toHaveBeenCalled();
+    expect(mockedProps.fetchData.mock.calls[0][0]).toEqual(2);
+  });
+
+  it('handle open in sql lab', async () => {
+    act(() => {
+      wrapper.find('[data-test="open-in-sql-lab"]').first().props().onClick();
+    });
+    expect(mockedProps.openInSqlLab).toHaveBeenCalled();
+    expect(mockedProps.openInSqlLab.mock.calls[0][0]).toEqual(1);
+  });
+});
diff --git a/superset-frontend/src/common/components/Modal.tsx b/superset-frontend/src/common/components/Modal.tsx
index 9634cc3..42f7192 100644
--- a/superset-frontend/src/common/components/Modal.tsx
+++ b/superset-frontend/src/common/components/Modal.tsx
@@ -17,6 +17,7 @@
  * under the License.
  */
 import React from 'react';
+import { isNil } from 'lodash';
 import { styled, t } from '@superset-ui/core';
 import { Modal as BaseModal } from 'src/common/components';
 import Button from 'src/components/Button';
@@ -26,13 +27,14 @@ interface ModalProps {
   children: React.ReactNode;
   disablePrimaryButton?: boolean;
   onHide: () => void;
-  onHandledPrimaryAction: () => void;
-  primaryButtonName: string;
+  onHandledPrimaryAction?: () => void;
+  primaryButtonName?: string;
   primaryButtonType?: 'primary' | 'danger';
   show: boolean;
   title: React.ReactNode;
   width?: string;
   centered?: boolean;
+  footer?: React.ReactNode;
 }
 
 const StyledModal = styled(BaseModal)`
@@ -96,8 +98,26 @@ export default function Modal({
   title,
   width,
   centered,
+  footer,
   ...rest
 }: ModalProps) {
+  const modalFooter = isNil(footer)
+    ? [
+        <Button key="back" onClick={onHide} cta>
+          {t('Cancel')}
+        </Button>,
+        <Button
+          key="submit"
+          buttonStyle={primaryButtonType}
+          disabled={disablePrimaryButton}
+          onClick={onHandledPrimaryAction}
+          cta
+        >
+          {primaryButtonName}
+        </Button>,
+      ]
+    : footer;
+
   return (
     <StyledModal
       centered={!!centered}
@@ -111,20 +131,7 @@ export default function Modal({
           ×
         </span>
       }
-      footer={[
-        <Button key="back" onClick={onHide} cta>
-          {t('Cancel')}
-        </Button>,
-        <Button
-          key="submit"
-          buttonStyle={primaryButtonType}
-          disabled={disablePrimaryButton}
-          onClick={onHandledPrimaryAction}
-          cta
-        >
-          {primaryButtonName}
-        </Button>,
-      ]}
+      footer={modalFooter}
       {...rest}
     >
       {children}
diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx
index e0e19c5..4085804 100644
--- a/superset-frontend/src/components/ListView/ListView.tsx
+++ b/superset-frontend/src/components/ListView/ListView.tsx
@@ -202,6 +202,7 @@ export interface ListViewProps<T extends object = any> {
   renderCard?: (row: T & { loading: boolean }) => React.ReactNode;
   cardSortSelectOptions?: Array<CardSortSelectOption>;
   defaultViewMode?: ViewModeType;
+  highlightRowId?: number;
 }
 
 function ListView<T extends object = any>({
@@ -221,6 +222,7 @@ function ListView<T extends object = any>({
   renderCard,
   cardSortSelectOptions,
   defaultViewMode = 'card',
+  highlightRowId,
 }: ListViewProps<T>) {
   const {
     getTableProps,
@@ -350,6 +352,7 @@ function ListView<T extends object = any>({
               rows={rows}
               columns={columns}
               loading={loading}
+              highlightRowId={highlightRowId}
             />
           )}
           {!loading && rows.length === 0 && (
diff --git a/superset-frontend/src/components/ListView/TableCollection.tsx b/superset-frontend/src/components/ListView/TableCollection.tsx
index 4dbbc86..41b0018 100644
--- a/superset-frontend/src/components/ListView/TableCollection.tsx
+++ b/superset-frontend/src/components/ListView/TableCollection.tsx
@@ -30,6 +30,7 @@ interface TableCollectionProps {
   rows: TableInstance['rows'];
   columns: TableInstance['column'][];
   loading: boolean;
+  highlightRowId?: number;
 }
 
 const Table = styled.table`
@@ -199,6 +200,7 @@ export default function TableCollection({
   columns,
   rows,
   loading,
+  highlightRowId,
 }: TableCollectionProps) {
   return (
     <Table {...getTableProps()} className="table table-hover">
@@ -262,7 +264,9 @@ export default function TableCollection({
               <tr
                 {...row.getRowProps()}
                 className={cx('table-row', {
-                  'table-row-selected': row.isSelected,
+                  'table-row-selected':
+                    // @ts-ignore
+                    row.isSelected || row.original.id === highlightRowId,
                 })}
               >
                 {row.cells.map(cell => {
diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx
index 5b0b96d..15b9ba2 100644
--- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx
+++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx
@@ -39,6 +39,7 @@ import DeleteModal from 'src/components/DeleteModal';
 import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
 import { IconName } from 'src/components/Icon';
 import { commonMenuData } from 'src/views/CRUD/data/common';
+import SavedQueryPreviewModal from './SavedQueryPreviewModal';
 
 const PAGE_SIZE = 25;
 
@@ -48,8 +49,17 @@ interface SavedQueryListProps {
 }
 
 type SavedQueryObject = {
+  database: {
+    database_name: string;
+    id: number;
+  };
+  db_id: number;
+  description?: string;
   id: number;
   label: string;
+  schema: string;
+  sql: string;
+  sql_tables: Array<{ catalog?: string; schema: string; table: string }>;
 };
 
 const StyledTableLabel = styled.div`
@@ -85,11 +95,14 @@ function SavedQueryList({
     t('saved_queries'),
     addDangerToast,
   );
-
   const [
     queryCurrentlyDeleting,
     setQueryCurrentlyDeleting,
   ] = useState<SavedQueryObject | null>(null);
+  const [
+    savedQueryCurrentlyPreviewing,
+    setSavedQueryCurrentlyPreviewing,
+  ] = useState<SavedQueryObject | null>(null);
 
   const canCreate = hasPerm('can_add');
   const canEdit = hasPerm('can_edit');
@@ -99,6 +112,21 @@ function SavedQueryList({
     window.open(`${window.location.origin}/superset/sqllab?new=true`);
   };
 
+  const handleSavedQueryPreview = (id: number) => {
+    SupersetClient.get({
+      endpoint: `/api/v1/saved_query/${id}`,
+    }).then(
+      ({ json = {} }) => {
+        setSavedQueryCurrentlyPreviewing({ ...json.result });
+      },
+      createErrorHandler(errMsg =>
+        addDangerToast(
+          t('There was an issue previewing the selected query %s', errMsg),
+        ),
+      ),
+    );
+  };
+
   const menuData: SubMenuProps = {
     activeChild: 'Saved Queries',
     ...commonMenuData,
@@ -293,7 +321,9 @@ function SavedQueryList({
       },
       {
         Cell: ({ row: { original } }: any) => {
-          const handlePreview = () => {}; // openQueryPreviewModal(original); // TODO: open preview modal
+          const handlePreview = () => {
+            handleSavedQueryPreview(original.id);
+          };
           const handleEdit = () => {
             openInSqlLab(original.id);
           };
@@ -410,6 +440,16 @@ function SavedQueryList({
           title={t('Delete Query?')}
         />
       )}
+      {savedQueryCurrentlyPreviewing && (
+        <SavedQueryPreviewModal
+          fetchData={handleSavedQueryPreview}
+          onHide={() => setSavedQueryCurrentlyPreviewing(null)}
+          savedQuery={savedQueryCurrentlyPreviewing}
+          queries={queries}
+          openInSqlLab={openInSqlLab}
+          show
+        />
+      )}
       <ConfirmStatusChange
         title={t('Please confirm')}
         description={t('Are you sure you want to delete the selected queries?')}
@@ -441,6 +481,7 @@ function SavedQueryList({
               bulkActions={bulkActions}
               bulkSelectEnabled={bulkSelectEnabled}
               disableBulkSelect={toggleBulkSelect}
+              highlightRowId={savedQueryCurrentlyPreviewing?.id}
             />
           );
         }}
diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx
new file mode 100644
index 0000000..8caafea
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryPreviewModal.tsx
@@ -0,0 +1,172 @@
+/**
+ * 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, { FunctionComponent, useState, useEffect } 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);
+
+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 16px 0;
+`;
+
+const StyledModal = styled(Modal)`
+  .ant-modal-content {
+    height: 620px;
+  }
+
+  .ant-modal-body {
+    padding: 24px;
+  }
+
+  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;
+  }
+`;
+
+type SavedQueryObject = {
+  id: number;
+  label: string;
+  sql: string;
+};
+
+interface SavedQueryPreviewModalProps {
+  fetchData: (id: number) => {};
+  onHide: () => void;
+  openInSqlLab: (id: number) => {};
+  queries: Array<SavedQueryObject>;
+  savedQuery: SavedQueryObject;
+  show: boolean;
+}
+
+const SavedQueryPreviewModal: FunctionComponent<SavedQueryPreviewModalProps> = ({
+  fetchData,
+  onHide,
+  openInSqlLab,
+  queries,
+  savedQuery,
+  show,
+}) => {
+  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();
+  });
+
+  return (
+    <div role="none" onKeyUp={handleKeyPress}>
+      <StyledModal
+        onHide={onHide}
+        show={show}
+        title={t('Query Preview')}
+        footer={[
+          <Button
+            data-test="previous-saved-query"
+            key="previous-saved-query"
+            disabled={disbalePrevious}
+            onClick={() => handleDataChange(true)}
+          >
+            {t('Previous')}
+          </Button>,
+          <Button
+            data-test="next-saved-query"
+            key="next-saved-query"
+            disabled={disbaleNext}
+            onClick={() => handleDataChange(false)}
+          >
+            {t('Next')}
+          </Button>,
+          <Button
+            data-test="open-in-sql-lab"
+            key="open-in-sql-lab"
+            buttonStyle="primary"
+            onClick={() => openInSqlLab(savedQuery.id)}
+          >
+            {t('Open in SQL Lab')}
+          </Button>,
+        ]}
+      >
+        <QueryTitle>query name</QueryTitle>
+        <QueryLabel>{savedQuery.label}</QueryLabel>
+        <SyntaxHighlighter language="sql" style={github}>
+          {savedQuery.sql}
+        </SyntaxHighlighter>
+      </StyledModal>
+    </div>
+  );
+};
+
+export default withToasts(SavedQueryPreviewModal);