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