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/09/02 18:48:41 UTC

[incubator-superset] branch master updated: feat: SIP-34 table list view for databases (#10705)

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 7bccb38  feat: SIP-34 table list view for databases (#10705)
7bccb38 is described below

commit 7bccb38a60a28d08d8a29256050ffeeadaf0c591
Author: ʈᵃᵢ <td...@gmail.com>
AuthorDate: Wed Sep 2 11:48:21 2020 -0700

    feat: SIP-34 table list view for databases (#10705)
---
 .../views/CRUD/data/database/DatabaseList_spec.jsx |  73 +++++++
 .../src/components/ConfirmStatusChange.tsx         |  17 +-
 .../src/views/CRUD/data/database/DatabaseList.tsx  | 218 ++++++++++++++++++---
 .../src/views/CRUD/data/database/DatabaseModal.tsx |  16 +-
 .../src/views/CRUD/data/database/types.ts          |  38 ++++
 superset-frontend/src/views/CRUD/utils.tsx         |   2 +-
 superset/databases/api.py                          |  30 ++-
 tests/databases/api_tests.py                       |   3 +
 tests/sqllab_tests.py                              |   1 +
 9 files changed, 353 insertions(+), 45 deletions(-)

diff --git a/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseList_spec.jsx
index c8fb96a..25f0fbd 100644
--- a/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseList_spec.jsx
+++ b/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseList_spec.jsx
@@ -19,19 +19,57 @@
 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 DatabaseList from 'src/views/CRUD/data/database/DatabaseList';
 import DatabaseModal from 'src/views/CRUD/data/database/DatabaseModal';
 import SubMenu from 'src/components/Menu/SubMenu';
+import ListView from 'src/components/ListView';
+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 databasesInfoEndpoint = 'glob:*/api/v1/database/_info*';
+const databasesEndpoint = 'glob:*/api/v1/database/?*';
+const databaseEndpoint = 'glob:*/api/v1/database/*';
+
+const mockdatabases = [...new Array(3)].map((_, i) => ({
+  changed_by: {
+    first_name: `user`,
+    last_name: `${i}`,
+  },
+  database_name: `db ${i}`,
+  backend: 'postgresql',
+  allow_run_async: true,
+  allow_dml: false,
+  allow_csv_upload: true,
+  expose_in_sqllab: false,
+  changed_on_delta_humanized: `${i} day(s) ago`,
+  changed_on: new Date().toISOString,
+  id: i,
+}));
+
+fetchMock.get(databasesInfoEndpoint, {
+  permissions: ['can_delete'],
+});
+fetchMock.get(databasesEndpoint, {
+  result: mockdatabases,
+  database_count: 3,
+});
+
+fetchMock.delete(databaseEndpoint, {});
+
 describe('DatabaseList', () => {
   const wrapper = mount(<DatabaseList />, { context: { store } });
 
+  beforeAll(async () => {
+    await waitForComponentToPaint(wrapper);
+  });
+
   it('renders', () => {
     expect(wrapper.find(DatabaseList)).toExist();
   });
@@ -43,4 +81,39 @@ describe('DatabaseList', () => {
   it('renders a DatabaseModal', () => {
     expect(wrapper.find(DatabaseModal)).toExist();
   });
+
+  it('renders a ListView', () => {
+    expect(wrapper.find(ListView)).toExist();
+  });
+
+  it('fetches Databases', () => {
+    const callsD = fetchMock.calls(/database\/\?q/);
+    expect(callsD).toHaveLength(1);
+    expect(callsD[0][0]).toMatchInlineSnapshot(
+      `"http://localhost/api/v1/database/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`,
+    );
+  });
+
+  it('deletes', async () => {
+    act(() => {
+      wrapper.find('[data-test="database-delete"]').first().props().onClick();
+    });
+    await waitForComponentToPaint(wrapper);
+
+    act(() => {
+      wrapper
+        .find('#delete')
+        .first()
+        .props()
+        .onChange({ target: { value: 'DELETE' } });
+    });
+    await waitForComponentToPaint(wrapper);
+    act(() => {
+      wrapper.find('button').last().props().onClick();
+    });
+
+    await waitForComponentToPaint(wrapper);
+
+    expect(fetchMock.calls(/database\/0/, 'DELETE')).toHaveLength(1);
+  });
 });
diff --git a/superset-frontend/src/components/ConfirmStatusChange.tsx b/superset-frontend/src/components/ConfirmStatusChange.tsx
index 45ad4e9..06d30da 100644
--- a/superset-frontend/src/components/ConfirmStatusChange.tsx
+++ b/superset-frontend/src/components/ConfirmStatusChange.tsx
@@ -38,9 +38,20 @@ export default function ConfirmStatusChange({
 
   const showConfirm = (...callbackArgs: any[]) => {
     // check if any args are DOM events, if so, call persist
-    callbackArgs.forEach(
-      arg => arg && typeof arg.persist === 'function' && arg.persist(),
-    );
+    callbackArgs.forEach(arg => {
+      if (!arg) {
+        return;
+      }
+      if (typeof arg.persist === 'function') {
+        arg.persist();
+      }
+      if (typeof arg.preventDefault === 'function') {
+        arg.preventDefault();
+      }
+      if (typeof arg.stopPropagation === 'function') {
+        arg.stopPropagation();
+      }
+    });
     setOpen(true);
     setCurrentCallbackArgs(callbackArgs);
   };
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
index fb0edbc..646ed37 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
@@ -17,52 +17,72 @@
  * under the License.
  */
 import { SupersetClient } from '@superset-ui/connection';
+import styled from '@superset-ui/style';
 import { t } from '@superset-ui/translation';
-import React, { useEffect, useState } from 'react';
+import React, { useState, useMemo } from 'react';
+import { useListViewResource } from 'src/views/CRUD/hooks';
 import { createErrorHandler } from 'src/views/CRUD/utils';
 import withToasts from 'src/messageToasts/enhancers/withToasts';
+import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
 import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
+import TooltipWrapper from 'src/components/TooltipWrapper';
+import Icon from 'src/components/Icon';
+import ListView, { Filters } from 'src/components/ListView';
 import { commonMenuData } from 'src/views/CRUD/data/common';
-import DatabaseModal, { DatabaseObject } from './DatabaseModal';
+import DatabaseModal from './DatabaseModal';
+import { DatabaseObject } from './types';
+
+const PAGE_SIZE = 25;
 
 interface DatabaseListProps {
   addDangerToast: (msg: string) => void;
   addSuccessToast: (msg: string) => void;
 }
 
+const IconBlack = styled(Icon)`
+  color: ${({ theme }) => theme.colors.grayscale.dark1};
+`;
+
+function BooleanDisplay(value: any) {
+  return value ? <IconBlack name="check" /> : <IconBlack name="cancel-x" />;
+}
+
 function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
+  const {
+    state: {
+      loading,
+      resourceCount: databaseCount,
+      resourceCollection: databases,
+    },
+    hasPerm,
+    fetchData,
+    refreshData,
+  } = useListViewResource<DatabaseObject>(
+    'database',
+    t('database'),
+    addDangerToast,
+  );
   const [databaseModalOpen, setDatabaseModalOpen] = useState<boolean>(false);
   const [currentDatabase, setCurrentDatabase] = useState<DatabaseObject | null>(
     null,
   );
-  const [permissions, setPermissions] = useState<string[]>([]);
 
-  const fetchDatasetInfo = () => {
-    SupersetClient.get({
-      endpoint: `/api/v1/dataset/_info`,
+  function handleDatabaseDelete({ id, database_name: dbName }: DatabaseObject) {
+    SupersetClient.delete({
+      endpoint: `/api/v1/database/${id}`,
     }).then(
-      ({ json: infoJson = {} }) => {
-        setPermissions(infoJson.permissions);
+      () => {
+        refreshData();
+        addSuccessToast(t('Deleted: %s', dbName));
       },
       createErrorHandler(errMsg =>
-        addDangerToast(t('An error occurred while fetching datasets', errMsg)),
+        addDangerToast(t('There was an issue deleting %s: %s', dbName, errMsg)),
       ),
     );
-  };
-
-  useEffect(() => {
-    fetchDatasetInfo();
-  }, []);
-
-  const hasPerm = (perm: string) => {
-    if (!permissions.length) {
-      return false;
-    }
-
-    return Boolean(permissions.find(p => p === perm));
-  };
+  }
 
   const canCreate = hasPerm('can_add');
+  const canDelete = hasPerm('can_delete');
 
   const menuData: SubMenuProps = {
     activeChild: 'Databases',
@@ -85,6 +105,148 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
     };
   }
 
+  const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
+  const columns = useMemo(
+    () => [
+      {
+        accessor: 'database_name',
+        Header: t('Database'),
+      },
+      {
+        accessor: 'backend',
+        Header: t('Backend'),
+        size: 'xxl',
+        disableSortBy: true, // TODO: api support for sorting by 'backend'
+      },
+      {
+        accessor: 'allow_run_async',
+        Header: (
+          <TooltipWrapper
+            label="allow-run-async-header"
+            tooltip={t('Asynchronous Query Execution')}
+            placement="top"
+          >
+            <span>{t('AQE')}</span>
+          </TooltipWrapper>
+        ),
+        Cell: ({
+          row: {
+            original: { allow_run_async: allowRunAsync },
+          },
+        }: any) => <BooleanDisplay value={allowRunAsync} />,
+        size: 'md',
+      },
+      {
+        accessor: 'allow_dml',
+        Header: (
+          <TooltipWrapper
+            label="allow-dml-header"
+            tooltip={t('Allow Data Danipulation Language')}
+            placement="top"
+          >
+            <span>{t('DML')}</span>
+          </TooltipWrapper>
+        ),
+        Cell: ({
+          row: {
+            original: { allow_dml: allowDML },
+          },
+        }: any) => <BooleanDisplay value={allowDML} />,
+        size: 'md',
+      },
+      {
+        accessor: 'allow_csv_upload',
+        Header: t('CSV Upload'),
+        Cell: ({
+          row: {
+            original: { allow_csv_upload: allowCSVUpload },
+          },
+        }: any) => <BooleanDisplay value={allowCSVUpload} />,
+        size: 'xl',
+      },
+      {
+        accessor: 'expose_in_sqllab',
+        Header: t('Expose in SQL Lab'),
+        Cell: ({
+          row: {
+            original: { expose_in_sqllab: exposeInSqllab },
+          },
+        }: any) => <BooleanDisplay value={exposeInSqllab} />,
+        size: 'xxl',
+      },
+      {
+        accessor: 'created_by',
+        disableSortBy: true,
+        Header: t('Created By'),
+        Cell: ({
+          row: {
+            original: { created_by: createdBy },
+          },
+        }: any) =>
+          createdBy ? `${createdBy.first_name} ${createdBy.last_name}` : '',
+        size: 'xl',
+      },
+      {
+        Cell: ({
+          row: {
+            original: { changed_on_delta_humanized: changedOn },
+          },
+        }: any) => changedOn,
+        Header: t('Last Modified'),
+        accessor: 'changed_on_delta_humanized',
+        size: 'xl',
+      },
+      {
+        Cell: ({ row: { original } }: any) => {
+          const handleDelete = () => handleDatabaseDelete(original);
+          if (!canDelete) {
+            return null;
+          }
+          return (
+            <span className="actions">
+              {canDelete && (
+                <ConfirmStatusChange
+                  title={t('Please Confirm')}
+                  description={
+                    <>
+                      {t('Are you sure you want to delete')}{' '}
+                      <b>{original.database_name}</b>?
+                    </>
+                  }
+                  onConfirm={handleDelete}
+                >
+                  {confirmDelete => (
+                    <span
+                      role="button"
+                      tabIndex={0}
+                      className="action-button"
+                      data-test="database-delete"
+                      onClick={confirmDelete}
+                    >
+                      <TooltipWrapper
+                        label="delete-action"
+                        tooltip={t('Delete database')}
+                        placement="bottom"
+                      >
+                        <Icon name="trash" />
+                      </TooltipWrapper>
+                    </span>
+                  )}
+                </ConfirmStatusChange>
+              )}
+            </span>
+          );
+        },
+        Header: t('Actions'),
+        id: 'actions',
+        disableSortBy: true,
+      },
+    ],
+    [canDelete, canCreate],
+  );
+
+  const filters: Filters = [];
+
   return (
     <>
       <SubMenu {...menuData} />
@@ -96,6 +258,18 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
           /* TODO: add database logic here */
         }}
       />
+
+      <ListView<DatabaseObject>
+        className="database-list-view"
+        columns={columns}
+        count={databaseCount}
+        data={databases}
+        fetchData={fetchData}
+        filters={filters}
+        initialSort={initialSort}
+        loading={loading}
+        pageSize={PAGE_SIZE}
+      />
     </>
   );
 }
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx
index bc4e3d1..2513739 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx
@@ -23,13 +23,7 @@ import withToasts from 'src/messageToasts/enhancers/withToasts';
 import Icon from 'src/components/Icon';
 import Modal from 'src/common/components/Modal';
 import Tabs from 'src/common/components/Tabs';
-
-export type DatabaseObject = {
-  id?: number;
-  name: string;
-  uri: string;
-  // TODO: add more props
-};
+import { DatabaseObject } from './types';
 
 interface DatabaseModalProps {
   addDangerToast: (msg: string) => void;
@@ -90,7 +84,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
 }) => {
   // const [disableSave, setDisableSave] = useState(true);
   const [disableSave] = useState<boolean>(true);
-  const [db, setDB] = useState<DatabaseObject | null>(null);
+  const [db, setDB] = useState<Partial<DatabaseObject> | null>(null);
   const [isHidden, setIsHidden] = useState<boolean>(true);
 
   // Functions
@@ -110,7 +104,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
   const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
     const target = event.target;
     const data = {
-      name: db ? db.name : '',
+      database_name: db ? db.database_name : '',
       uri: db ? db.uri : '',
       ...db,
     };
@@ -130,7 +124,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
     setDB(database);
   } else if (!isEditMode && (!db || db.id || (isHidden && show))) {
     setDB({
-      name: '',
+      database_name: '',
       uri: '',
     });
   }
@@ -175,7 +169,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
               <input
                 type="text"
                 name="name"
-                value={db ? db.name : ''}
+                value={db ? db.database_name : ''}
                 placeholder={t('Name your datasource')}
                 onChange={onInputChange}
               />
diff --git a/superset-frontend/src/views/CRUD/data/database/types.ts b/superset-frontend/src/views/CRUD/data/database/types.ts
new file mode 100644
index 0000000..ab6b149
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/data/database/types.ts
@@ -0,0 +1,38 @@
+/**
+ * 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.
+ */
+type DatabaseUser = {
+  first_name: string;
+  last_name: string;
+};
+
+export type DatabaseObject = {
+  id: number;
+  database_name: string;
+  backend: string;
+  allow_run_async: boolean;
+  allow_dml: boolean;
+  allow_csv_upload: boolean;
+  expose_in_sqllab: boolean;
+  created_by: null | DatabaseUser;
+  changed_on_delta_humanized: string;
+  changed_on: string;
+
+  uri: string;
+  // TODO: add more props
+};
diff --git a/superset-frontend/src/views/CRUD/utils.tsx b/superset-frontend/src/views/CRUD/utils.tsx
index 17377ea..99c6e97 100644
--- a/superset-frontend/src/views/CRUD/utils.tsx
+++ b/superset-frontend/src/views/CRUD/utils.tsx
@@ -60,6 +60,6 @@ export function createErrorHandler(handleErrorFunc: (errMsg?: string) => void) {
   return async (e: SupersetClientResponse | string) => {
     const parsedError = await getClientErrorObject(e);
     logging.error(e);
-    handleErrorFunc(parsedError.error);
+    handleErrorFunc(parsedError.message || parsedError.error);
   };
 }
diff --git a/superset/databases/api.py b/superset/databases/api.py
index f9e2c41..9dd5438 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -87,22 +87,26 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
         "sqlalchemy_uri",
     ]
     list_columns = [
-        "id",
-        "database_name",
-        "expose_in_sqllab",
+        "allow_csv_upload",
         "allow_ctas",
         "allow_cvas",
-        "force_ctas_schema",
-        "allow_run_async",
         "allow_dml",
         "allow_multi_schema_metadata_fetch",
-        "allow_csv_upload",
-        "allows_subquery",
+        "allow_run_async",
         "allows_cost_estimate",
+        "allows_subquery",
         "allows_virtual_table_explore",
-        "explore_database_id",
         "backend",
+        "changed_on",
+        "changed_on_delta_humanized",
+        "created_by.first_name",
+        "created_by.last_name",
+        "database_name",
+        "explore_database_id",
+        "expose_in_sqllab",
+        "force_ctas_schema",
         "function_names",
+        "id",
     ]
     add_columns = [
         "database_name",
@@ -124,6 +128,16 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
     edit_columns = add_columns
 
     list_select_columns = list_columns + ["extra", "sqlalchemy_uri", "password"]
+    order_columns = [
+        "allow_csv_upload",
+        "allow_dml",
+        "allow_run_async",
+        "changed_on",
+        "changed_on_delta_humanized",
+        "created_by.first_name",
+        "database_name",
+        "expose_in_sqllab",
+    ]
     # Removes the local limit for the page size
     max_page_size = -1
     add_model_schema = DatabasePostSchema()
diff --git a/tests/databases/api_tests.py b/tests/databases/api_tests.py
index 503387a..642f57c 100644
--- a/tests/databases/api_tests.py
+++ b/tests/databases/api_tests.py
@@ -72,6 +72,9 @@ class TestDatabaseApi(SupersetTestCase):
             "allows_subquery",
             "allows_virtual_table_explore",
             "backend",
+            "changed_on",
+            "changed_on_delta_humanized",
+            "created_by",
             "database_name",
             "explore_database_id",
             "expose_in_sqllab",
diff --git a/tests/sqllab_tests.py b/tests/sqllab_tests.py
index 97433df..20a3382 100644
--- a/tests/sqllab_tests.py
+++ b/tests/sqllab_tests.py
@@ -515,6 +515,7 @@ class TestSqlLab(SupersetTestCase):
             "page_size": -1,
         }
         url = f"api/v1/database/?q={prison.dumps(arguments)}"
+
         self.assertEqual(
             {"examples", "fake_db_100", "main"},
             {r.get("database_name") for r in self.get_json_resp(url)["result"]},