blob: bb935d1f1f3d06df7e9d99e9a4dd5b40d5e5bb0a [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SupersetClient, t } from '@superset-ui/core';
import React, { useState, useMemo } from 'react';
import rison from 'rison';
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
import {
createFetchRelated,
createErrorHandler,
handleDashboardDelete,
handleBulkDashboardExport,
} from 'src/views/CRUD/utils';
import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks';
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
import ListView, {
ListViewProps,
Filters,
FilterOperators,
} from 'src/components/ListView';
import Owner from 'src/types/Owner';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import FacePile from 'src/components/FacePile';
import Icon from 'src/components/Icon';
import FaveStar from 'src/components/FaveStar';
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
import TooltipWrapper from 'src/components/TooltipWrapper';
import ImportModelsModal from 'src/components/ImportModal/index';
import Dashboard from 'src/dashboard/containers/Dashboard';
import DashboardCard from './DashboardCard';
const PAGE_SIZE = 25;
const PASSWORDS_NEEDED_MESSAGE = t(
'The passwords for the databases below are needed in order to ' +
'import them together with the dashboards. Please note that the ' +
'"Secure Extra" and "Certificate" sections of ' +
'the database configuration are not present in export files, and ' +
'should be added manually after the import if they are needed.',
);
const CONFIRM_OVERWRITE_MESSAGE = t(
'You are importing one or more dashboards that already exist. ' +
'Overwriting might cause you to lose some of your work. Are you ' +
'sure you want to overwrite?',
);
interface DashboardListProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
user: {
userId: string | number;
};
}
interface Dashboard {
changed_by_name: string;
changed_by_url: string;
changed_on_delta_humanized: string;
changed_by: string;
dashboard_title: string;
id: number;
published: boolean;
url: string;
thumbnail_url: string;
owners: Owner[];
created_by: object;
}
function DashboardList(props: DashboardListProps) {
const { addDangerToast, addSuccessToast } = props;
const {
state: {
loading,
resourceCount: dashboardCount,
resourceCollection: dashboards,
bulkSelectEnabled,
},
setResourceCollection: setDashboards,
hasPerm,
fetchData,
toggleBulkSelect,
refreshData,
} = useListViewResource<Dashboard>(
'dashboard',
t('dashboard'),
addDangerToast,
);
const dashboardIds = useMemo(() => dashboards.map(d => d.id), [dashboards]);
const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus(
'dashboard',
dashboardIds,
addDangerToast,
);
const [dashboardToEdit, setDashboardToEdit] = useState<Dashboard | null>(
null,
);
const [importingDashboard, showImportModal] = useState<boolean>(false);
const [passwordFields, setPasswordFields] = useState<string[]>([]);
const openDashboardImportModal = () => {
showImportModal(true);
};
const closeDashboardImportModal = () => {
showImportModal(false);
};
const handleDashboardImport = () => {
showImportModal(false);
refreshData();
};
const canCreate = hasPerm('can_write');
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
const canExport = hasPerm('can_read');
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 = {} }) => {
setDashboards(
dashboards.map(dashboard => {
if (dashboard.id === json.id) {
return json.result;
}
return dashboard;
}),
);
},
createErrorHandler(errMsg =>
addDangerToast(
t('An error occurred while fetching dashboards: %s', errMsg),
),
),
);
}
function handleBulkDashboardDelete(dashboardsToDelete: Dashboard[]) {
return SupersetClient.delete({
endpoint: `/api/v1/dashboard/?q=${rison.encode(
dashboardsToDelete.map(({ id }) => id),
)}`,
}).then(
({ json = {} }) => {
addSuccessToast(json.message);
},
createErrorHandler(errMsg =>
addDangerToast(
t('There was an issue deleting the selected dashboards: ', errMsg),
),
),
);
}
const columns = useMemo(
() => [
{
Cell: ({
row: {
original: { id },
},
}: any) => (
<FaveStar
itemId={id}
saveFaveStar={saveFavoriteStatus}
isStarred={favoriteStatus[id]}
/>
),
Header: '',
id: 'id',
disableSortBy: true,
size: 'xs',
},
{
Cell: ({
row: {
original: { url, dashboard_title: dashboardTitle },
},
}: any) => <a href={url}>{dashboardTitle}</a>,
Header: t('Title'),
accessor: 'dashboard_title',
},
{
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',
size: 'xl',
},
{
Cell: ({
row: {
original: { published },
},
}: any) => (published ? t('Published') : t('Draft')),
Header: t('Status'),
accessor: 'published',
size: 'xl',
},
{
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: { created_by: createdBy },
},
}: any) =>
createdBy ? `${createdBy.first_name} ${createdBy.last_name}` : '',
Header: t('Created by'),
accessor: 'created_by',
disableSortBy: true,
size: 'xl',
},
{
Cell: ({
row: {
original: { owners = [] },
},
}: any) => <FacePile users={owners} />,
Header: t('Owners'),
accessor: 'owners',
disableSortBy: true,
size: 'xl',
},
{
Cell: ({ row: { original } }: any) => {
const handleDelete = () =>
handleDashboardDelete(
original,
refreshData,
addSuccessToast,
addDangerToast,
);
const handleEdit = () => openDashboardEditModal(original);
const handleExport = () => handleBulkDashboardExport([original]);
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 => (
<TooltipWrapper
label="delete-action"
tooltip={t('Delete')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={confirmDelete}
>
<Icon
data-test="dashboard-list-trash-icon"
name="trash"
/>
</span>
</TooltipWrapper>
)}
</ConfirmStatusChange>
)}
{canExport && (
<TooltipWrapper
label="export-action"
tooltip={t('Export')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleExport}
>
<Icon name="share" />
</span>
</TooltipWrapper>
)}
{canEdit && (
<TooltipWrapper
label="edit-action"
tooltip={t('Edit')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleEdit}
>
<Icon name="edit-alt" />
</span>
</TooltipWrapper>
)}
</span>
);
},
Header: t('Actions'),
id: 'actions',
hidden: !canEdit && !canDelete && !canExport,
disableSortBy: true,
},
],
[canEdit, canDelete, canExport, favoriteStatus],
);
const filters: Filters = [
{
Header: t('Owner'),
id: 'owners',
input: 'select',
operator: FilterOperators.relationManyMany,
unfilteredLabel: 'All',
fetchSelects: createFetchRelated(
'dashboard',
'owners',
createErrorHandler(errMsg =>
addDangerToast(
t(
'An error occurred while fetching dashboard owner values: %s',
errMsg,
),
),
),
props.user.userId,
),
paginate: true,
},
{
Header: t('Created by'),
id: 'created_by',
input: 'select',
operator: FilterOperators.relationOneMany,
unfilteredLabel: 'All',
fetchSelects: createFetchRelated(
'dashboard',
'created_by',
createErrorHandler(errMsg =>
addDangerToast(
t(
'An error occurred while fetching dashboard created by values: %s',
errMsg,
),
),
),
props.user.userId,
),
paginate: true,
},
{
Header: t('Status'),
id: 'published',
input: 'select',
operator: FilterOperators.equals,
unfilteredLabel: 'Any',
selects: [
{ label: t('Published'), value: true },
{ label: t('Unpublished'), value: false },
],
},
{
Header: t('Favorite'),
id: 'id',
urlDisplay: 'favorite',
input: 'select',
operator: FilterOperators.dashboardIsFav,
unfilteredLabel: 'Any',
selects: [
{ label: t('Yes'), value: true },
{ label: t('No'), value: false },
],
},
{
Header: t('Search'),
id: 'dashboard_title',
input: 'search',
operator: FilterOperators.titleOrSlug,
},
];
const sortTypes = [
{
desc: false,
id: 'dashboard_title',
label: 'Alphabetical',
value: 'alphabetical',
},
{
desc: true,
id: 'changed_on_delta_humanized',
label: 'Recently modified',
value: 'recently_modified',
},
{
desc: false,
id: 'changed_on_delta_humanized',
label: 'Least recently modified',
value: 'least_recently_modified',
},
];
function renderCard(dashboard: Dashboard) {
return (
<DashboardCard
dashboard={dashboard}
hasPerm={hasPerm}
bulkSelectEnabled={bulkSelectEnabled}
refreshData={refreshData}
loading={loading}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
openDashboardEditModal={openDashboardEditModal}
saveFavoriteStatus={saveFavoriteStatus}
favoriteStatus={favoriteStatus[dashboard.id]}
/>
);
}
const subMenuButtons: SubMenuProps['buttons'] = [];
if (canDelete || canExport) {
subMenuButtons.push({
name: t('Bulk select'),
buttonStyle: 'secondary',
onClick: toggleBulkSelect,
});
}
if (canCreate) {
subMenuButtons.push({
name: (
<>
<i className="fa fa-plus" /> {t('Dashboard')}
</>
),
buttonStyle: 'primary',
onClick: () => {
window.location.assign('/dashboard/new');
},
});
}
if (isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT)) {
subMenuButtons.push({
name: <Icon name="import" />,
buttonStyle: 'link',
onClick: openDashboardImportModal,
});
}
return (
<>
<SubMenu name={t('Dashboards')} buttons={subMenuButtons} />
<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<Dashboard>
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}
defaultViewMode={
isFeatureEnabled(FeatureFlag.LISTVIEWS_DEFAULT_CARD_VIEW)
? 'card'
: 'table'
}
/>
</>
);
}}
</ConfirmStatusChange>
<ImportModelsModal
resourceName="dashboard"
resourceLabel={t('dashboard')}
passwordsNeededMessage={PASSWORDS_NEEDED_MESSAGE}
confirmOverwriteMessage={CONFIRM_OVERWRITE_MESSAGE}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
onModelImport={handleDashboardImport}
show={importingDashboard}
onHide={closeDashboardImportModal}
passwordFields={passwordFields}
setPasswordFields={setPasswordFields}
/>
</>
);
}
export default withToasts(DashboardList);