refactor: useListViewResource hook for charts, dashboards, datasets (#10680)

This commit is contained in:
ʈᵃᵢ 2020-08-26 15:39:18 -07:00 committed by GitHub
parent bc0fc4ea25
commit 6ff96cfc72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 1226 additions and 1341 deletions

View File

@ -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(<ChartList {...mockedProps} />, {
context: { store },
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
});
beforeAll(async () => {
await waitForComponentToPaint(wrapper);
});
it('renders', () => {

View File

@ -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(<DashboardList {...mockedProps} />, {
context: { store },
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
});
beforeAll(async () => {
await waitForComponentToPaint(wrapper);
});
it('renders', () => {

View File

@ -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<Props, State> {
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;
}
componentDidMount() {
SupersetClient.get({
endpoint: `/api/v1/chart/_info`,
function ChartList(props: ChartListProps) {
const {
state: {
loading,
resourceCount: chartCount,
resourceCollection: charts,
bulkSelectEnabled,
},
setResourceCollection: setCharts,
hasPerm,
fetchData,
toggleBulkSelect,
refreshData,
} = useListViewResource<Chart>('chart', t('chart'), props.addDangerToast);
const [favoriteStatusRef, fetchFaveStar, saveFaveStar] = useFavoriteStatus(
{},
FAVESTAR_BASE_URL,
props.addDangerToast,
);
const [
sliceCurrentlyEditing,
setSliceCurrentlyEditing,
] = useState<Slice | null>(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(
({ json: infoJson = {} }) => {
this.setState({
permissions: infoJson.permissions,
});
() => {
refreshData();
props.addSuccessToast(t('Deleted: %s', sliceName));
},
() => {
props.addDangerToast(t('There was an issue deleting: %s', sliceName));
},
);
}
function handleBulkChartDelete(chartsToDelete: Chart[]) {
SupersetClient.delete({
endpoint: `/api/v1/chart/?q=${rison.encode(
chartsToDelete.map(({ id }) => id),
)}`,
}).then(
({ 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');
function renderFaveStar(id: number) {
return (
<FaveStar
itemId={id}
fetchFaveStar={fetchFaveStar}
saveFaveStar={saveFaveStar}
isStarred={!!favoriteStatusRef.current[id]}
height={20}
width={20}
/>
);
}
get canDelete() {
return this.hasPerm('can_delete');
}
const columns = useMemo(
() => [
{
Cell: ({
row: {
original: { id },
},
}: any) => renderFaveStar(id),
Header: '',
id: 'favorite',
disableSortBy: true,
},
{
Cell: ({
row: {
original: { url, slice_name: sliceName },
},
}: any) => <a href={url}>{sliceName}</a>,
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) => <a href={dsUrl}>{dsNameTxt}</a>,
Header: t('Datasource'),
accessor: 'datasource_name',
},
{
Cell: ({
row: {
original: {
changed_by_name: changedByName,
changed_by_url: changedByUrl,
},
},
}: any) => <a href={changedByUrl}>{changedByName}</a>,
Header: t('Modified By'),
accessor: 'changed_by.first_name',
},
{
Cell: ({
row: {
original: { changed_on_delta_humanized: changedOn },
},
}: any) => <span className="no-wrap">{changedOn}</span>,
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;
}
initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
fetchMethods = createFaveStarHandlers(
FAVESTAR_BASE_URL,
this,
(message: string) => {
this.props.addDangerToast(message);
},
return (
<span className="actions">
{canDelete && (
<ConfirmStatusChange
title={t('Please Confirm')}
description={
<>
{t('Are you sure you want to delete')}{' '}
<b>{original.slice_name}</b>?
</>
}
onConfirm={handleDelete}
>
{confirmDelete => (
<span
role="button"
tabIndex={0}
className="action-button"
onClick={confirmDelete}
>
<Icon name="trash" />
</span>
)}
</ConfirmStatusChange>
)}
{canEdit && (
<span
role="button"
tabIndex={0}
className="action-button"
onClick={openEditModal}
>
<Icon name="pencil" />
</span>
)}
</span>
);
},
Header: t('Actions'),
id: 'actions',
disableSortBy: true,
},
],
[canEdit, canDelete, favoriteStatusRef],
);
columns = [
{
Cell: ({ row: { original } }: any) => {
return (
<FaveStar
itemId={original.id}
fetchFaveStar={this.fetchMethods.fetchFaveStar}
saveFaveStar={this.fetchMethods.saveFaveStar}
isStarred={!!this.state.favoriteStatus[original.id]}
height={20}
/>
);
},
Header: '',
id: 'favorite',
disableSortBy: true,
},
{
Cell: ({
row: {
original: { url, slice_name: sliceName },
},
}: any) => <a href={url}>{sliceName}</a>,
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) => <a href={dsUrl}>{dsNameTxt}</a>,
Header: t('Datasource'),
accessor: 'datasource_name',
},
{
Cell: ({
row: {
original: {
changed_by_name: changedByName,
changed_by_url: changedByUrl,
},
},
}: any) => <a href={changedByUrl}>{changedByName}</a>,
Header: t('Modified By'),
accessor: 'changed_by.first_name',
},
{
Cell: ({
row: {
original: { changed_on_delta_humanized: changedOn },
},
}: any) => <span className="no-wrap">{changedOn}</span>,
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;
}
return (
<span className="actions">
{this.canDelete && (
<ConfirmStatusChange
title={t('Please Confirm')}
description={
<>
{t('Are you sure you want to delete')}{' '}
<b>{original.slice_name}</b>?
</>
}
onConfirm={handleDelete}
>
{confirmDelete => (
<span
role="button"
tabIndex={0}
className="action-button"
onClick={confirmDelete}
>
<Icon name="trash" />
</span>
)}
</ConfirmStatusChange>
)}
{this.canEdit && (
<span
role="button"
tabIndex={0}
className="action-button"
onClick={openEditModal}
>
<Icon name="pencil" />
</span>
)}
</span>
);
},
Header: t('Actions'),
id: 'actions',
disableSortBy: true,
},
];
filters: Filters = [
const filters: Filters = [
{
Header: t('Owner'),
id: 'owners',
@ -296,7 +323,7 @@ class ChartList extends React.PureComponent<Props, State> {
'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<Props, State> {
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<Props, State> {
},
];
sortTypes = [
const sortTypes = [
{
desc: false,
id: 'slice_name',
@ -363,139 +390,20 @@ class ChartList extends React.PureComponent<Props, State> {
},
];
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 = (
<Menu>
{this.canDelete && (
{canDelete && (
<Menu.Item>
<ConfirmStatusChange
title={t('Please Confirm')}
description={
<>
{t('Are you sure you want to delete')}{' '}
<b>{props.slice_name}</b>?
<b>{chart.slice_name}</b>?
</>
}
onConfirm={() => this.handleChartDelete(props)}
onConfirm={() => handleChartDelete(chart)}
>
{confirmDelete => (
<div
@ -510,11 +418,11 @@ class ChartList extends React.PureComponent<Props, State> {
</ConfirmStatusChange>
</Menu.Item>
)}
{this.canEdit && (
{canEdit && (
<Menu.Item
role="button"
tabIndex={0}
onClick={() => this.openChartEditModal(props)}
onClick={() => openChartEditModal(chart)}
>
<ListViewCard.MenuIcon name="pencil" /> Edit
</Menu.Item>
@ -524,16 +432,16 @@ class ChartList extends React.PureComponent<Props, State> {
return (
<ListViewCard
loading={props.loading}
title={props.slice_name}
url={this.state.bulkSelectEnabled ? undefined : props.url}
imgURL={props.thumbnail_url ?? ''}
loading={chart.loading}
title={chart.slice_name}
url={bulkSelectEnabled ? undefined : chart.url}
imgURL={chart.thumbnail_url ?? ''}
imgFallbackURL={'/static/assets/images/chart-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', chart.changed_on_delta_humanized)}
coverLeft={(chart.owners || []).slice(0, 5).map(owner => (
<AvatarIcon
key={owner.id}
uniqueKey={`${owner.username}-${props.id}`}
uniqueKey={`${owner.username}-${chart.id}`}
firstName={owner.first_name}
lastName={owner.last_name}
iconSize={24}
@ -541,18 +449,11 @@ class ChartList extends React.PureComponent<Props, State> {
/>
))}
coverRight={
<Label bsStyle="secondary">{props.datasource_name_text}</Label>
<Label bsStyle="secondary">{chart.datasource_name_text}</Label>
}
actions={
<ListViewCard.Actions>
<FaveStar
itemId={props.id}
fetchFaveStar={this.fetchMethods.fetchFaveStar}
saveFaveStar={this.fetchMethods.saveFaveStar}
isStarred={!!this.state.favoriteStatus[props.id]}
width={20}
height={20}
/>
{renderFaveStar(chart.id)}
<Dropdown overlay={menu}>
<Icon name="more" />
</Dropdown>
@ -560,79 +461,68 @@ class ChartList extends React.PureComponent<Props, State> {
}
/>
);
};
render() {
const {
bulkSelectEnabled,
charts,
chartCount,
loading,
sliceCurrentlyEditing,
} = this.state;
return (
<>
<SubMenu
name={t('Charts')}
secondaryButton={
this.canDelete
? {
name: t('Bulk Select'),
onClick: this.toggleBulkSelect,
}
: undefined
}
/>
{sliceCurrentlyEditing && (
<PropertiesModal
onHide={this.closeChartEditModal}
onSave={this.handleChartUpdated}
show
slice={sliceCurrentlyEditing}
/>
)}
<ConfirmStatusChange
title={t('Please confirm')}
description={t(
'Are you sure you want to delete the selected charts?',
)}
onConfirm={this.handleBulkChartDelete}
>
{confirmDelete => {
const bulkActions: ListViewProps['bulkActions'] = this.canDelete
? [
{
key: 'delete',
name: t('Delete'),
onSelect: confirmDelete,
type: 'danger',
},
]
: [];
return (
<ListView
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
cardSortSelectOptions={this.sortTypes}
className="chart-list-view"
columns={this.columns}
count={chartCount}
data={charts}
disableBulkSelect={this.toggleBulkSelect}
fetchData={this.fetchData}
filters={this.filters}
initialSort={this.initialSort}
loading={loading}
pageSize={PAGE_SIZE}
renderCard={this.renderCard}
/>
);
}}
</ConfirmStatusChange>
</>
);
}
return (
<>
<SubMenu
name={t('Charts')}
secondaryButton={
canDelete
? {
name: t('Bulk Select'),
onClick: toggleBulkSelect,
}
: undefined
}
/>
{sliceCurrentlyEditing && (
<PropertiesModal
onHide={closeChartEditModal}
onSave={handleChartUpdated}
show
slice={sliceCurrentlyEditing}
/>
)}
<ConfirmStatusChange
title={t('Please confirm')}
description={t('Are you sure you want to delete the selected charts?')}
onConfirm={handleBulkChartDelete}
>
{confirmDelete => {
const bulkActions: ListViewProps['bulkActions'] = canDelete
? [
{
key: 'delete',
name: t('Delete'),
onSelect: confirmDelete,
type: 'danger',
},
]
: [];
return (
<ListView
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
cardSortSelectOptions={sortTypes}
className="chart-list-view"
columns={columns}
count={chartCount}
data={charts}
disableBulkSelect={toggleBulkSelect}
fetchData={fetchData}
filters={filters}
initialSort={initialSort}
loading={loading}
pageSize={PAGE_SIZE}
renderCard={renderCard}
/>
);
}}
</ConfirmStatusChange>
</>
);
}
export default withToasts(ChartList);

View File

@ -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<Props, State> {
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>(
'dashboard',
t('dashboard'),
props.addDangerToast,
);
const [favoriteStatusRef, fetchFaveStar, saveFaveStar] = useFavoriteStatus(
{},
FAVESTAR_BASE_URL,
props.addDangerToast,
);
state: State = {
bulkSelectEnabled: false,
dashboardCount: 0,
dashboards: [],
favoriteStatus: {}, // Hash mapping dashboard id to 'isStarred' status
dashboardToEdit: null,
lastFetchDataConfig: null,
loading: true,
permissions: [],
};
const [dashboardToEdit, setDashboardToEdit] = useState<Dashboard | null>(
null,
);
componentDidMount() {
SupersetClient.get({
endpoint: `/api/v1/dashboard/_info`,
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);
}
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 }];
function renderFaveStar(id: number) {
return (
<FaveStar
itemId={id}
fetchFaveStar={fetchFaveStar}
saveFaveStar={saveFaveStar}
isStarred={!!favoriteStatusRef.current[id]}
height={20}
width={20}
/>
);
}
fetchMethods = createFaveStarHandlers(
FAVESTAR_BASE_URL,
this,
(message: string) => {
this.props.addDangerToast(message);
},
const columns = useMemo(
() => [
{
Cell: ({
row: {
original: { id },
},
}: any) => renderFaveStar(id),
Header: '',
id: 'favorite',
disableSortBy: true,
},
{
Cell: ({
row: {
original: { url, dashboard_title: dashboardTitle },
},
}: any) => <a href={url}>{dashboardTitle}</a>,
Header: t('Title'),
accessor: 'dashboard_title',
},
{
Cell: ({
row: {
original: { owners },
},
}: any) => (
<ExpandableList
items={owners.map(
({ first_name: firstName, last_name: lastName }: any) =>
`${firstName} ${lastName}`,
)}
display={2}
/>
),
Header: t('Owners'),
accessor: 'owners',
disableSortBy: true,
},
{
Cell: ({
row: {
original: {
changed_by_name: changedByName,
changed_by_url: changedByUrl,
},
},
}: any) => <a href={changedByUrl}>{changedByName}</a>,
Header: t('Modified By'),
accessor: 'changed_by.first_name',
},
{
Cell: ({
row: {
original: { published },
},
}: any) => (
<span className="no-wrap">
{published ? <Icon name="check" /> : ''}
</span>
),
Header: t('Published'),
accessor: 'published',
},
{
Cell: ({
row: {
original: { changed_on_delta_humanized: changedOn },
},
}: any) => <span className="no-wrap">{changedOn}</span>,
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 (
<span className="actions">
{canDelete && (
<ConfirmStatusChange
title={t('Please Confirm')}
description={
<>
{t('Are you sure you want to delete')}{' '}
<b>{original.dashboard_title}</b>?
</>
}
onConfirm={handleDelete}
>
{confirmDelete => (
<span
role="button"
tabIndex={0}
className="action-button"
onClick={confirmDelete}
>
<Icon name="trash" />
</span>
)}
</ConfirmStatusChange>
)}
{canExport && (
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleExport}
>
<Icon name="share" />
</span>
)}
{canEdit && (
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleEdit}
>
<Icon name="pencil" />
</span>
)}
</span>
);
},
Header: t('Actions'),
id: 'actions',
disableSortBy: true,
},
],
[canEdit, canDelete, canExport, favoriteStatusRef],
);
columns = [
{
Cell: ({ row: { original } }: any) => {
return (
<FaveStar
itemId={original.id}
fetchFaveStar={this.fetchMethods.fetchFaveStar}
saveFaveStar={this.fetchMethods.saveFaveStar}
isStarred={!!this.state.favoriteStatus[original.id]}
height={20}
/>
);
},
Header: '',
id: 'favorite',
disableSortBy: true,
},
{
Cell: ({
row: {
original: { url, dashboard_title: dashboardTitle },
},
}: any) => <a href={url}>{dashboardTitle}</a>,
Header: t('Title'),
accessor: 'dashboard_title',
},
{
Cell: ({
row: {
original: { owners },
},
}: any) => (
<ExpandableList
items={owners.map(
({ first_name: firstName, last_name: lastName }: any) =>
`${firstName} ${lastName}`,
)}
display={2}
/>
),
Header: t('Owners'),
accessor: 'owners',
disableSortBy: true,
},
{
Cell: ({
row: {
original: {
changed_by_name: changedByName,
changed_by_url: changedByUrl,
},
},
}: any) => <a href={changedByUrl}>{changedByName}</a>,
Header: t('Modified By'),
accessor: 'changed_by.first_name',
},
{
Cell: ({
row: {
original: { published },
},
}: any) => (
<span className="no-wrap">
{published ? <Icon name="check" /> : ''}
</span>
),
Header: t('Published'),
accessor: 'published',
},
{
Cell: ({
row: {
original: { changed_on_delta_humanized: changedOn },
},
}: any) => <span className="no-wrap">{changedOn}</span>,
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 });
};
filters: Filters = [
const filters: Filters = [
{
Header: 'Owner',
id: 'owners',
@ -237,7 +327,7 @@ class DashboardList extends React.PureComponent<Props, State> {
'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<Props, State> {
},
];
sortTypes = [
const sortTypes = [
{
desc: false,
id: 'dashboard_title',
@ -287,210 +377,20 @@ class DashboardList extends React.PureComponent<Props, State> {
},
];
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 (
<span className="actions">
{this.canDelete && (
<ConfirmStatusChange
title={t('Please Confirm')}
description={
<>
{t('Are you sure you want to delete')}{' '}
<b>{original.dashboard_title}</b>?
</>
}
onConfirm={handleDelete}
>
{confirmDelete => (
<span
role="button"
tabIndex={0}
className="action-button"
onClick={confirmDelete}
>
<Icon name="trash" />
</span>
)}
</ConfirmStatusChange>
)}
{this.canExport && (
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleExport}
>
<Icon name="share" />
</span>
)}
{this.canEdit && (
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleEdit}
>
<Icon name="pencil" />
</span>
)}
</span>
);
}
renderCard = (props: Dashboard & { loading: boolean }) => {
function renderCard(dashboard: Dashboard & { loading: boolean }) {
const menu = (
<Menu>
{this.canDelete && (
{canDelete && (
<Menu.Item>
<ConfirmStatusChange
title={t('Please Confirm')}
description={
<>
{t('Are you sure you want to delete')}{' '}
<b>{props.dashboard_title}</b>?
<b>{dashboard.dashboard_title}</b>?
</>
}
onConfirm={() => this.handleDashboardDelete(props)}
onConfirm={() => handleDashboardDelete(dashboard)}
>
{confirmDelete => (
<div
@ -505,20 +405,20 @@ class DashboardList extends React.PureComponent<Props, State> {
</ConfirmStatusChange>
</Menu.Item>
)}
{this.canExport && (
{canExport && (
<Menu.Item
role="button"
tabIndex={0}
onClick={() => this.handleBulkDashboardExport([props])}
onClick={() => handleBulkDashboardExport([dashboard])}
>
<ListViewCard.MenuIcon name="share" /> Export
</Menu.Item>
)}
{this.canEdit && (
{canEdit && (
<Menu.Item
role="button"
tabIndex={0}
onClick={() => this.openDashboardEditModal(props)}
onClick={() => openDashboardEditModal(dashboard)}
>
<ListViewCard.MenuIcon name="pencil" /> Edit
</Menu.Item>
@ -528,17 +428,22 @@ class DashboardList extends React.PureComponent<Props, State> {
return (
<ListViewCard
title={props.dashboard_title}
loading={props.loading}
titleRight={<Label>{props.published ? 'published' : 'draft'}</Label>}
url={this.state.bulkSelectEnabled ? undefined : props.url}
imgURL={props.thumbnail_url}
loading={dashboard.loading}
title={dashboard.dashboard_title}
titleRight={
<Label>{dashboard.published ? 'published' : 'draft'}</Label>
}
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 => (
<AvatarIcon
key={owner.id}
uniqueKey={`${owner.username}-${props.id}`}
uniqueKey={`${owner.username}-${dashboard.id}`}
firstName={owner.first_name}
lastName={owner.last_name}
iconSize={24}
@ -547,14 +452,7 @@ class DashboardList extends React.PureComponent<Props, State> {
))}
actions={
<ListViewCard.Actions>
<FaveStar
itemId={props.id}
fetchFaveStar={this.fetchMethods.fetchFaveStar}
saveFaveStar={this.fetchMethods.saveFaveStar}
isStarred={!!this.state.favoriteStatus[props.id]}
width={20}
height={20}
/>
{renderFaveStar(dashboard.id)}
<Dropdown overlay={menu}>
<Icon name="more" />
</Dropdown>
@ -562,87 +460,78 @@ class DashboardList extends React.PureComponent<Props, State> {
}
/>
);
};
render() {
const {
bulkSelectEnabled,
dashboards,
dashboardCount,
loading,
dashboardToEdit,
} = this.state;
return (
<>
<SubMenu
name={t('Dashboards')}
secondaryButton={
this.canDelete || this.canExport
? {
name: t('Bulk Select'),
onClick: this.toggleBulkSelect,
}
: undefined
}
/>
<ConfirmStatusChange
title={t('Please confirm')}
description={t(
'Are you sure you want to delete the selected dashboards?',
)}
onConfirm={this.handleBulkDashboardDelete}
>
{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 && (
<PropertiesModal
dashboardId={dashboardToEdit.id}
show
onHide={() => this.setState({ dashboardToEdit: null })}
onSubmit={this.handleDashboardEdit}
/>
)}
<ListView
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
cardSortSelectOptions={this.sortTypes}
className="dashboard-list-view"
columns={this.columns}
count={dashboardCount}
data={dashboards}
disableBulkSelect={this.toggleBulkSelect}
fetchData={this.fetchData}
filters={this.filters}
initialSort={this.initialSort}
loading={loading}
pageSize={PAGE_SIZE}
renderCard={this.renderCard}
/>
</>
);
}}
</ConfirmStatusChange>
</>
);
}
return (
<>
<SubMenu
name={t('Dashboards')}
secondaryButton={
canDelete || canExport
? {
name: t('Bulk Select'),
onClick: toggleBulkSelect,
}
: undefined
}
/>
<ConfirmStatusChange
title={t('Please confirm')}
description={t(
'Are you sure you want to delete the selected dashboards?',
)}
onConfirm={handleBulkDashboardDelete}
>
{confirmDelete => {
const bulkActions: ListViewProps['bulkActions'] = [];
if (canDelete) {
bulkActions.push({
key: 'delete',
name: t('Delete'),
type: 'danger',
onSelect: confirmDelete,
});
}
if (canExport) {
bulkActions.push({
key: 'export',
name: t('Export'),
type: 'primary',
onSelect: handleBulkDashboardExport,
});
}
return (
<>
{dashboardToEdit && (
<PropertiesModal
dashboardId={dashboardToEdit.id}
show
onHide={() => setDashboardToEdit(null)}
onSubmit={handleDashboardEdit}
/>
)}
<ListView
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
cardSortSelectOptions={sortTypes}
className="dashboard-list-view"
columns={columns}
count={dashboardCount}
data={dashboards}
disableBulkSelect={toggleBulkSelect}
fetchData={fetchData}
filters={filters}
initialSort={initialSort}
loading={loading}
pageSize={PAGE_SIZE}
renderCard={renderCard}
/>
</>
);
}}
</ConfirmStatusChange>
</>
);
}
export default withToasts(DashboardList);

View File

@ -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<Owner>;
@ -104,118 +97,31 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
addDangerToast,
addSuccessToast,
}) => {
const [datasetCount, setDatasetCount] = useState(0);
const [datasetCurrentlyDeleting, setDatasetCurrentlyDeleting] = useState<
(Dataset & { chart_count: number; dashboard_count: number }) | null
>(null);
const [
datasetCurrentlyEditing,
setDatasetCurrentlyEditing,
] = useState<Dataset | null>(null);
const [datasets, setDatasets] = useState<any[]>([]);
const [
lastFetchDataConfig,
setLastFetchDataConfig,
] = useState<FetchDataConfig | null>(null);
const [loading, setLoading] = useState(true);
const [permissions, setPermissions] = useState<string[]>([]);
const {
state: {
loading,
resourceCount: datasetCount,
resourceCollection: datasets,
bulkSelectEnabled,
},
hasPerm,
fetchData,
toggleBulkSelect,
refreshData,
} = useListViewResource<Dataset>('dataset', t('dataset'), addDangerToast);
const [datasetAddModalOpen, setDatasetAddModalOpen] = useState<boolean>(
false,
);
const [bulkSelectEnabled, setBulkSelectEnabled] = useState<boolean>(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 [datasetCurrentlyDeleting, setDatasetCurrentlyDeleting] = useState<
(Dataset & { chart_count: number; dashboard_count: number }) | null
>(null);
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 [
datasetCurrentlyEditing,
setDatasetCurrentlyEditing,
] = useState<Dataset | null>(null);
const canEdit = hasPerm('can_edit');
const canDelete = hasPerm('can_delete');
@ -258,187 +164,260 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
),
);
const columns = [
{
Cell: ({
row: {
original: { kind },
},
}: any) => {
if (kind === 'physical')
const columns = useMemo(
() => [
{
Cell: ({
row: {
original: { kind },
},
}: any) => {
if (kind === 'physical')
return (
<TooltipWrapper
label="physical-dataset"
tooltip={t('Physical Dataset')}
>
<Icon name="dataset-physical" />
</TooltipWrapper>
);
return (
<TooltipWrapper
label="physical-dataset"
tooltip={t('Physical Dataset')}
label="virtual-dataset"
tooltip={t('Virtual Dataset')}
>
<Icon name="dataset-physical" />
<Icon name="dataset-virtual" />
</TooltipWrapper>
);
return (
<TooltipWrapper
label="virtual-dataset"
tooltip={t('Virtual Dataset')}
>
<Icon name="dataset-virtual" />
</TooltipWrapper>
);
},
accessor: 'kind_icon',
disableSortBy: true,
size: 'xs',
},
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) => <span className="no-wrap">{changedOn}</span>,
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) => (
<AvatarIcon
key={owner.id}
uniqueKey={`${tableName}-${owner.username}`}
firstName={owner.first_name}
lastName={owner.last_name}
iconSize={24}
textSize={9}
/>
));
{
Cell: ({
row: {
original: { table_name: datasetTitle },
},
}: any) => datasetTitle,
Header: t('Name'),
accessor: 'table_name',
},
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 (
<span className="actions">
<TooltipWrapper
label="explore-action"
tooltip={t('Explore')}
placement="bottom"
>
<a
role="button"
tabIndex={0}
className="action-button"
href={original.explore_url}
>
<Icon name="compass" />
</a>
</TooltipWrapper>
{canDelete && (
{
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) => <span className="no-wrap">{changedOn}</span>,
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) => (
<AvatarIcon
key={owner.id}
uniqueKey={`${tableName}-${owner.username}`}
firstName={owner.first_name}
lastName={owner.last_name}
iconSize={24}
textSize={9}
/>
));
},
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 (
<span className="actions">
<TooltipWrapper
label="delete-action"
tooltip={t('Delete')}
label="explore-action"
tooltip={t('Explore')}
placement="bottom"
>
<span
<a
role="button"
tabIndex={0}
className="action-button"
onClick={handleDelete}
href={original.explore_url}
>
<Icon name="trash" />
</span>
<Icon name="compass" />
</a>
</TooltipWrapper>
)}
{canDelete && (
<TooltipWrapper
label="delete-action"
tooltip={t('Delete')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleDelete}
>
<Icon name="trash" />
</span>
</TooltipWrapper>
)}
{canEdit && (
<TooltipWrapper
label="edit-action"
tooltip={t('Edit')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleEdit}
{canEdit && (
<TooltipWrapper
label="edit-action"
tooltip={t('Edit')}
placement="bottom"
>
<Icon name="pencil" />
</span>
</TooltipWrapper>
)}
</span>
);
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleEdit}
>
<Icon name="pencil" />
</span>
</TooltipWrapper>
)}
</span>
);
},
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<DatasetListProps> = ({
if (canDelete) {
menuData.secondaryButton = {
name: t('Bulk Select'),
onClick: () => setBulkSelectEnabled(!bulkSelectEnabled),
onClick: toggleBulkSelect,
};
}
@ -468,60 +447,16 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
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<DatasetListProps> = ({
);
};
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<DatasetListProps> = ({
);
};
const handleUpdateDataset = () => {
if (lastFetchDataConfig) {
fetchData(lastFetchDataConfig);
}
};
return (
<>
<SubMenu {...menuData} />
<AddDatasetModal
show={datasetAddModalOpen}
onHide={() => setDatasetAddModalOpen(false)}
onDatasetAdd={() => {
if (lastFetchDataConfig) fetchData(lastFetchDataConfig);
}}
onDatasetAdd={refreshData}
/>
{datasetCurrentlyDeleting && (
<DeleteModal
@ -577,12 +502,24 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
datasetCurrentlyDeleting.chart_count,
datasetCurrentlyDeleting.dashboard_count,
)}
onConfirm={() => handleDatasetDelete(datasetCurrentlyDeleting)}
onConfirm={() => {
if (datasetCurrentlyDeleting) {
handleDatasetDelete(datasetCurrentlyDeleting);
}
}}
onHide={closeDatasetDeleteModal}
open
title={t('Delete Dataset?')}
/>
)}
{datasetCurrentlyEditing && (
<DatasourceModal
datasource={datasetCurrentlyEditing}
onDatasourceSave={refreshData}
onHide={closeDatasetEditModal}
show
/>
)}
<ConfirmStatusChange
title={t('Please confirm')}
description={t(
@ -603,82 +540,54 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
: [];
return (
<>
{datasetCurrentlyDeleting && (
<DeleteModal
description={t(
`The dataset ${datasetCurrentlyDeleting.table_name} is linked to
${datasetCurrentlyDeleting.chart_count} charts that appear on
${datasetCurrentlyDeleting.dashboard_count} dashboards.
Are you sure you want to continue? Deleting the dataset will break
those objects.`,
)}
onConfirm={() =>
handleDatasetDelete(datasetCurrentlyDeleting)
}
onHide={closeDatasetDeleteModal}
open
title={t('Delete Dataset?')}
/>
)}
{datasetCurrentlyEditing && (
<DatasourceModal
datasource={datasetCurrentlyEditing}
onDatasourceSave={handleUpdateDataset}
onHide={closeDatasetEditModal}
show
/>
)}
<ListView
className="dataset-list-view"
columns={columns}
data={datasets}
count={datasetCount}
pageSize={PAGE_SIZE}
fetchData={fetchData}
filters={filterTypes}
loading={loading}
initialSort={initialSort}
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={() => 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 },
);
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,
);
}
<ListView
className="dataset-list-view"
columns={columns}
data={datasets}
count={datasetCount}
pageSize={PAGE_SIZE}
fetchData={fetchData}
filters={filterTypes}
loading={loading}
initialSort={initialSort}
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={toggleBulkSelect}
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 },
);
if (!selected.length) {
return t('0 Selected');
} else if (virtualCount && !physicalCount) {
return t(
'%s Selected (%s Physical, %s Virtual)',
'%s Selected (Virtual)',
selected.length,
physicalCount,
virtualCount,
);
}}
/>
</>
} else if (physicalCount && !virtualCount) {
return t(
'%s Selected (Physical)',
selected.length,
physicalCount,
);
}
return t(
'%s Selected (%s Physical, %s Virtual)',
selected.length,
physicalCount,
virtualCount,
);
}}
/>
);
}}
</ConfirmStatusChange>

View File

@ -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<D extends object = any> {
loading: boolean;
collection: D[];
count: number;
permissions: string[];
lastFetchDataConfig: FetchDataConfig | null;
bulkSelectEnabled: boolean;
}
export function useListViewResource<D extends object = any>(
resource: string,
resourceLabel: string, // resourceLabel for translations
handleErrorMsg: (errorMsg: string) => void,
) {
const [state, setState] = useState<ListViewResourceState<D>>({
count: 0,
collection: [],
loading: true,
lastFetchDataConfig: null,
permissions: [],
bulkSelectEnabled: false,
});
function updateState(update: Partial<ListViewResourceState<D>>) {
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<FavoriteStatus>(
initialState,
);
const favoriteStatusRef = useRef<FavoriteStatus>(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;
}

View File

@ -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;
};

View File

@ -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);
};
}