From 7bccb38a60a28d08d8a29256050ffeeadaf0c591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CA=88=E1=B5=83=E1=B5=A2?= Date: Wed, 2 Sep 2020 11:48:21 -0700 Subject: [PATCH] feat: SIP-34 table list view for databases (#10705) --- .../CRUD/data/database/DatabaseList_spec.jsx | 73 ++++++ .../src/components/ConfirmStatusChange.tsx | 17 +- .../views/CRUD/data/database/DatabaseList.tsx | 218 ++++++++++++++++-- .../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(-) create mode 100644 superset-frontend/src/views/CRUD/data/database/types.ts 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 c8fb96ac45..25f0fbd4cc 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(, { 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 45ad4e94f5..06d30da070 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 fb0edbc5bc..646ed37536 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 ? : ; +} + function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { + const { + state: { + loading, + resourceCount: databaseCount, + resourceCollection: databases, + }, + hasPerm, + fetchData, + refreshData, + } = useListViewResource( + 'database', + t('database'), + addDangerToast, + ); const [databaseModalOpen, setDatabaseModalOpen] = useState(false); const [currentDatabase, setCurrentDatabase] = useState( null, ); - const [permissions, setPermissions] = useState([]); - 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: ( + + {t('AQE')} + + ), + Cell: ({ + row: { + original: { allow_run_async: allowRunAsync }, + }, + }: any) => , + size: 'md', + }, + { + accessor: 'allow_dml', + Header: ( + + {t('DML')} + + ), + Cell: ({ + row: { + original: { allow_dml: allowDML }, + }, + }: any) => , + size: 'md', + }, + { + accessor: 'allow_csv_upload', + Header: t('CSV Upload'), + Cell: ({ + row: { + original: { allow_csv_upload: allowCSVUpload }, + }, + }: any) => , + size: 'xl', + }, + { + accessor: 'expose_in_sqllab', + Header: t('Expose in SQL Lab'), + Cell: ({ + row: { + original: { expose_in_sqllab: exposeInSqllab }, + }, + }: any) => , + 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 ( + + {canDelete && ( + + {t('Are you sure you want to delete')}{' '} + {original.database_name}? + + } + onConfirm={handleDelete} + > + {confirmDelete => ( + + + + + + )} + + )} + + ); + }, + Header: t('Actions'), + id: 'actions', + disableSortBy: true, + }, + ], + [canDelete, canCreate], + ); + + const filters: Filters = []; + return ( <> @@ -96,6 +258,18 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { /* TODO: add database logic here */ }} /> + + + 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 bc4e3d114e..25137393df 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 = ({ }) => { // const [disableSave, setDisableSave] = useState(true); const [disableSave] = useState(true); - const [db, setDB] = useState(null); + const [db, setDB] = useState | null>(null); const [isHidden, setIsHidden] = useState(true); // Functions @@ -110,7 +104,7 @@ const DatabaseModal: FunctionComponent = ({ const onInputChange = (event: React.ChangeEvent) => { 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 = ({ setDB(database); } else if (!isEditMode && (!db || db.id || (isHidden && show))) { setDB({ - name: '', + database_name: '', uri: '', }); } @@ -175,7 +169,7 @@ const DatabaseModal: FunctionComponent = ({ 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 0000000000..ab6b1499f4 --- /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 17377eae00..99c6e9770d 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 f9e2c4150d..9dd5438950 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 503387af39..642f57c3d0 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 97433df75d..20a3382452 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"]},