diff --git a/superset-frontend/spec/javascripts/views/CRUD/chart/ChartList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/chart/ChartList_spec.jsx index d6401164af202..73dd9eea335bf 100644 --- a/superset-frontend/spec/javascripts/views/CRUD/chart/ChartList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/CRUD/chart/ChartList_spec.jsx @@ -17,18 +17,18 @@ * 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 { supersetTheme, ThemeProvider } from '@superset-ui/style'; + +import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; +import { styledMount as mount } from 'spec/helpers/theming'; import ChartList from 'src/views/CRUD/chart/ChartList'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import ListView from 'src/components/ListView'; import PropertiesModal from 'src/explore/components/PropertiesModal'; import ListViewCard from 'src/components/ListViewCard'; - // store needed for withToasts(ChartTable) const mockStore = configureStore([thunk]); const store = mockStore({}); @@ -78,8 +78,10 @@ describe('ChartList', () => { const mockedProps = {}; const wrapper = mount(, { context: { store }, - wrappingComponent: ThemeProvider, - wrappingComponentProps: { theme: supersetTheme }, + }); + + beforeAll(async () => { + await waitForComponentToPaint(wrapper); }); it('renders', () => { diff --git a/superset-frontend/spec/javascripts/views/CRUD/dashboard/DashboardList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/dashboard/DashboardList_spec.jsx index 7eb634c46fc00..fd824147ff553 100644 --- a/superset-frontend/spec/javascripts/views/CRUD/dashboard/DashboardList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/CRUD/dashboard/DashboardList_spec.jsx @@ -17,11 +17,12 @@ * 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 { supersetTheme, ThemeProvider } from '@superset-ui/style'; + +import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; +import { styledMount as mount } from 'spec/helpers/theming'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import DashboardList from 'src/views/CRUD/dashboard/DashboardList'; @@ -69,8 +70,10 @@ describe('DashboardList', () => { const mockedProps = {}; const wrapper = mount(, { context: { store }, - wrappingComponent: ThemeProvider, - wrappingComponentProps: { theme: supersetTheme }, + }); + + beforeAll(async () => { + await waitForComponentToPaint(wrapper); }); it('renders', () => { diff --git a/superset-frontend/src/views/CRUD/chart/ChartList.tsx b/superset-frontend/src/views/CRUD/chart/ChartList.tsx index fb8d905540c2c..514a93a70b2bb 100644 --- a/superset-frontend/src/views/CRUD/chart/ChartList.tsx +++ b/superset-frontend/src/views/CRUD/chart/ChartList.tsx @@ -19,15 +19,11 @@ import { SupersetClient } from '@superset-ui/connection'; import { t } from '@superset-ui/translation'; import { getChartMetadataRegistry } from '@superset-ui/chart'; -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useState, useMemo } from 'react'; import rison from 'rison'; import { uniqBy } from 'lodash'; -import { - createFetchRelated, - createErrorHandler, - createFaveStarHandlers, -} from 'src/views/CRUD/utils'; +import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils'; +import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import SubMenu from 'src/components/Menu/SubMenu'; import AvatarIcon from 'src/components/AvatarIcon'; @@ -35,7 +31,6 @@ import Icon from 'src/components/Icon'; import FaveStar from 'src/components/FaveStar'; import ListView, { ListViewProps, - FetchDataConfig, Filters, SelectOption, } from 'src/components/ListView'; @@ -49,23 +44,6 @@ import { Dropdown, Menu } from 'src/common/components'; const PAGE_SIZE = 25; const FAVESTAR_BASE_URL = '/superset/favstar/slice'; -interface Props { - addDangerToast: (msg: string) => void; - addSuccessToast: (msg: string) => void; -} - -interface State { - bulkSelectEnabled: boolean; - chartCount: number; - charts: Chart[]; - favoriteStatus: object; - lastFetchDataConfig: FetchDataConfig | null; - loading: boolean; - permissions: string[]; - // for now we need to use the Slice type defined in PropertiesModal. - // In future it would be better to have a unified Chart entity. - sliceCurrentlyEditing: Slice | null; -} const createFetchDatasets = (handleError: (err: Response) => void) => async ( filterValue = '', pageIndex?: number, @@ -102,190 +80,239 @@ const createFetchDatasets = (handleError: (err: Response) => void) => async ( } return []; }; -class ChartList extends React.PureComponent { - static propTypes = { - addDangerToast: PropTypes.func.isRequired, - }; - state: State = { - bulkSelectEnabled: false, - chartCount: 0, - charts: [], - favoriteStatus: {}, // Hash mapping dashboard id to 'isStarred' status - lastFetchDataConfig: null, - loading: true, - permissions: [], - sliceCurrentlyEditing: null, - }; +interface ChartListProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; +} + +function ChartList(props: ChartListProps) { + const { + state: { + loading, + resourceCount: chartCount, + resourceCollection: charts, + bulkSelectEnabled, + }, + setResourceCollection: setCharts, + hasPerm, + fetchData, + toggleBulkSelect, + refreshData, + } = useListViewResource('chart', t('chart'), props.addDangerToast); + const [favoriteStatusRef, fetchFaveStar, saveFaveStar] = useFavoriteStatus( + {}, + FAVESTAR_BASE_URL, + props.addDangerToast, + ); + const [ + sliceCurrentlyEditing, + setSliceCurrentlyEditing, + ] = useState(null); + + const canEdit = hasPerm('can_edit'); + const canDelete = hasPerm('can_delete'); + const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }]; + + function openChartEditModal(chart: Chart) { + setSliceCurrentlyEditing({ + slice_id: chart.id, + slice_name: chart.slice_name, + description: chart.description, + cache_timeout: chart.cache_timeout, + }); + } + + function closeChartEditModal() { + setSliceCurrentlyEditing(null); + } + + function handleChartUpdated(edits: Chart) { + // update the chart in our state with the edited info + const newCharts = charts.map(chart => + chart.id === edits.id ? { ...chart, ...edits } : chart, + ); + setCharts(newCharts); + } + + function handleChartDelete({ id, slice_name: sliceName }: Chart) { + SupersetClient.delete({ + endpoint: `/api/v1/chart/${id}`, + }).then( + () => { + refreshData(); + props.addSuccessToast(t('Deleted: %s', sliceName)); + }, + () => { + props.addDangerToast(t('There was an issue deleting: %s', sliceName)); + }, + ); + } - componentDidMount() { - SupersetClient.get({ - endpoint: `/api/v1/chart/_info`, + function handleBulkChartDelete(chartsToDelete: Chart[]) { + SupersetClient.delete({ + endpoint: `/api/v1/chart/?q=${rison.encode( + chartsToDelete.map(({ id }) => id), + )}`, }).then( - ({ json: infoJson = {} }) => { - this.setState({ - permissions: infoJson.permissions, - }); + ({ json = {} }) => { + refreshData(); + props.addSuccessToast(json.message); }, createErrorHandler(errMsg => - this.props.addDangerToast( - t('An error occurred while fetching chart info: %s', errMsg), + props.addDangerToast( + t('There was an issue deleting the selected charts: %s', errMsg), ), ), ); } - get canEdit() { - return this.hasPerm('can_edit'); - } - - get canDelete() { - return this.hasPerm('can_delete'); + function renderFaveStar(id: number) { + return ( + + ); } - initialSort = [{ id: 'changed_on_delta_humanized', desc: true }]; - - fetchMethods = createFaveStarHandlers( - FAVESTAR_BASE_URL, - this, - (message: string) => { - this.props.addDangerToast(message); - }, - ); - - columns = [ - { - Cell: ({ row: { original } }: any) => { - return ( - - ); + const columns = useMemo( + () => [ + { + Cell: ({ + row: { + original: { id }, + }, + }: any) => renderFaveStar(id), + Header: '', + id: 'favorite', + disableSortBy: true, }, - Header: '', - id: 'favorite', - disableSortBy: true, - }, - { - Cell: ({ - row: { - original: { url, slice_name: sliceName }, - }, - }: any) => {sliceName}, - Header: t('Chart'), - accessor: 'slice_name', - }, - { - Cell: ({ - row: { - original: { viz_type: vizType }, - }, - }: any) => vizType, - Header: t('Visualization Type'), - accessor: 'viz_type', - }, - { - Cell: ({ - row: { - original: { datasource_name_text: dsNameTxt, datasource_url: dsUrl }, - }, - }: any) => {dsNameTxt}, - Header: t('Datasource'), - accessor: 'datasource_name', - }, - { - Cell: ({ - row: { - original: { - changed_by_name: changedByName, - changed_by_url: changedByUrl, + { + Cell: ({ + row: { + original: { url, slice_name: sliceName }, }, - }, - }: any) => {changedByName}, - Header: t('Modified By'), - accessor: 'changed_by.first_name', - }, - { - Cell: ({ - row: { - original: { changed_on_delta_humanized: changedOn }, - }, - }: any) => {changedOn}, - Header: t('Last Modified'), - accessor: 'changed_on_delta_humanized', - }, - { - accessor: 'description', - hidden: true, - disableSortBy: true, - }, - { - accessor: 'owners', - hidden: true, - disableSortBy: true, - }, - { - accessor: 'datasource_id', - hidden: true, - disableSortBy: true, - }, - { - Cell: ({ row: { original } }: any) => { - const handleDelete = () => this.handleChartDelete(original); - const openEditModal = () => this.openChartEditModal(original); - if (!this.canEdit && !this.canDelete) { - return null; - } + }: any) => {sliceName}, + Header: t('Chart'), + accessor: 'slice_name', + }, + { + Cell: ({ + row: { + original: { viz_type: vizType }, + }, + }: any) => vizType, + Header: t('Visualization Type'), + accessor: 'viz_type', + }, + { + Cell: ({ + row: { + original: { + datasource_name_text: dsNameTxt, + datasource_url: dsUrl, + }, + }, + }: any) => {dsNameTxt}, + Header: t('Datasource'), + accessor: 'datasource_name', + }, + { + Cell: ({ + row: { + original: { + changed_by_name: changedByName, + changed_by_url: changedByUrl, + }, + }, + }: any) => {changedByName}, + Header: t('Modified By'), + accessor: 'changed_by.first_name', + }, + { + Cell: ({ + row: { + original: { changed_on_delta_humanized: changedOn }, + }, + }: any) => {changedOn}, + Header: t('Last Modified'), + accessor: 'changed_on_delta_humanized', + }, + { + accessor: 'description', + hidden: true, + disableSortBy: true, + }, + { + accessor: 'owners', + hidden: true, + disableSortBy: true, + }, + { + accessor: 'datasource_id', + hidden: true, + disableSortBy: true, + }, + { + Cell: ({ row: { original } }: any) => { + const handleDelete = () => handleChartDelete(original); + const openEditModal = () => openChartEditModal(original); + if (!canEdit && !canDelete) { + return null; + } - return ( - - {this.canDelete && ( - - {t('Are you sure you want to delete')}{' '} - {original.slice_name}? - - } - onConfirm={handleDelete} - > - {confirmDelete => ( - - - - )} - - )} - {this.canEdit && ( - - - - )} - - ); + return ( + + {canDelete && ( + + {t('Are you sure you want to delete')}{' '} + {original.slice_name}? + + } + onConfirm={handleDelete} + > + {confirmDelete => ( + + + + )} + + )} + {canEdit && ( + + + + )} + + ); + }, + Header: t('Actions'), + id: 'actions', + disableSortBy: true, }, - Header: t('Actions'), - id: 'actions', - disableSortBy: true, - }, - ]; + ], + [canEdit, canDelete, favoriteStatusRef], + ); - filters: Filters = [ + const filters: Filters = [ { Header: t('Owner'), id: 'owners', @@ -296,7 +323,7 @@ class ChartList extends React.PureComponent { 'chart', 'owners', createErrorHandler(errMsg => - this.props.addDangerToast( + props.addDangerToast( t( 'An error occurred while fetching chart dataset values: %s', errMsg, @@ -324,7 +351,7 @@ class ChartList extends React.PureComponent { unfilteredLabel: 'All', fetchSelects: createFetchDatasets( createErrorHandler(errMsg => - this.props.addDangerToast( + props.addDangerToast( t( 'An error occurred while fetching chart dataset values: %s', errMsg, @@ -342,7 +369,7 @@ class ChartList extends React.PureComponent { }, ]; - sortTypes = [ + const sortTypes = [ { desc: false, id: 'slice_name', @@ -363,139 +390,20 @@ class ChartList extends React.PureComponent { }, ]; - hasPerm = (perm: string) => { - if (!this.state.permissions.length) { - return false; - } - - return this.state.permissions.some(p => p === perm); - }; - - toggleBulkSelect = () => { - this.setState({ bulkSelectEnabled: !this.state.bulkSelectEnabled }); - }; - - openChartEditModal = (chart: Chart) => { - this.setState({ - sliceCurrentlyEditing: { - slice_id: chart.id, - slice_name: chart.slice_name, - description: chart.description, - cache_timeout: chart.cache_timeout, - }, - }); - }; - - closeChartEditModal = () => { - this.setState({ sliceCurrentlyEditing: null }); - }; - - handleChartUpdated = (edits: Chart) => { - // update the chart in our state with the edited info - const newCharts = this.state.charts.map(chart => - chart.id === edits.id ? { ...chart, ...edits } : chart, - ); - this.setState({ - charts: newCharts, - }); - }; - - handleChartDelete = ({ id, slice_name: sliceName }: Chart) => { - SupersetClient.delete({ - endpoint: `/api/v1/chart/${id}`, - }).then( - () => { - const { lastFetchDataConfig } = this.state; - if (lastFetchDataConfig) { - this.fetchData(lastFetchDataConfig); - } - this.props.addSuccessToast(t('Deleted: %s', sliceName)); - }, - () => { - this.props.addDangerToast( - t('There was an issue deleting: %s', sliceName), - ); - }, - ); - }; - - handleBulkChartDelete = (charts: Chart[]) => { - SupersetClient.delete({ - endpoint: `/api/v1/chart/?q=${rison.encode(charts.map(({ id }) => id))}`, - }).then( - ({ json = {} }) => { - const { lastFetchDataConfig } = this.state; - if (lastFetchDataConfig) { - this.fetchData(lastFetchDataConfig); - } - this.props.addSuccessToast(json.message); - }, - createErrorHandler(errMsg => - this.props.addDangerToast( - t('There was an issue deleting the selected charts: %s', errMsg), - ), - ), - ); - }; - - 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 = rison.encode({ - 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/chart/?q=${queryParams}`, - }) - .then( - ({ json = {} }) => { - this.setState({ charts: json.result, chartCount: json.count }); - }, - createErrorHandler(errMsg => - this.props.addDangerToast( - t('An error occurred while fetching charts: %s', errMsg), - ), - ), - ) - .finally(() => { - this.setState({ loading: false }); - }); - }; - - renderCard = (props: Chart & { loading: boolean }) => { + function renderCard(chart: Chart & { loading: boolean }) { const menu = ( - {this.canDelete && ( + {canDelete && ( {t('Are you sure you want to delete')}{' '} - {props.slice_name}? + {chart.slice_name}? } - onConfirm={() => this.handleChartDelete(props)} + onConfirm={() => handleChartDelete(chart)} > {confirmDelete => (
{ )} - {this.canEdit && ( + {canEdit && ( this.openChartEditModal(props)} + onClick={() => openChartEditModal(chart)} > Edit @@ -524,16 +432,16 @@ class ChartList extends React.PureComponent { return ( ( + description={t('Last modified %s', chart.changed_on_delta_humanized)} + coverLeft={(chart.owners || []).slice(0, 5).map(owner => ( { /> ))} coverRight={ - + } actions={ - + {renderFaveStar(chart.id)} @@ -560,79 +461,68 @@ class ChartList extends React.PureComponent { } /> ); - }; + } - render() { - const { - bulkSelectEnabled, - charts, - chartCount, - loading, - sliceCurrentlyEditing, - } = this.state; - return ( - <> - + + {sliceCurrentlyEditing && ( + - {sliceCurrentlyEditing && ( - - )} - - {confirmDelete => { - const bulkActions: ListViewProps['bulkActions'] = this.canDelete - ? [ - { - key: 'delete', - name: t('Delete'), - onSelect: confirmDelete, - type: 'danger', - }, - ] - : []; - - return ( - - ); - }} - - - ); - } + )} + + {confirmDelete => { + const bulkActions: ListViewProps['bulkActions'] = canDelete + ? [ + { + key: 'delete', + name: t('Delete'), + onSelect: confirmDelete, + type: 'danger', + }, + ] + : []; + + return ( + + ); + }} + + + ); } export default withToasts(ChartList); diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx index 437edbbc34cc6..fded93a1bc10f 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx +++ b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx @@ -18,22 +18,14 @@ */ import { SupersetClient } from '@superset-ui/connection'; import { t } from '@superset-ui/translation'; -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useState, useMemo } from 'react'; import rison from 'rison'; -import { - createFetchRelated, - createErrorHandler, - createFaveStarHandlers, -} from 'src/views/CRUD/utils'; +import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils'; +import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import SubMenu from 'src/components/Menu/SubMenu'; import AvatarIcon from 'src/components/AvatarIcon'; -import ListView, { - ListViewProps, - FetchDataConfig, - Filters, -} from 'src/components/ListView'; +import ListView, { ListViewProps, Filters } from 'src/components/ListView'; import ExpandableList from 'src/components/ExpandableList'; import Owner from 'src/types/Owner'; import withToasts from 'src/messageToasts/enhancers/withToasts'; @@ -47,22 +39,11 @@ import { Dropdown, Menu } from 'src/common/components'; const PAGE_SIZE = 25; const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard'; -interface Props { +interface DashboardListProps { addDangerToast: (msg: string) => void; addSuccessToast: (msg: string) => void; } -interface State { - bulkSelectEnabled: boolean; - dashboardCount: number; - dashboards: Dashboard[]; - favoriteStatus: object; - dashboardToEdit: Dashboard | null; - lastFetchDataConfig: FetchDataConfig | null; - loading: boolean; - permissions: string[]; -} - interface Dashboard { changed_by_name: string; changed_by_url: string; @@ -76,157 +57,266 @@ interface Dashboard { owners: Owner[]; } -class DashboardList extends React.PureComponent { - static propTypes = { - addDangerToast: PropTypes.func.isRequired, - }; +function DashboardList(props: DashboardListProps) { + const { + state: { + loading, + resourceCount: dashboardCount, + resourceCollection: dashboards, + bulkSelectEnabled, + }, + setResourceCollection: setDashboards, + hasPerm, + fetchData, + toggleBulkSelect, + refreshData, + } = useListViewResource( + 'dashboard', + t('dashboard'), + props.addDangerToast, + ); + const [favoriteStatusRef, fetchFaveStar, saveFaveStar] = useFavoriteStatus( + {}, + FAVESTAR_BASE_URL, + props.addDangerToast, + ); + + const [dashboardToEdit, setDashboardToEdit] = useState( + null, + ); - state: State = { - bulkSelectEnabled: false, - dashboardCount: 0, - dashboards: [], - favoriteStatus: {}, // Hash mapping dashboard id to 'isStarred' status - dashboardToEdit: null, - lastFetchDataConfig: null, - loading: true, - permissions: [], - }; + const canEdit = hasPerm('can_edit'); + const canDelete = hasPerm('can_delete'); + const canExport = hasPerm('can_mulexport'); + + const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }]; + + function openDashboardEditModal(dashboard: Dashboard) { + setDashboardToEdit(dashboard); + } - componentDidMount() { - SupersetClient.get({ - endpoint: `/api/v1/dashboard/_info`, + function handleDashboardEdit(edits: Dashboard) { + return SupersetClient.get({ + endpoint: `/api/v1/dashboard/${edits.id}`, }).then( - ({ json: infoJson = {} }) => { - this.setState({ - permissions: infoJson.permissions, - }); + ({ json = {} }) => { + setDashboards( + dashboards.map(dashboard => { + if (dashboard.id === json.id) { + return json.result; + } + return dashboard; + }), + ); }, createErrorHandler(errMsg => - this.props.addDangerToast( - t('An error occurred while fetching Dashboards: %s, %s', errMsg), + props.addDangerToast( + t('An error occurred while fetching dashboards: %s', errMsg), ), ), ); } - get canEdit() { - return this.hasPerm('can_edit'); + function handleDashboardDelete({ + id, + dashboard_title: dashboardTitle, + }: Dashboard) { + return SupersetClient.delete({ + endpoint: `/api/v1/dashboard/${id}`, + }).then( + () => { + refreshData(); + props.addSuccessToast(t('Deleted: %s', dashboardTitle)); + }, + createErrorHandler(errMsg => + props.addDangerToast( + t('There was an issue deleting %s: %s', dashboardTitle, errMsg), + ), + ), + ); } - get canDelete() { - return this.hasPerm('can_delete'); + function handleBulkDashboardDelete(dashboardsToDelete: Dashboard[]) { + return SupersetClient.delete({ + endpoint: `/api/v1/dashboard/?q=${rison.encode( + dashboardsToDelete.map(({ id }) => id), + )}`, + }).then( + ({ json = {} }) => { + props.addSuccessToast(json.message); + }, + createErrorHandler(errMsg => + props.addDangerToast( + t('There was an issue deleting the selected dashboards: ', errMsg), + ), + ), + ); } - get canExport() { - return this.hasPerm('can_mulexport'); + function handleBulkDashboardExport(dashboardsToExport: Dashboard[]) { + return window.location.assign( + `/api/v1/dashboard/export/?q=${rison.encode( + dashboardsToExport.map(({ id }) => id), + )}`, + ); } - initialSort = [{ id: 'changed_on_delta_humanized', desc: true }]; - - fetchMethods = createFaveStarHandlers( - FAVESTAR_BASE_URL, - this, - (message: string) => { - this.props.addDangerToast(message); - }, - ); + function renderFaveStar(id: number) { + return ( + + ); + } - columns = [ - { - Cell: ({ row: { original } }: any) => { - return ( - [ + { + Cell: ({ + row: { + original: { id }, + }, + }: any) => renderFaveStar(id), + Header: '', + id: 'favorite', + disableSortBy: true, + }, + { + Cell: ({ + row: { + original: { url, dashboard_title: dashboardTitle }, + }, + }: any) => {dashboardTitle}, + Header: t('Title'), + accessor: 'dashboard_title', + }, + { + Cell: ({ + row: { + original: { owners }, + }, + }: any) => ( + + `${firstName} ${lastName}`, + )} + display={2} /> - ); + ), + Header: t('Owners'), + accessor: 'owners', + disableSortBy: true, }, - Header: '', - id: 'favorite', - disableSortBy: true, - }, - { - Cell: ({ - row: { - original: { url, dashboard_title: dashboardTitle }, - }, - }: any) => {dashboardTitle}, - Header: t('Title'), - accessor: 'dashboard_title', - }, - { - Cell: ({ - row: { - original: { owners }, - }, - }: any) => ( - - `${firstName} ${lastName}`, - )} - display={2} - /> - ), - Header: t('Owners'), - accessor: 'owners', - disableSortBy: true, - }, - { - Cell: ({ - row: { - original: { - changed_by_name: changedByName, - changed_by_url: changedByUrl, + { + Cell: ({ + row: { + original: { + changed_by_name: changedByName, + changed_by_url: changedByUrl, + }, }, + }: any) => {changedByName}, + Header: t('Modified By'), + accessor: 'changed_by.first_name', + }, + { + Cell: ({ + row: { + original: { published }, + }, + }: any) => ( + + {published ? : ''} + + ), + Header: t('Published'), + accessor: 'published', + }, + { + Cell: ({ + row: { + original: { changed_on_delta_humanized: changedOn }, + }, + }: any) => {changedOn}, + Header: t('Modified'), + accessor: 'changed_on_delta_humanized', + }, + { + accessor: 'slug', + hidden: true, + disableSortBy: true, + }, + { + Cell: ({ row: { original } }: any) => { + const handleDelete = () => handleDashboardDelete(original); + const handleEdit = () => openDashboardEditModal(original); + const handleExport = () => handleBulkDashboardExport([original]); + if (!canEdit && !canDelete && !canExport) { + return null; + } + return ( + + {canDelete && ( + + {t('Are you sure you want to delete')}{' '} + {original.dashboard_title}? + + } + onConfirm={handleDelete} + > + {confirmDelete => ( + + + + )} + + )} + {canExport && ( + + + + )} + {canEdit && ( + + + + )} + + ); }, - }: any) => {changedByName}, - Header: t('Modified By'), - accessor: 'changed_by.first_name', - }, - { - Cell: ({ - row: { - original: { published }, - }, - }: any) => ( - - {published ? : ''} - - ), - Header: t('Published'), - accessor: 'published', - }, - { - Cell: ({ - row: { - original: { changed_on_delta_humanized: changedOn }, - }, - }: any) => {changedOn}, - Header: t('Modified'), - accessor: 'changed_on_delta_humanized', - }, - { - accessor: 'slug', - hidden: true, - disableSortBy: true, - }, - { - Cell: ({ row: { original } }: any) => this.renderActions(original), - Header: t('Actions'), - id: 'actions', - disableSortBy: true, - }, - ]; - - toggleBulkSelect = () => { - this.setState({ bulkSelectEnabled: !this.state.bulkSelectEnabled }); - }; + Header: t('Actions'), + id: 'actions', + disableSortBy: true, + }, + ], + [canEdit, canDelete, canExport, favoriteStatusRef], + ); - filters: Filters = [ + const filters: Filters = [ { Header: 'Owner', id: 'owners', @@ -237,7 +327,7 @@ class DashboardList extends React.PureComponent { 'dashboard', 'owners', createErrorHandler(errMsg => - this.props.addDangerToast( + props.addDangerToast( t( 'An error occurred while fetching chart owner values: %s', errMsg, @@ -266,7 +356,7 @@ class DashboardList extends React.PureComponent { }, ]; - sortTypes = [ + const sortTypes = [ { desc: false, id: 'dashboard_title', @@ -287,210 +377,20 @@ class DashboardList extends React.PureComponent { }, ]; - hasPerm = (perm: string) => { - if (!this.state.permissions.length) { - return false; - } - - return Boolean(this.state.permissions.find(p => p === perm)); - }; - - openDashboardEditModal = (dashboard: Dashboard) => { - this.setState({ - dashboardToEdit: dashboard, - }); - }; - - handleDashboardEdit = (edits: any) => { - this.setState({ loading: true }); - return SupersetClient.get({ - endpoint: `/api/v1/dashboard/${edits.id}`, - }).then( - ({ json = {} }) => { - this.setState({ - dashboards: this.state.dashboards.map(dashboard => { - if (dashboard.id === json.id) { - return json.result; - } - return dashboard; - }), - loading: false, - }); - }, - createErrorHandler(errMsg => - this.props.addDangerToast( - t('An error occurred while fetching dashboards: %s', errMsg), - ), - ), - ); - }; - - handleDashboardDelete = ({ - id, - dashboard_title: dashboardTitle, - }: Dashboard) => - SupersetClient.delete({ - endpoint: `/api/v1/dashboard/${id}`, - }).then( - () => { - const { lastFetchDataConfig } = this.state; - if (lastFetchDataConfig) { - this.fetchData(lastFetchDataConfig); - } - this.props.addSuccessToast(t('Deleted: %s', dashboardTitle)); - }, - createErrorHandler(errMsg => - this.props.addDangerToast( - t('There was an issue deleting %s: %s', dashboardTitle, errMsg), - ), - ), - ); - - handleBulkDashboardDelete = (dashboards: Dashboard[]) => { - SupersetClient.delete({ - endpoint: `/api/v1/dashboard/?q=${rison.encode( - dashboards.map(({ id }) => id), - )}`, - }).then( - ({ json = {} }) => { - const { lastFetchDataConfig } = this.state; - if (lastFetchDataConfig) { - this.fetchData(lastFetchDataConfig); - } - this.props.addSuccessToast(json.message); - }, - createErrorHandler(errMsg => - this.props.addDangerToast( - t('There was an issue deleting the selected dashboards: ', errMsg), - ), - ), - ); - }; - - handleBulkDashboardExport = (dashboards: Dashboard[]) => { - return window.location.assign( - `/api/v1/dashboard/export/?q=${rison.encode( - dashboards.map(({ id }) => id), - )}`, - ); - }; - - 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 = rison.encode({ - 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/dashboard/?q=${queryParams}`, - }) - .then( - ({ json = {} }) => { - this.setState({ - dashboards: json.result, - dashboardCount: json.count, - }); - }, - createErrorHandler(errMsg => - this.props.addDangerToast( - t('An error occurred while fetching dashboards: %s', errMsg), - ), - ), - ) - .finally(() => { - this.setState({ loading: false }); - }); - }; - - renderActions(original: Dashboard) { - const handleDelete = () => this.handleDashboardDelete(original); - const handleEdit = () => this.openDashboardEditModal(original); - const handleExport = () => this.handleBulkDashboardExport([original]); - if (!this.canEdit && !this.canDelete && !this.canExport) { - return null; - } - return ( - - {this.canDelete && ( - - {t('Are you sure you want to delete')}{' '} - {original.dashboard_title}? - - } - onConfirm={handleDelete} - > - {confirmDelete => ( - - - - )} - - )} - {this.canExport && ( - - - - )} - {this.canEdit && ( - - - - )} - - ); - } - - renderCard = (props: Dashboard & { loading: boolean }) => { + function renderCard(dashboard: Dashboard & { loading: boolean }) { const menu = ( - {this.canDelete && ( + {canDelete && ( {t('Are you sure you want to delete')}{' '} - {props.dashboard_title}? + {dashboard.dashboard_title}? } - onConfirm={() => this.handleDashboardDelete(props)} + onConfirm={() => handleDashboardDelete(dashboard)} > {confirmDelete => (
{ )} - {this.canExport && ( + {canExport && ( this.handleBulkDashboardExport([props])} + onClick={() => handleBulkDashboardExport([dashboard])} > Export )} - {this.canEdit && ( + {canEdit && ( this.openDashboardEditModal(props)} + onClick={() => openDashboardEditModal(dashboard)} > Edit @@ -528,17 +428,22 @@ class DashboardList extends React.PureComponent { return ( {props.published ? 'published' : 'draft'}} - url={this.state.bulkSelectEnabled ? undefined : props.url} - imgURL={props.thumbnail_url} + loading={dashboard.loading} + title={dashboard.dashboard_title} + titleRight={ + + } + url={bulkSelectEnabled ? undefined : dashboard.url} + imgURL={dashboard.thumbnail_url} imgFallbackURL="/static/assets/images/dashboard-card-fallback.png" - description={t('Last modified %s', props.changed_on_delta_humanized)} - coverLeft={(props.owners || []).slice(0, 5).map(owner => ( + description={t( + 'Last modified %s', + dashboard.changed_on_delta_humanized, + )} + coverLeft={(dashboard.owners || []).slice(0, 5).map(owner => ( { ))} actions={ - + {renderFaveStar(dashboard.id)} @@ -562,87 +460,78 @@ class DashboardList extends React.PureComponent { } /> ); - }; + } - render() { - const { - bulkSelectEnabled, - dashboards, - dashboardCount, - loading, - dashboardToEdit, - } = this.state; - return ( - <> - + + + {confirmDelete => { + const bulkActions: ListViewProps['bulkActions'] = []; + if (canDelete) { + bulkActions.push({ + key: 'delete', + name: t('Delete'), + type: 'danger', + onSelect: confirmDelete, + }); } - /> - - {confirmDelete => { - const bulkActions: ListViewProps['bulkActions'] = []; - if (this.canDelete) { - bulkActions.push({ - key: 'delete', - name: t('Delete'), - type: 'danger', - onSelect: confirmDelete, - }); - } - if (this.canExport) { - bulkActions.push({ - key: 'export', - name: t('Export'), - type: 'primary', - onSelect: this.handleBulkDashboardExport, - }); - } - return ( - <> - {dashboardToEdit && ( - this.setState({ dashboardToEdit: null })} - onSubmit={this.handleDashboardEdit} - /> - )} - + {dashboardToEdit && ( + setDashboardToEdit(null)} + onSubmit={handleDashboardEdit} /> - - ); - }} - - - ); - } + )} + + + ); + }} + + + ); } export default withToasts(DashboardList); diff --git a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx index f7a5ab04f5aff..668739c5f72f6 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx @@ -18,22 +18,14 @@ */ import { SupersetClient } from '@superset-ui/connection'; import { t } from '@superset-ui/translation'; -import React, { - FunctionComponent, - useCallback, - useEffect, - useState, -} from 'react'; +import React, { FunctionComponent, useState, useMemo } from 'react'; import rison from 'rison'; import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils'; +import { useListViewResource } from 'src/views/CRUD/hooks'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import DatasourceModal from 'src/datasource/DatasourceModal'; import DeleteModal from 'src/components/DeleteModal'; -import ListView, { - ListViewProps, - FetchDataConfig, - Filters, -} from 'src/components/ListView'; +import ListView, { ListViewProps, Filters } from 'src/components/ListView'; import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu'; import { commonMenuData } from 'src/views/CRUD/data/common'; import AvatarIcon from 'src/components/AvatarIcon'; @@ -54,6 +46,7 @@ type Dataset = { id: string; database_name: string; }; + kind: string; explore_url: string; id: number; owners: Array; @@ -104,118 +97,31 @@ const DatasetList: FunctionComponent = ({ addDangerToast, addSuccessToast, }) => { - const [datasetCount, setDatasetCount] = useState(0); + const { + state: { + loading, + resourceCount: datasetCount, + resourceCollection: datasets, + bulkSelectEnabled, + }, + hasPerm, + fetchData, + toggleBulkSelect, + refreshData, + } = useListViewResource('dataset', t('dataset'), addDangerToast); + + const [datasetAddModalOpen, setDatasetAddModalOpen] = useState( + false, + ); + const [datasetCurrentlyDeleting, setDatasetCurrentlyDeleting] = useState< (Dataset & { chart_count: number; dashboard_count: number }) | null >(null); + const [ datasetCurrentlyEditing, setDatasetCurrentlyEditing, ] = useState(null); - const [datasets, setDatasets] = useState([]); - const [ - lastFetchDataConfig, - setLastFetchDataConfig, - ] = useState(null); - const [loading, setLoading] = useState(true); - const [permissions, setPermissions] = useState([]); - - const [datasetAddModalOpen, setDatasetAddModalOpen] = useState( - false, - ); - const [bulkSelectEnabled, setBulkSelectEnabled] = useState(false); - - const filterTypes: Filters = [ - { - Header: t('Owner'), - id: 'owners', - input: 'select', - operator: 'rel_m_m', - unfilteredLabel: 'All', - fetchSelects: createFetchRelated( - 'dataset', - 'owners', - createErrorHandler(errMsg => - t( - 'An error occurred while fetching dataset owner values: %s', - errMsg, - ), - ), - ), - paginate: true, - }, - { - Header: t('Datasource'), - id: 'database', - input: 'select', - operator: 'rel_o_m', - unfilteredLabel: 'All', - fetchSelects: createFetchRelated( - 'dataset', - 'database', - createErrorHandler(errMsg => - t( - 'An error occurred while fetching dataset datasource values: %s', - errMsg, - ), - ), - ), - paginate: true, - }, - { - Header: t('Schema'), - id: 'schema', - input: 'select', - operator: 'eq', - unfilteredLabel: 'All', - fetchSelects: createFetchSchemas(errMsg => - t('An error occurred while fetching schema values: %s', errMsg), - ), - paginate: true, - }, - { - Header: t('Type'), - id: 'is_sqllab_view', - input: 'select', - operator: 'eq', - unfilteredLabel: 'All', - selects: [ - { label: 'Virtual', value: true }, - { label: 'Physical', value: false }, - ], - }, - { - Header: t('Search'), - id: 'table_name', - input: 'search', - operator: 'ct', - }, - ]; - - const fetchDatasetInfo = () => { - SupersetClient.get({ - endpoint: `/api/v1/dataset/_info`, - }).then( - ({ json: infoJson = {} }) => { - setPermissions(infoJson.permissions); - }, - createErrorHandler(errMsg => - addDangerToast(t('An error occurred while fetching datasets', errMsg)), - ), - ); - }; - - useEffect(() => { - fetchDatasetInfo(); - }, []); - - const hasPerm = (perm: string) => { - if (!permissions.length) { - return false; - } - - return Boolean(permissions.find(p => p === perm)); - }; const canEdit = hasPerm('can_edit'); const canDelete = hasPerm('can_delete'); @@ -258,187 +164,260 @@ const DatasetList: FunctionComponent = ({ ), ); - const columns = [ - { - Cell: ({ - row: { - original: { kind }, - }, - }: any) => { - if (kind === 'physical') + const columns = useMemo( + () => [ + { + Cell: ({ + row: { + original: { kind }, + }, + }: any) => { + if (kind === 'physical') + return ( + + + + ); + return ( - + ); - - return ( - - - - ); - }, - accessor: 'kind_icon', - disableSortBy: true, - size: 'xs', - }, - { - Cell: ({ - row: { - original: { table_name: datasetTitle }, - }, - }: any) => datasetTitle, - Header: t('Name'), - accessor: 'table_name', - }, - { - Cell: ({ - row: { - original: { kind }, }, - }: any) => kind[0]?.toUpperCase() + kind.slice(1), - Header: t('Type'), - accessor: 'kind', - disableSortBy: true, - size: 'md', - }, - { - Header: t('Source'), - accessor: 'database.database_name', - size: 'lg', - }, - { - Header: t('Schema'), - accessor: 'schema', - size: 'lg', - }, - { - Cell: ({ - row: { - original: { changed_on_delta_humanized: changedOn }, - }, - }: any) => {changedOn}, - Header: t('Modified'), - accessor: 'changed_on_delta_humanized', - size: 'xl', - }, - { - Cell: ({ - row: { - original: { changed_by_name: changedByName }, - }, - }: any) => changedByName, - Header: t('Modified By'), - accessor: 'changed_by.first_name', - size: 'xl', - }, - { - accessor: 'database', - disableSortBy: true, - hidden: true, - }, - { - Cell: ({ - row: { - original: { owners, table_name: tableName }, + accessor: 'kind_icon', + disableSortBy: true, + size: 'xs', + }, + { + Cell: ({ + row: { + original: { table_name: datasetTitle }, + }, + }: any) => datasetTitle, + Header: t('Name'), + accessor: 'table_name', + }, + { + Cell: ({ + row: { + original: { kind }, + }, + }: any) => kind[0]?.toUpperCase() + kind.slice(1), + Header: t('Type'), + accessor: 'kind', + disableSortBy: true, + size: 'md', + }, + { + Header: t('Source'), + accessor: 'database.database_name', + size: 'lg', + }, + { + Header: t('Schema'), + accessor: 'schema', + size: 'lg', + }, + { + Cell: ({ + row: { + original: { changed_on_delta_humanized: changedOn }, + }, + }: any) => {changedOn}, + Header: t('Modified'), + accessor: 'changed_on_delta_humanized', + size: 'xl', + }, + { + Cell: ({ + row: { + original: { changed_by_name: changedByName }, + }, + }: any) => changedByName, + Header: t('Modified By'), + accessor: 'changed_by.first_name', + size: 'xl', + }, + { + accessor: 'database', + disableSortBy: true, + hidden: true, + }, + { + Cell: ({ + row: { + original: { owners, table_name: tableName }, + }, + }: any) => { + if (!owners) { + return null; + } + return owners + .slice(0, 5) + .map((owner: Owner) => ( + + )); }, - }: any) => { - if (!owners) { - return null; - } - return owners - .slice(0, 5) - .map((owner: Owner) => ( - - )); + Header: t('Owners'), + id: 'owners', + disableSortBy: true, + size: 'lg', }, - Header: t('Owners'), - id: 'owners', - disableSortBy: true, - size: 'lg', - }, - { - accessor: 'is_sqllab_view', - hidden: true, - disableSortBy: true, - }, - { - Cell: ({ row: { original } }: any) => { - const handleEdit = () => openDatasetEditModal(original); - const handleDelete = () => openDatasetDeleteModal(original); - if (!canEdit && !canDelete) { - return null; - } - return ( - - - - - - - {canDelete && ( + { + accessor: 'is_sqllab_view', + hidden: true, + disableSortBy: true, + }, + { + Cell: ({ row: { original } }: any) => { + const handleEdit = () => openDatasetEditModal(original); + const handleDelete = () => openDatasetDeleteModal(original); + if (!canEdit && !canDelete) { + return null; + } + return ( + - - - + + - )} + {canDelete && ( + + + + + + )} - {canEdit && ( - - - - - - )} - - ); + + + + + )} + + ); + }, + Header: t('Actions'), + id: 'actions', + disableSortBy: true, }, - Header: t('Actions'), - id: 'actions', - disableSortBy: true, - }, - ]; + ], + [canCreate, canEdit, canDelete], + ); + + const filterTypes: Filters = useMemo( + () => [ + { + Header: t('Owner'), + id: 'owners', + input: 'select', + operator: 'rel_m_m', + unfilteredLabel: 'All', + fetchSelects: createFetchRelated( + 'dataset', + 'owners', + createErrorHandler(errMsg => + t( + 'An error occurred while fetching dataset owner values: %s', + errMsg, + ), + ), + ), + paginate: true, + }, + { + Header: t('Datasource'), + id: 'database', + input: 'select', + operator: 'rel_o_m', + unfilteredLabel: 'All', + fetchSelects: createFetchRelated( + 'dataset', + 'database', + createErrorHandler(errMsg => + t( + 'An error occurred while fetching dataset datasource values: %s', + errMsg, + ), + ), + ), + paginate: true, + }, + { + Header: t('Schema'), + id: 'schema', + input: 'select', + operator: 'eq', + unfilteredLabel: 'All', + fetchSelects: createFetchSchemas(errMsg => + t('An error occurred while fetching schema values: %s', errMsg), + ), + paginate: true, + }, + { + Header: t('Type'), + id: 'is_sqllab_view', + input: 'select', + operator: 'eq', + unfilteredLabel: 'All', + selects: [ + { label: 'Virtual', value: true }, + { label: 'Physical', value: false }, + ], + }, + { + Header: t('Search'), + id: 'table_name', + input: 'search', + operator: 'ct', + }, + ], + [], + ); const menuData: SubMenuProps = { activeChild: 'Datasets', @@ -460,7 +439,7 @@ const DatasetList: FunctionComponent = ({ if (canDelete) { menuData.secondaryButton = { name: t('Bulk Select'), - onClick: () => setBulkSelectEnabled(!bulkSelectEnabled), + onClick: toggleBulkSelect, }; } @@ -468,60 +447,16 @@ const DatasetList: FunctionComponent = ({ setDatasetCurrentlyDeleting(null); }; - const closeDatasetEditModal = () => setDatasetCurrentlyEditing(null); - - const fetchData = useCallback( - ({ pageIndex, pageSize, sortBy, filters }: FetchDataConfig) => { - // set loading state, cache the last config for fetching data in this component. - setLoading(true); - setLastFetchDataConfig({ - filters, - pageIndex, - pageSize, - sortBy, - }); - const filterExps = filters.map(({ id: col, operator: opr, value }) => ({ - col, - opr, - value, - })); - - const queryParams = rison.encode({ - 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 }) => { - setLoading(false); - setDatasets(json.result); - setDatasetCount(json.count); - }, - createErrorHandler(errMsg => - addDangerToast( - t('An error occurred while fetching datasets: %s', errMsg), - ), - ), - ) - .finally(() => setLoading(false)); - }, - [], - ); + const closeDatasetEditModal = () => { + setDatasetCurrentlyEditing(null); + }; const handleDatasetDelete = ({ id, table_name: tableName }: Dataset) => { SupersetClient.delete({ endpoint: `/api/v1/dataset/${id}`, }).then( () => { - if (lastFetchDataConfig) { - fetchData(lastFetchDataConfig); - } + refreshData(); setDatasetCurrentlyDeleting(null); addSuccessToast(t('Deleted: %s', tableName)); }, @@ -533,16 +468,14 @@ const DatasetList: FunctionComponent = ({ ); }; - const handleBulkDatasetDelete = () => { + const handleBulkDatasetDelete = (datasetsToDelete: Dataset[]) => { SupersetClient.delete({ endpoint: `/api/v1/dataset/?q=${rison.encode( - datasets.map(({ id }) => id), + datasetsToDelete.map(({ id }) => id), )}`, }).then( ({ json = {} }) => { - if (lastFetchDataConfig) { - fetchData(lastFetchDataConfig); - } + refreshData(); addSuccessToast(json.message); }, createErrorHandler(errMsg => @@ -553,21 +486,13 @@ const DatasetList: FunctionComponent = ({ ); }; - const handleUpdateDataset = () => { - if (lastFetchDataConfig) { - fetchData(lastFetchDataConfig); - } - }; - return ( <> setDatasetAddModalOpen(false)} - onDatasetAdd={() => { - if (lastFetchDataConfig) fetchData(lastFetchDataConfig); - }} + onDatasetAdd={refreshData} /> {datasetCurrentlyDeleting && ( = ({ datasetCurrentlyDeleting.chart_count, datasetCurrentlyDeleting.dashboard_count, )} - onConfirm={() => handleDatasetDelete(datasetCurrentlyDeleting)} + onConfirm={() => { + if (datasetCurrentlyDeleting) { + handleDatasetDelete(datasetCurrentlyDeleting); + } + }} onHide={closeDatasetDeleteModal} open title={t('Delete Dataset?')} /> )} + {datasetCurrentlyEditing && ( + + )} = ({ : []; return ( - <> - {datasetCurrentlyDeleting && ( - - handleDatasetDelete(datasetCurrentlyDeleting) - } - onHide={closeDatasetDeleteModal} - open - title={t('Delete Dataset?')} - /> - )} - {datasetCurrentlyEditing && ( - - )} - setBulkSelectEnabled(false)} - renderBulkSelectCopy={selected => { - const { virtualCount, physicalCount } = selected.reduce( - (acc, e) => { - if (e.original.kind === 'physical') - acc.physicalCount += 1; - else if (e.original.kind === 'virtual') - acc.virtualCount += 1; - return acc; - }, - { virtualCount: 0, physicalCount: 0 }, + { + const { virtualCount, physicalCount } = selected.reduce( + (acc, e) => { + if (e.original.kind === 'physical') acc.physicalCount += 1; + else if (e.original.kind === 'virtual') + acc.virtualCount += 1; + return acc; + }, + { virtualCount: 0, physicalCount: 0 }, + ); + + if (!selected.length) { + return t('0 Selected'); + } else if (virtualCount && !physicalCount) { + return t( + '%s Selected (Virtual)', + selected.length, + virtualCount, ); - - if (!selected.length) { - return t('0 Selected'); - } else if (virtualCount && !physicalCount) { - return t( - '%s Selected (Virtual)', - selected.length, - virtualCount, - ); - } else if (physicalCount && !virtualCount) { - return t( - '%s Selected (Physical)', - selected.length, - physicalCount, - ); - } - + } else if (physicalCount && !virtualCount) { return t( - '%s Selected (%s Physical, %s Virtual)', + '%s Selected (Physical)', selected.length, physicalCount, - virtualCount, ); - }} - /> - + } + + return t( + '%s Selected (%s Physical, %s Virtual)', + selected.length, + physicalCount, + virtualCount, + ); + }} + /> ); }} diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts new file mode 100644 index 0000000000000..56e5c56443039 --- /dev/null +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -0,0 +1,224 @@ +/** + * 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 rison from 'rison'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { SupersetClient } from '@superset-ui/connection'; +import { t } from '@superset-ui/translation'; + +import { createErrorHandler } from 'src/views/CRUD/utils'; +import { FetchDataConfig } from 'src/components/ListView'; +import { FavoriteStatus } from './types'; + +interface ListViewResourceState { + loading: boolean; + collection: D[]; + count: number; + permissions: string[]; + lastFetchDataConfig: FetchDataConfig | null; + bulkSelectEnabled: boolean; +} + +export function useListViewResource( + resource: string, + resourceLabel: string, // resourceLabel for translations + handleErrorMsg: (errorMsg: string) => void, +) { + const [state, setState] = useState>({ + count: 0, + collection: [], + loading: true, + lastFetchDataConfig: null, + permissions: [], + bulkSelectEnabled: false, + }); + + function updateState(update: Partial>) { + setState(currentState => ({ ...currentState, ...update })); + } + + function toggleBulkSelect() { + updateState({ bulkSelectEnabled: !state.bulkSelectEnabled }); + } + + useEffect(() => { + SupersetClient.get({ + endpoint: `/api/v1/${resource}/_info`, + }).then( + ({ json: infoJson = {} }) => { + updateState({ + permissions: infoJson.permissions, + }); + }, + createErrorHandler(errMsg => + handleErrorMsg( + t( + 'An error occurred while fetching %ss info: %s', + resourceLabel, + errMsg, + ), + ), + ), + ); + }, []); + + function hasPerm(perm: string) { + if (!state.permissions.length) { + return false; + } + + return Boolean(state.permissions.find(p => p === perm)); + } + + const fetchData = useCallback( + ({ + pageIndex, + pageSize, + sortBy, + filters: filterValues, + }: FetchDataConfig) => { + // set loading state, cache the last config for refreshing data. + updateState({ + lastFetchDataConfig: { + filters: filterValues, + pageIndex, + pageSize, + sortBy, + }, + loading: true, + }); + + const filterExps = filterValues.map( + ({ id: col, operator: opr, value }) => ({ + col, + opr, + value, + }), + ); + + const queryParams = rison.encode({ + 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/${resource}/?q=${queryParams}`, + }) + .then( + ({ json = {} }) => { + updateState({ + collection: json.result, + count: json.count, + }); + }, + createErrorHandler(errMsg => + handleErrorMsg( + t( + 'An error occurred while fetching %ss: %s', + resourceLabel, + errMsg, + ), + ), + ), + ) + .finally(() => { + updateState({ loading: false }); + }); + }, + [], + ); + + return { + state: { + loading: state.loading, + resourceCount: state.count, + resourceCollection: state.collection, + bulkSelectEnabled: state.bulkSelectEnabled, + }, + setResourceCollection: (update: D[]) => + updateState({ + collection: update, + }), + hasPerm, + fetchData, + toggleBulkSelect, + refreshData: () => { + if (state.lastFetchDataConfig) { + fetchData(state.lastFetchDataConfig); + } + }, + }; +} + +// the hooks api has some known limitations around stale state in closures. +// See https://github.com/reactjs/rfcs/blob/master/text/0068-react-hooks.md#drawbacks +// the useRef hook is a way of getting around these limitations by having a consistent ref +// that points to the most recent value. +export function useFavoriteStatus( + initialState: FavoriteStatus, + baseURL: string, + handleErrorMsg: (message: string) => void, +) { + const [favoriteStatus, setFavoriteStatus] = useState( + initialState, + ); + const favoriteStatusRef = useRef(favoriteStatus); + useEffect(() => { + favoriteStatusRef.current = favoriteStatus; + }); + + const updateFavoriteStatus = (update: FavoriteStatus) => + setFavoriteStatus(currentState => ({ ...currentState, ...update })); + + const fetchFaveStar = (id: number) => { + SupersetClient.get({ + endpoint: `${baseURL}/${id}/count/`, + }).then( + ({ json }) => { + updateFavoriteStatus({ [id]: json.count > 0 }); + }, + createErrorHandler(errMsg => + handleErrorMsg( + t('There was an error fetching the favorite status: %s', errMsg), + ), + ), + ); + }; + + const saveFaveStar = (id: number, isStarred: boolean) => { + const urlSuffix = isStarred ? 'unselect' : 'select'; + + SupersetClient.get({ + endpoint: `${baseURL}/${id}/${urlSuffix}/`, + }).then( + () => { + updateFavoriteStatus({ [id]: !isStarred }); + }, + createErrorHandler(errMsg => + handleErrorMsg( + t('There was an error saving the favorite status: %s', errMsg), + ), + ), + ); + }; + + return [favoriteStatusRef, fetchFaveStar, saveFaveStar] as const; +} diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts new file mode 100644 index 0000000000000..91d88a375e547 --- /dev/null +++ b/superset-frontend/src/views/CRUD/types.ts @@ -0,0 +1,22 @@ +/** + * 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. + */ + +export type FavoriteStatus = { + [id: number]: boolean; +}; diff --git a/superset-frontend/src/views/CRUD/utils.tsx b/superset-frontend/src/views/CRUD/utils.tsx index 5aea548a78a82..2bced467bb3c3 100644 --- a/superset-frontend/src/views/CRUD/utils.tsx +++ b/superset-frontend/src/views/CRUD/utils.tsx @@ -20,7 +20,6 @@ import { SupersetClient, SupersetClientResponse, } from '@superset-ui/connection'; -import { t } from '@superset-ui/translation'; import rison from 'rison'; import getClientErrorObject from 'src/utils/getClientErrorObject'; import { logging } from '@superset-ui/core'; @@ -58,59 +57,6 @@ export function createErrorHandler(handleErrorFunc: (errMsg?: string) => void) { return async (e: SupersetClientResponse | string) => { const parsedError = await getClientErrorObject(e); logging.error(e); - handleErrorFunc(parsedError.message); - }; -} - -export function createFaveStarHandlers( - baseURL: string, - context: any, - handleErrorFunc: (message: string) => void, -) { - const fetchFaveStar = (id: number) => { - SupersetClient.get({ - endpoint: `${baseURL}/${id}/count/`, - }) - .then(({ json }) => { - const faves = { - ...context.state.favoriteStatus, - }; - - faves[id] = json.count > 0; - - context.setState({ - favoriteStatus: faves, - }); - }) - .catch(() => - handleErrorFunc(t('There was an error fetching the favorite status')), - ); - }; - - const saveFaveStar = (id: number, isStarred: boolean) => { - const urlSuffix = isStarred ? 'unselect' : 'select'; - - SupersetClient.get({ - endpoint: `${baseURL}/${id}/${urlSuffix}/`, - }) - .then(() => { - const faves = { - ...context.state.favoriteStatus, - }; - - faves[id] = !isStarred; - - context.setState({ - favoriteStatus: faves, - }); - }) - .catch(() => - handleErrorFunc(t('There was an error saving the favorite status')), - ); - }; - - return { - fetchFaveStar, - saveFaveStar, + handleErrorFunc(parsedError.error); }; }