diff --git a/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx b/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx index 51a0ca47556e..94ee989c5c4c 100644 --- a/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx +++ b/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx @@ -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(); @@ -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(() => { @@ -215,15 +212,15 @@ describe('ListView', () => { bulkActionsProps.onSelect(bulkActionsProps.eventKey); expect(mockedProps.bulkActions[0].onSelect.mock.calls[0]) .toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "id": 1, - "name": "data 1", - }, - ], - ] - `); + Array [ + Array [ + Object { + "id": 1, + "name": "data 1", + }, + ], + ] + `); }); it('handles bulk actions on all rows', () => { act(() => { @@ -248,18 +245,32 @@ describe('ListView', () => { 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", - }, - ], - ] - `); + 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(); + } catch (e) { + expect(e).toMatchInlineSnapshot( + `[ListViewError: Invalid filter config, some_column is not present in columns]`, + ); + } }); }); diff --git a/superset-frontend/spec/javascripts/sqllab/Link_spec.jsx b/superset-frontend/spec/javascripts/sqllab/Link_spec.jsx index 2df2eb39c6ec..6b20c154944c 100644 --- a/superset-frontend/spec/javascripts/sqllab/Link_spec.jsx +++ b/superset-frontend/spec/javascripts/sqllab/Link_spec.jsx @@ -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 = { diff --git a/superset-frontend/spec/javascripts/sqllab/TableElement_spec.jsx b/superset-frontend/spec/javascripts/sqllab/TableElement_spec.jsx index ae3bd627fd2a..98bae0ae9421 100644 --- a/superset-frontend/spec/javascripts/sqllab/TableElement_spec.jsx +++ b/superset-frontend/spec/javascripts/sqllab/TableElement_spec.jsx @@ -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'; diff --git a/superset-frontend/spec/javascripts/views/chartList/ChartList_spec.jsx b/superset-frontend/spec/javascripts/views/chartList/ChartList_spec.jsx index 07c2cc6abf81..60c8ccb1f554 100644 --- a/superset-frontend/spec/javascripts/views/chartList/ChartList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/chartList/ChartList_spec.jsx @@ -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/); diff --git a/superset-frontend/spec/javascripts/views/dashboardList/DashboardList_spec.jsx b/superset-frontend/spec/javascripts/views/dashboardList/DashboardList_spec.jsx index 51026e64812c..86dd72990df3 100644 --- a/superset-frontend/spec/javascripts/views/dashboardList/DashboardList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/dashboardList/DashboardList_spec.jsx @@ -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/); diff --git a/superset-frontend/spec/javascripts/views/datasetList/DatasetList_spec.jsx b/superset-frontend/spec/javascripts/views/datasetList/DatasetList_spec.jsx new file mode 100644 index 000000000000..b7b3e586b21d --- /dev/null +++ b/superset-frontend/spec/javascripts/views/datasetList/DatasetList_spec.jsx @@ -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(, { + 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}"`, + ); + }); +}); diff --git a/superset-frontend/src/SqlLab/components/QueryTable.jsx b/superset-frontend/src/SqlLab/components/QueryTable.jsx index 011fddee0a60..472a5a2a924e 100644 --- a/superset-frontend/src/SqlLab/components/QueryTable.jsx +++ b/superset-frontend/src/SqlLab/components/QueryTable.jsx @@ -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'; diff --git a/superset-frontend/src/SqlLab/components/ShowSQL.jsx b/superset-frontend/src/SqlLab/components/ShowSQL.jsx index 10180f76139a..9b030699a8dd 100644 --- a/superset-frontend/src/SqlLab/components/ShowSQL.jsx +++ b/superset-frontend/src/SqlLab/components/ShowSQL.jsx @@ -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); diff --git a/superset-frontend/src/SqlLab/components/TableElement.jsx b/superset-frontend/src/SqlLab/components/TableElement.jsx index c8e96a9960c9..2d11f0d14ebe 100644 --- a/superset-frontend/src/SqlLab/components/TableElement.jsx +++ b/superset-frontend/src/SqlLab/components/TableElement.jsx @@ -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'; diff --git a/superset-frontend/src/SqlLab/components/Link.tsx b/superset-frontend/src/components/Link.tsx similarity index 91% rename from superset-frontend/src/SqlLab/components/Link.tsx rename to superset-frontend/src/components/Link.tsx index 22bec9200875..913429f75daf 100644 --- a/superset-frontend/src/SqlLab/components/Link.tsx +++ b/superset-frontend/src/components/Link.tsx @@ -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 = ({ diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index e624b56fe033..aff559e0b20a 100644 --- a/superset-frontend/src/components/ListView/ListView.tsx +++ b/superset-frontend/src/components/ListView/ListView.tsx @@ -45,6 +45,7 @@ import { import { convertFilters, extractInputValue, + ListViewError, removeFromList, useListViewState, } from './utils'; @@ -122,6 +123,19 @@ const ListView: FunctionComponent = ({ 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); diff --git a/superset-frontend/src/components/ListView/TableCollection.tsx b/superset-frontend/src/components/ListView/TableCollection.tsx index 0ec2b5e66e80..126a0570be5b 100644 --- a/superset-frontend/src/components/ListView/TableCollection.tsx +++ b/superset-frontend/src/components/ListView/TableCollection.tsx @@ -44,7 +44,9 @@ export default function TableCollection({ {headerGroup.headers.map(column => column.hidden ? null : ( {column.render('Header')} diff --git a/superset-frontend/src/components/ListView/utils.ts b/superset-frontend/src/components/ListView/utils.ts index ca4a88deb9c4..d94703a301cc 100644 --- a/superset-frontend/src/components/ListView/utils.ts +++ b/superset-frontend/src/components/ListView/utils.ts @@ -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); diff --git a/superset-frontend/src/views/chartList/ChartList.tsx b/superset-frontend/src/views/chartList/ChartList.tsx index 7ff2710f4407..95f734a0f906 100644 --- a/superset-frontend/src/views/chartList/ChartList.tsx +++ b/superset-frontend/src/views/chartList/ChartList.tsx @@ -252,11 +252,11 @@ class ChartList extends React.PureComponent { 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), ); }, ); diff --git a/superset-frontend/src/views/dashboardList/DashboardList.tsx b/superset-frontend/src/views/dashboardList/DashboardList.tsx index ec29d1ba39e7..8ade11e4e4da 100644 --- a/superset-frontend/src/views/dashboardList/DashboardList.tsx +++ b/superset-frontend/src/views/dashboardList/DashboardList.tsx @@ -267,12 +267,12 @@ class DashboardList extends React.PureComponent { 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), ); }, ); diff --git a/superset-frontend/src/views/datasetList/DatasetList.tsx b/superset-frontend/src/views/datasetList/DatasetList.tsx new file mode 100644 index 000000000000..b88536fced58 --- /dev/null +++ b/superset-frontend/src/views/datasetList/DatasetList.tsx @@ -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 { + 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) => {datasetTitle}, + Header: t('Table'), + accessor: 'table_name', + }, + { + Header: t('Databse'), + accessor: 'database_name', + }, + { + Cell: ({ + row: { + original: { + changed_by_name: changedByName, + changed_by_url: changedByUrl, + }, + }, + }: any) => {changedByName}, + Header: t('Changed By'), + accessor: 'changed_by_fk', + }, + { + Cell: ({ + row: { + original: { changed_on: changedOn }, + }, + }: any) => {moment(changedOn).fromNow()}, + 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 ( + + {this.canDelete && ( + + {t('Are you sure you want to delete ')}{' '} + {original.table_name}? + + } + onConfirm={handleDelete} + > + {confirmDelete => ( + + + + )} + + )} + {this.canEdit && ( + + + + )} + + ); + }, + 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 ( +
+ + + {confirmDelete => { + const bulkActions = []; + if (this.canDelete) { + bulkActions.push({ + key: 'delete', + name: ( + <> + Delete + + ), + onSelect: confirmDelete, + }); + } + return ( + <> + {this.canCreate && ( + + + + + + )} + + + ); + }} + + +
+ ); + } +} + +export default withToasts(DatasetList); diff --git a/superset-frontend/src/welcome/App.jsx b/superset-frontend/src/welcome/App.jsx index 7abd84c5937e..4f7c92c71b07 100644 --- a/superset-frontend/src/welcome/App.jsx +++ b/superset-frontend/src/welcome/App.jsx @@ -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 = () => ( + + + diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index f249d842a99c..aaa4252e7f33 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -440,6 +440,18 @@ def make_sqla_column_compatible( 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) diff --git a/superset/connectors/sqla/views.py b/superset/connectors/sqla/views.py index 1d7d466a1645..f9aec88666ff 100644 --- a/superset/connectors/sqla/views.py +++ b/superset/connectors/sqla/views.py @@ -29,7 +29,7 @@ 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 @@ def refresh(self, tables): 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() diff --git a/superset/datasets/api.py b/superset/datasets/api.py index 64821dbfd63c..6e24ef218339 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -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", diff --git a/tests/dataset_api_tests.py b/tests/dataset_api_tests.py index 3f765b715403..847419162c2e 100644 --- a/tests/dataset_api_tests.py +++ b/tests/dataset_api_tests.py @@ -69,8 +69,12 @@ def test_get_dataset_list(self): 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", ]