feat: SIP-34 table list view for databases (#10705)

This commit is contained in:
ʈᵃᵢ 2020-09-02 11:48:21 -07:00 committed by GitHub
parent 5a4370012b
commit 7bccb38a60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 353 additions and 45 deletions

View File

@ -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);
});
});

View File

@ -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);
};

View File

@ -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}
/>
</>
);
}

View File

@ -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}
/>

View File

@ -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
};

View File

@ -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);
};
}

View File

@ -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()

View File

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

View File

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