[datasets] new, listview (react) (#9197)

* [datasets] new, react listview

* add hidden columns to support filtering by columns not rendered

* throw exception if config is incorrect

* fix database filter

* update endpoints to point to datasets; fix translation strings

* move Link into src/components

* add add new record button to datasets listview
This commit is contained in:
ʈᵃᵢ
2020-03-13 12:35:00 -07:00
committed by GitHub
parent f80fadff0e
commit 5767fb15cd
21 changed files with 766 additions and 128 deletions

View File

@@ -17,11 +17,12 @@
* under the License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { mount, shallow } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { MenuItem, Pagination } from 'react-bootstrap';
import ListView from 'src/components/ListView/ListView';
import { areArraysShallowEqual } from 'src/reduxUtils';
describe('ListView', () => {
const mockedProps = {
@@ -53,10 +54,6 @@ describe('ListView', () => {
pageSize: 1,
fetchData: jest.fn(() => []),
loading: false,
filterTypes: {
id: [],
name: [{ name: 'sw', label: 'Starts With' }],
},
bulkActions: [{ name: 'do something', onSelect: jest.fn() }],
};
const wrapper = mount(<ListView {...mockedProps} />);
@@ -71,15 +68,15 @@ describe('ListView', () => {
it('calls fetchData on mount', () => {
expect(wrapper.find(ListView)).toHaveLength(1);
expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"filters": Array [],
"pageIndex": 0,
"pageSize": 1,
"sortBy": Array [],
},
]
`);
Array [
Object {
"filters": Array [],
"pageIndex": 0,
"pageSize": 1,
"sortBy": Array [],
},
]
`);
});
it('calls fetchData on sort', () => {
@@ -90,20 +87,20 @@ describe('ListView', () => {
expect(mockedProps.fetchData).toHaveBeenCalled();
expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"filters": Array [],
"pageIndex": 0,
"pageSize": 1,
"sortBy": Array [
Object {
"desc": false,
"id": "id",
},
],
},
]
`);
Array [
Object {
"filters": Array [],
"pageIndex": 0,
"pageSize": 1,
"sortBy": Array [
Object {
"desc": false,
"id": "id",
},
],
},
]
`);
});
it('calls fetchData on filter', () => {
@@ -140,27 +137,27 @@ describe('ListView', () => {
wrapper.update();
expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"filters": Array [
Object {
"Header": "name",
"id": "name",
"operator": "sw",
"value": "foo",
},
],
"pageIndex": 0,
"pageSize": 1,
"sortBy": Array [
Object {
"desc": false,
"id": "id",
},
],
},
]
`);
Array [
Object {
"filters": Array [
Object {
"Header": "name",
"id": "name",
"operator": "sw",
"value": "foo",
},
],
"pageIndex": 0,
"pageSize": 1,
"sortBy": Array [
Object {
"desc": false,
"id": "id",
},
],
},
]
`);
});
it('calls fetchData on page change', () => {
@@ -170,27 +167,27 @@ describe('ListView', () => {
wrapper.update();
expect(mockedProps.fetchData.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"filters": Array [
Object {
"Header": "name",
"id": "name",
"operator": "sw",
"value": "foo",
},
],
"pageIndex": 1,
"pageSize": 1,
"sortBy": Array [
Object {
"desc": false,
"id": "id",
},
],
},
]
`);
Array [
Object {
"filters": Array [
Object {
"Header": "name",
"id": "name",
"operator": "sw",
"value": "foo",
},
],
"pageIndex": 1,
"pageSize": 1,
"sortBy": Array [
Object {
"desc": false,
"id": "id",
},
],
},
]
`);
});
it('handles bulk actions on 1 row', () => {
act(() => {
@@ -212,6 +209,39 @@ describe('ListView', () => {
.last()
.props();
bulkActionsProps.onSelect(bulkActionsProps.eventKey);
expect(mockedProps.bulkActions[0].onSelect.mock.calls[0])
.toMatchInlineSnapshot(`
Array [
Array [
Object {
"id": 1,
"name": "data 1",
},
],
]
`);
});
it('handles bulk actions on all rows', () => {
act(() => {
wrapper
.find('input[title="Toggle All Rows Selected"]')
.at(0)
.prop('onChange')({ target: { value: 'on' } });
wrapper
.find('.dropdown-toggle')
.children('button')
.at(1)
.props()
.onClick();
});
wrapper.update();
const bulkActionsProps = wrapper
.find(MenuItem)
.last()
.props();
bulkActionsProps.onSelect(bulkActionsProps.eventKey);
expect(mockedProps.bulkActions[0].onSelect.mock.calls[0])
.toMatchInlineSnapshot(`
@@ -221,45 +251,26 @@ describe('ListView', () => {
"id": 1,
"name": "data 1",
},
Object {
"id": 2,
"name": "data 2",
},
],
]
`);
});
it('handles bulk actions on all rows', () => {
act(() => {
wrapper
.find('input[title="Toggle All Rows Selected"]')
.at(0)
.prop('onChange')({ target: { value: 'on' } });
wrapper
.find('.dropdown-toggle')
.children('button')
.at(1)
.props()
.onClick();
});
wrapper.update();
const bulkActionsProps = wrapper
.find(MenuItem)
.last()
.props();
bulkActionsProps.onSelect(bulkActionsProps.eventKey);
expect(mockedProps.bulkActions[0].onSelect.mock.calls[0])
.toMatchInlineSnapshot(`
Array [
Array [
Object {
"id": 1,
"name": "data 1",
},
Object {
"id": 2,
"name": "data 2",
},
],
]
`);
it('Throws an exception if filter missing in columns', () => {
expect.assertions(1);
const props = {
...mockedProps,
filters: [...mockedProps.filters, { id: 'some_column' }],
};
try {
shallow(<ListView {...props} />);
} catch (e) {
expect(e).toMatchInlineSnapshot(
`[ListViewError: Invalid filter config, some_column is not present in columns]`,
);
}
});
});

View File

@@ -19,7 +19,7 @@
import React from 'react';
import { shallow } from 'enzyme';
import Link from '../../../src/SqlLab/components/Link';
import Link from '../../../src/components/Link';
describe('Link', () => {
const mockedProps = {

View File

@@ -19,7 +19,7 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import Link from '../../../src/SqlLab/components/Link';
import Link from '../../../src/components/Link';
import TableElement from '../../../src/SqlLab/components/TableElement';
import ColumnElement from '../../../src/SqlLab/components/ColumnElement';
import { mockedActions, table } from './fixtures';

View File

@@ -30,6 +30,7 @@ const mockStore = configureStore([thunk]);
const store = mockStore({});
const chartsInfoEndpoint = 'glob:*/api/v1/chart/_info*';
const chartssOwnersEndpoint = 'glob:*/api/v1/chart/related/owners*';
const chartsEndpoint = 'glob:*/api/v1/chart/?*';
const mockCharts = [...new Array(3)].map((_, i) => ({
@@ -43,7 +44,16 @@ const mockCharts = [...new Array(3)].map((_, i) => ({
fetchMock.get(chartsInfoEndpoint, {
permissions: ['can_list', 'can_edit'],
filters: [],
filters: {
slice_name: [],
description: [],
viz_type: [],
datasource_name: [],
owners: [],
},
});
fetchMock.get(chartssOwnersEndpoint, {
result: [],
});
fetchMock.get(chartsEndpoint, {
result: mockCharts,
@@ -69,6 +79,11 @@ describe('ChartList', () => {
expect(callsI).toHaveLength(1);
});
it('fetches owners', () => {
const callsO = fetchMock.calls(/chart\/related\/owners/);
expect(callsO).toHaveLength(1);
});
it('fetches data', () => {
wrapper.update();
const callsD = fetchMock.calls(/chart\/\?q/);

View File

@@ -30,6 +30,7 @@ const mockStore = configureStore([thunk]);
const store = mockStore({});
const dashboardsInfoEndpoint = 'glob:*/api/v1/dashboard/_info*';
const dashboardOwnersEndpoint = 'glob:*/api/v1/dashboard/related/owners*';
const dashboardsEndpoint = 'glob:*/api/v1/dashboard/?*';
const mockDashboards = [...new Array(3)].map((_, i) => ({
@@ -45,7 +46,15 @@ const mockDashboards = [...new Array(3)].map((_, i) => ({
fetchMock.get(dashboardsInfoEndpoint, {
permissions: ['can_list', 'can_edit'],
filters: [],
filters: {
dashboard_title: [],
slug: [],
owners: [],
published: [],
},
});
fetchMock.get(dashboardOwnersEndpoint, {
result: [],
});
fetchMock.get(dashboardsEndpoint, {
result: mockDashboards,
@@ -71,6 +80,11 @@ describe('DashboardList', () => {
expect(callsI).toHaveLength(1);
});
it('fetches owners', () => {
const callsO = fetchMock.calls(/dashboard\/related\/owners/);
expect(callsO).toHaveLength(1);
});
it('fetches data', () => {
wrapper.update();
const callsD = fetchMock.calls(/dashboard\/\?q/);

View File

@@ -0,0 +1,98 @@
/**
* 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.
*/
import React from 'react';
import { mount } from 'enzyme';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import fetchMock from 'fetch-mock';
import DatasetList from 'src/views/datasetList/DatasetList';
import ListView from 'src/components/ListView/ListView';
// store needed for withToasts(datasetTable)
const mockStore = configureStore([thunk]);
const store = mockStore({});
const datasetsInfoEndpoint = 'glob:*/api/v1/dataset/_info*';
const datasetsOwnersEndpoint = 'glob:*/api/v1/dataset/related/owners*';
const datasetsEndpoint = 'glob:*/api/v1/dataset/?*';
const mockdatasets = [...new Array(3)].map((_, i) => ({
changed_by_name: 'user',
changed_by_url: 'changed_by_url',
changed_by: 'user',
changed_on: new Date().toISOString(),
database_name: `db ${i}`,
explore_url: `/explore/table/${i}`,
id: i,
schema: `schema ${i}`,
table_name: `coolest table ${i}`,
}));
fetchMock.get(datasetsInfoEndpoint, {
permissions: ['can_list', 'can_edit'],
filters: {
database: [],
schema: [],
table_name: [],
owners: [],
is_sqllab_view: [],
},
});
fetchMock.get(datasetsOwnersEndpoint, {
result: [],
});
fetchMock.get(datasetsEndpoint, {
result: mockdatasets,
dataset_count: 3,
});
describe('DatasetList', () => {
const mockedProps = {};
const wrapper = mount(<DatasetList {...mockedProps} />, {
context: { store },
});
it('renders', () => {
expect(wrapper.find(DatasetList)).toHaveLength(1);
});
it('renders a ListView', () => {
expect(wrapper.find(ListView)).toHaveLength(1);
});
it('fetches info', () => {
const callsI = fetchMock.calls(/dataset\/_info/);
expect(callsI).toHaveLength(1);
});
it('fetches owners', () => {
const callsO = fetchMock.calls(/dataset\/related\/owners/);
expect(callsO).toHaveLength(1);
});
it('fetches data', () => {
wrapper.update();
const callsD = fetchMock.calls(/dataset\/\?q/);
expect(callsD).toHaveLength(1);
expect(callsD[0][0]).toMatchInlineSnapshot(
`"/http//localhost/api/v1/dataset/?q={%22order_column%22:%22changed_on%22,%22order_direction%22:%22desc%22,%22page%22:0,%22page_size%22:25}"`,
);
});
});

View File

@@ -23,7 +23,7 @@ import { Table } from 'reactable-arc';
import { Label, ProgressBar, Well } from 'react-bootstrap';
import { t } from '@superset-ui/translation';
import Link from './Link';
import Link from '../../components/Link';
import ResultSet from './ResultSet';
import ModalTrigger from '../../components/ModalTrigger';
import HighlightedSql from './HighlightedSql';

View File

@@ -26,7 +26,7 @@ import github from 'react-syntax-highlighter/dist/styles/hljs/github';
import { t } from '@superset-ui/translation';
import Link from './Link';
import Link from '../../components/Link';
import ModalTrigger from '../../components/ModalTrigger';
registerLanguage('sql', sql);

View File

@@ -23,7 +23,7 @@ import shortid from 'shortid';
import { t } from '@superset-ui/translation';
import CopyToClipboard from '../../components/CopyToClipboard';
import Link from './Link';
import Link from '../../components/Link';
import ColumnElement from './ColumnElement';
import ShowSQL from './ShowSQL';
import ModalTrigger from '../../components/ModalTrigger';

View File

@@ -21,13 +21,13 @@ import React, { ReactNode } from 'react';
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
interface Props {
children: ReactNode;
className: string;
href: string;
onClick: () => void;
placement: string;
style: object;
tooltip: string | null;
children?: ReactNode;
className?: string;
href?: string;
onClick?: () => void;
placement?: string;
style?: object;
tooltip?: string | null;
}
const Link = ({

View File

@@ -45,6 +45,7 @@ import {
import {
convertFilters,
extractInputValue,
ListViewError,
removeFromList,
useListViewState,
} from './utils';
@@ -122,6 +123,19 @@ const ListView: FunctionComponent<Props> = ({
initialSort,
});
const filterable = Boolean(filters.length);
if (filterable) {
const columnAccessors = columns.reduce(
(acc, col) => ({ ...acc, [col.accessor || col.id]: true }),
{},
);
filters.forEach(f => {
if (!columnAccessors[f.id]) {
throw new ListViewError(
`Invalid filter config, ${f.id} is not present in columns`,
);
}
});
}
const removeFilterAndApply = (index: number) => {
const updated = removeFromList(internalFilters, index);

View File

@@ -44,7 +44,9 @@ export default function TableCollection({
{headerGroup.headers.map(column =>
column.hidden ? null : (
<th
{...column.getHeaderProps(column.getSortByToggleProps())}
{...column.getHeaderProps(
column.sortable ? column.getSortByToggleProps() : {},
)}
data-test="sort-header"
>
{column.render('Header')}

View File

@@ -35,6 +35,10 @@ import {
import { FetchDataConfig, InternalFilter, SortColumn } from './types';
export class ListViewError extends Error {
name = 'ListViewError';
}
// removes element from a list, returns new list
export function removeFromList(list: any[], index: number): any[] {
return list.filter((_, i) => index !== i);

View File

@@ -252,11 +252,11 @@ class ChartList extends React.PureComponent<Props, State> {
if (lastFetchDataConfig) {
this.fetchData(lastFetchDataConfig);
}
this.props.addSuccessToast(t('Deleted: %(slice_name)', sliceName));
this.props.addSuccessToast(t('Deleted: %s', sliceName));
},
() => {
this.props.addDangerToast(
t('There was an issue deleting: %(slice_name)', sliceName),
t('There was an issue deleting: %s', sliceName),
);
},
);

View File

@@ -267,12 +267,12 @@ class DashboardList extends React.PureComponent<Props, State> {
if (lastFetchDataConfig) {
this.fetchData(lastFetchDataConfig);
}
this.props.addSuccessToast(`${t('Deleted')} ${dashboardTitle}`);
this.props.addSuccessToast(t('Deleted: %s', dashboardTitle));
},
(err: any) => {
console.error(err);
this.props.addDangerToast(
`${t('There was an issue deleting')}${dashboardTitle}`,
t('There was an issue deleting %s', dashboardTitle),
);
},
);

View File

@@ -0,0 +1,448 @@
/**
* 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.
*/
import { SupersetClient } from '@superset-ui/connection';
import { t } from '@superset-ui/translation';
import moment from 'moment';
import PropTypes from 'prop-types';
import React from 'react';
// @ts-ignore
import { Panel } from 'react-bootstrap';
import Link from 'src/components/Link';
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
import ListView from 'src/components/ListView/ListView';
import {
FetchDataConfig,
FilterOperatorMap,
Filters,
} from 'src/components/ListView/types';
import withToasts from 'src/messageToasts/enhancers/withToasts';
const PAGE_SIZE = 25;
interface Props {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
}
interface State {
datasets: any[];
datasetCount: number;
loading: boolean;
filterOperators: FilterOperatorMap;
filters: Filters;
owners: Array<{ text: string; value: number }>;
databases: Array<{ text: string; value: number }>;
permissions: string[];
lastFetchDataConfig: FetchDataConfig | null;
}
interface Dataset {
changed_by_name: string;
changed_by_url: string;
changed_by: string;
changed_on: string;
databse_name: string;
explore_url: string;
id: number;
schema: string;
table_name: string;
}
class DatasetList extends React.PureComponent<Props, State> {
static propTypes = {
addDangerToast: PropTypes.func.isRequired,
};
state: State = {
datasetCount: 0,
datasets: [],
filterOperators: {},
filters: [],
lastFetchDataConfig: null,
loading: false,
owners: [],
databases: [],
permissions: [],
};
componentDidMount() {
Promise.all([
SupersetClient.get({
endpoint: `/api/v1/dataset/_info`,
}),
SupersetClient.get({
endpoint: `/api/v1/dataset/related/owners`,
}),
SupersetClient.get({
endpoint: `/api/v1/dataset/related/database`,
}),
]).then(
([
{ json: infoJson = {} },
{ json: ownersJson = {} },
{ json: databasesJson = {} },
]) => {
this.setState(
{
filterOperators: infoJson.filters,
owners: ownersJson.result,
databases: databasesJson.result,
permissions: infoJson.permissions,
},
this.updateFilters,
);
},
([e1, e2]) => {
this.props.addDangerToast(
t('An error occurred while fetching Datasets'),
);
if (e1) {
console.error(e1);
}
if (e2) {
console.error(e2);
}
},
);
}
get canEdit() {
return this.hasPerm('can_edit');
}
get canDelete() {
return this.hasPerm('can_delete');
}
get canCreate() {
return this.hasPerm('can_add');
}
initialSort = [{ id: 'changed_on', desc: true }];
columns = [
{
Cell: ({
row: {
original: { explore_url: exploreUrl, table_name: datasetTitle },
},
}: any) => <a href={exploreUrl}>{datasetTitle}</a>,
Header: t('Table'),
accessor: 'table_name',
},
{
Header: t('Databse'),
accessor: 'database_name',
},
{
Cell: ({
row: {
original: {
changed_by_name: changedByName,
changed_by_url: changedByUrl,
},
},
}: any) => <a href={changedByUrl}>{changedByName}</a>,
Header: t('Changed By'),
accessor: 'changed_by_fk',
},
{
Cell: ({
row: {
original: { changed_on: changedOn },
},
}: any) => <span className="no-wrap">{moment(changedOn).fromNow()}</span>,
Header: t('Modified'),
accessor: 'changed_on',
sortable: true,
},
{
accessor: 'database',
hidden: true,
},
{
accessor: 'schema',
hidden: true,
},
{
accessor: 'owners',
hidden: true,
},
{
accessor: 'is_sqllab_view',
hidden: true,
},
{
Cell: ({ row: { state, original } }: any) => {
const handleDelete = () => this.handleDatasetDelete(original);
const handleEdit = () => this.handleDatasetEdit(original);
if (!this.canEdit && !this.canDelete) {
return null;
}
return (
<span
className={`actions ${state && state.hover ? '' : 'invisible'}`}
>
{this.canDelete && (
<ConfirmStatusChange
title={t('Please Confirm')}
description={
<>
{t('Are you sure you want to delete ')}{' '}
<b>{original.table_name}</b>?
</>
}
onConfirm={handleDelete}
>
{confirmDelete => (
<span
role="button"
tabIndex={0}
className="action-button"
onClick={confirmDelete}
>
<i className="fa fa-trash" />
</span>
)}
</ConfirmStatusChange>
)}
{this.canEdit && (
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleEdit}
>
<i className="fa fa-pencil" />
</span>
)}
</span>
);
},
Header: t('Actions'),
id: 'actions',
},
];
hasPerm = (perm: string) => {
if (!this.state.permissions.length) {
return false;
}
return Boolean(this.state.permissions.find(p => p === perm));
};
handleDatasetEdit = ({ id }: { id: number }) => {
window.location.assign(`/tablemodelview/edit/${id}`);
};
handleDatasetDelete = ({ id, table_name: tableName }: Dataset) =>
SupersetClient.delete({
endpoint: `/api/v1/dataset/${id}`,
}).then(
() => {
const { lastFetchDataConfig } = this.state;
if (lastFetchDataConfig) {
this.fetchData(lastFetchDataConfig);
}
this.props.addSuccessToast(t('Deleted: %s', tableName));
},
(err: any) => {
console.error(err);
this.props.addDangerToast(
t('There was an issue deleting %s', tableName),
);
},
);
handleBulkDatasetDelete = (datasets: Dataset[]) => {
SupersetClient.delete({
endpoint: `/api/v1/dataset/?q=!(${datasets
.map(({ id }) => id)
.join(',')})`,
}).then(
({ json = {} }) => {
const { lastFetchDataConfig } = this.state;
if (lastFetchDataConfig) {
this.fetchData(lastFetchDataConfig);
}
this.props.addSuccessToast(json.message);
},
(err: any) => {
console.error(err);
this.props.addDangerToast(
t('There was an issue deleting the selected datasets'),
);
},
);
};
fetchData = ({ pageIndex, pageSize, sortBy, filters }: FetchDataConfig) => {
// set loading state, cache the last config for fetching data in this component.
this.setState({
lastFetchDataConfig: {
filters,
pageIndex,
pageSize,
sortBy,
},
loading: true,
});
const filterExps = filters.map(({ id: col, operator: opr, value }) => ({
col,
opr,
value,
}));
const queryParams = JSON.stringify({
order_column: sortBy[0].id,
order_direction: sortBy[0].desc ? 'desc' : 'asc',
page: pageIndex,
page_size: pageSize,
...(filterExps.length ? { filters: filterExps } : {}),
});
return SupersetClient.get({
endpoint: `/api/v1/dataset/?q=${queryParams}`,
})
.then(({ json = {} }) => {
this.setState({ datasets: json.result, datasetCount: json.count });
})
.catch(() => {
this.props.addDangerToast(
t('An error occurred while fetching Datasets'),
);
})
.finally(() => {
this.setState({ loading: false });
});
};
updateFilters = () => {
const { filterOperators, owners, databases } = this.state;
const convertFilter = ({
name: label,
operator,
}: {
name: string;
operator: string;
}) => ({ label, value: operator });
this.setState({
filters: [
{
Header: 'Database',
id: 'database',
input: 'select',
operators: filterOperators.database.map(convertFilter),
selects: databases.map(({ text: label, value }) => ({
label,
value,
})),
},
{
Header: 'Schema',
id: 'schema',
operators: filterOperators.schema.map(convertFilter),
},
{
Header: 'Table Name',
id: 'table_name',
operators: filterOperators.table_name.map(convertFilter),
},
{
Header: 'Owners',
id: 'owners',
input: 'select',
operators: filterOperators.owners.map(convertFilter),
selects: owners.map(({ text: label, value }) => ({ label, value })),
},
{
Header: 'SQL Lab View',
id: 'is_sqllab_view',
input: 'checkbox',
operators: filterOperators.is_sqllab_view.map(convertFilter),
},
],
});
};
render() {
const { datasets, datasetCount, loading, filters } = this.state;
return (
<div className="container welcome">
<Panel>
<ConfirmStatusChange
title={t('Please confirm')}
description={t(
'Are you sure you want to delete the selected datasets?',
)}
onConfirm={this.handleBulkDatasetDelete}
>
{confirmDelete => {
const bulkActions = [];
if (this.canDelete) {
bulkActions.push({
key: 'delete',
name: (
<>
<i className="fa fa-trash" /> Delete
</>
),
onSelect: confirmDelete,
});
}
return (
<>
{this.canCreate && (
<span className="list-add-action">
<Link
className="btn btn-sm btn-primary pull-right"
href="/tablemodelview/add"
tooltip="Add a new record"
>
<i className="fa fa-plus" />
</Link>
</span>
)}
<ListView
className="dataset-list-view"
title={'Datasets'}
columns={this.columns}
data={datasets}
count={datasetCount}
pageSize={PAGE_SIZE}
fetchData={this.fetchData}
loading={loading}
initialSort={this.initialSort}
filters={filters}
bulkActions={bulkActions}
/>
</>
);
}}
</ConfirmStatusChange>
</Panel>
</div>
);
}
}
export default withToasts(DatasetList);

View File

@@ -26,6 +26,7 @@ import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
import Menu from 'src/components/Menu/Menu';
import DashboardList from 'src/views/dashboardList/DashboardList';
import ChartList from 'src/views/chartList/ChartList';
import DatasetList from 'src/views/datasetList/DatasetList';
import messageToastReducer from '../messageToasts/reducers';
import { initEnhancer } from '../reduxUtils';
@@ -62,6 +63,9 @@ const App = () => (
<Route path="/chart/list/">
<ChartList user={user} />
</Route>
<Route path="/tablemodelview/list/">
<DatasetList user={user} />
</Route>
</Switch>
<ToastPresenter />
</Router>

View File

@@ -440,6 +440,18 @@ class SqlaTable(Model, BaseDatasource):
def __repr__(self):
return self.name
@property
def changed_by_name(self) -> str:
if not self.changed_by:
return ""
return str(self.changed_by)
@property
def changed_by_url(self) -> str:
if not self.changed_by:
return ""
return f"/superset/profile/{self.changed_by.username}"
@property
def connection(self) -> str:
return str(self.database)

View File

@@ -29,7 +29,7 @@ from flask_babel import gettext as __, lazy_gettext as _
from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms.validators import Regexp
from superset import appbuilder, db, security_manager
from superset import app, appbuilder, db, security_manager
from superset.connectors.base.views import DatasourceModelView
from superset.constants import RouteMethod
from superset.utils import core as utils
@@ -461,3 +461,11 @@ class TableModelView(DatasourceModelView, DeleteMixin, YamlExportMixin):
flash(failure_msg, "danger")
return redirect("/tablemodelview/list/")
@expose("/list/")
@has_access
def list(self):
if not app.config["ENABLE_REACT_CRUD_VIEWS"]:
return super().list()
return super().render_app_template()

View File

@@ -52,11 +52,15 @@ class DatasetRestApi(BaseSupersetModelRestApi):
include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {RouteMethod.RELATED}
list_columns = [
"database_name",
"changed_by_name",
"changed_by_url",
"changed_by.username",
"changed_on",
"table_name",
"database_name",
"explore_url",
"id",
"schema",
"table_name",
]
show_columns = [
"database.database_name",

View File

@@ -69,8 +69,12 @@ class DatasetApiTests(SupersetTestCase):
self.assertEqual(response["count"], 1)
expected_columns = [
"changed_by",
"changed_by_name",
"changed_by_url",
"changed_on",
"database_name",
"explore_url",
"id",
"schema",
"table_name",
]