Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 2 commits into from
Sep 2, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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);
});
});
17 changes: 14 additions & 3 deletions superset-frontend/src/components/ConfirmStatusChange.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down
218 changes: 196 additions & 22 deletions superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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} />
Expand All @@ -96,6 +258,18 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
/* TODO: add database logic here */
}}
/>

<ListView<DatabaseObject>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: just wondering what does <DatabaseObject> do here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It allows specifying the generic for the ListView props. This means that props like renderCard are not (data: any) => ReactNode and are instead (data: DatabaseObject) => ReactNode.

You can read more about it here: https://mariusschulz.com/articles/passing-generics-to-jsx-elements-in-typescript

className="database-list-view"
columns={columns}
count={databaseCount}
data={databases}
fetchData={fetchData}
filters={filters}
initialSort={initialSort}
loading={loading}
pageSize={PAGE_SIZE}
/>
</>
);
}
Expand Down
Loading