mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
[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:
@@ -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]`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = ({
|
||||
@@ -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);
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
448
superset-frontend/src/views/datasetList/DatasetList.tsx
Normal file
448
superset-frontend/src/views/datasetList/DatasetList.tsx
Normal 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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user