| /** |
| * 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, getChartMetadataRegistry, t } from '@superset-ui/core'; |
| import React, { useMemo, useState } from 'react'; |
| import rison from 'rison'; |
| import { uniqBy } from 'lodash'; |
| import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; |
| import { |
| createFetchRelated, |
| createErrorHandler, |
| handleBulkChartExport, |
| handleChartDelete, |
| } from 'src/views/CRUD/utils'; |
| import { |
| useListViewResource, |
| useFavoriteStatus, |
| useChartEditModal, |
| } from 'src/views/CRUD/hooks'; |
| import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; |
| import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu'; |
| import Icon from 'src/components/Icon'; |
| import FaveStar from 'src/components/FaveStar'; |
| import ListView, { |
| ListViewProps, |
| Filters, |
| SelectOption, |
| FilterOperators, |
| } from 'src/components/ListView'; |
| import withToasts from 'src/messageToasts/enhancers/withToasts'; |
| import PropertiesModal from 'src/explore/components/PropertiesModal'; |
| import ImportModelsModal from 'src/components/ImportModal/index'; |
| import Chart from 'src/types/Chart'; |
| import TooltipWrapper from 'src/components/TooltipWrapper'; |
| import ChartCard from './ChartCard'; |
| |
| 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 charts. 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 charts that already exist. ' + |
| 'Overwriting might cause you to lose some of your work. Are you ' + |
| 'sure you want to overwrite?', |
| ); |
| |
| const registry = getChartMetadataRegistry(); |
| |
| const createFetchDatasets = (handleError: (err: Response) => void) => async ( |
| filterValue = '', |
| pageIndex?: number, |
| pageSize?: number, |
| ) => { |
| // add filters if filterValue |
| const filters = filterValue |
| ? { filters: [{ col: 'table_name', opr: 'sw', value: filterValue }] } |
| : {}; |
| try { |
| const queryParams = rison.encode({ |
| columns: ['datasource_name', 'datasource_id'], |
| keys: ['none'], |
| order_column: 'table_name', |
| order_direction: 'asc', |
| ...(pageIndex ? { page: pageIndex } : {}), |
| ...(pageSize ? { page_size: pageSize } : {}), |
| ...filters, |
| }); |
| |
| const { json = {} } = await SupersetClient.get({ |
| endpoint: `/api/v1/dataset/?q=${queryParams}`, |
| }); |
| |
| const datasets = json?.result?.map( |
| ({ table_name: tableName, id }: { table_name: string; id: number }) => ({ |
| label: tableName, |
| value: id, |
| }), |
| ); |
| |
| return uniqBy<SelectOption>(datasets, 'value'); |
| } catch (e) { |
| handleError(e); |
| } |
| return []; |
| }; |
| |
| interface ChartListProps { |
| addDangerToast: (msg: string) => void; |
| addSuccessToast: (msg: string) => void; |
| user: { |
| userId: string | number; |
| }; |
| } |
| |
| function ChartList(props: ChartListProps) { |
| const { addDangerToast, addSuccessToast } = props; |
| |
| const { |
| state: { |
| loading, |
| resourceCount: chartCount, |
| resourceCollection: charts, |
| bulkSelectEnabled, |
| }, |
| setResourceCollection: setCharts, |
| hasPerm, |
| fetchData, |
| toggleBulkSelect, |
| refreshData, |
| } = useListViewResource<Chart>('chart', t('chart'), addDangerToast); |
| |
| const chartIds = useMemo(() => charts.map(c => c.id), [charts]); |
| |
| const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus( |
| 'chart', |
| chartIds, |
| addDangerToast, |
| ); |
| const { |
| sliceCurrentlyEditing, |
| handleChartUpdated, |
| openChartEditModal, |
| closeChartEditModal, |
| } = useChartEditModal(setCharts, charts); |
| |
| const [importingChart, showImportModal] = useState<boolean>(false); |
| const [passwordFields, setPasswordFields] = useState<string[]>([]); |
| |
| const openChartImportModal = () => { |
| showImportModal(true); |
| }; |
| |
| const closeChartImportModal = () => { |
| showImportModal(false); |
| }; |
| |
| const handleChartImport = () => { |
| showImportModal(false); |
| refreshData(); |
| }; |
| |
| const canCreate = hasPerm('can_write'); |
| const canEdit = hasPerm('can_write'); |
| const canDelete = hasPerm('can_write'); |
| const canExport = |
| hasPerm('can_read') && isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT); |
| const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }]; |
| |
| function handleBulkChartDelete(chartsToDelete: Chart[]) { |
| SupersetClient.delete({ |
| endpoint: `/api/v1/chart/?q=${rison.encode( |
| chartsToDelete.map(({ id }) => id), |
| )}`, |
| }).then( |
| ({ json = {} }) => { |
| refreshData(); |
| addSuccessToast(json.message); |
| }, |
| createErrorHandler(errMsg => |
| addDangerToast( |
| t('There was an issue deleting the selected charts: %s', 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, slice_name: sliceName }, |
| }, |
| }: any) => ( |
| <a href={url} data-test={`${sliceName}-list-chart-title`}> |
| {sliceName} |
| </a> |
| ), |
| Header: t('Chart'), |
| accessor: 'slice_name', |
| }, |
| { |
| Cell: ({ |
| row: { |
| original: { viz_type: vizType }, |
| }, |
| }: any) => registry.get(vizType)?.name || vizType, |
| Header: t('Visualization type'), |
| accessor: 'viz_type', |
| size: 'xxl', |
| }, |
| { |
| Cell: ({ |
| row: { |
| original: { |
| datasource_name_text: dsNameTxt, |
| datasource_url: dsUrl, |
| }, |
| }, |
| }: any) => <a href={dsUrl}>{dsNameTxt}</a>, |
| Header: t('Dataset'), |
| accessor: 'datasource_id', |
| disableSortBy: true, |
| size: 'xl', |
| }, |
| { |
| 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: { changed_on_delta_humanized: changedOn }, |
| }, |
| }: any) => <span className="no-wrap">{changedOn}</span>, |
| Header: t('Last modified'), |
| accessor: 'changed_on_delta_humanized', |
| size: 'xl', |
| }, |
| { |
| accessor: 'owners', |
| hidden: true, |
| disableSortBy: true, |
| }, |
| { |
| 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 } }: any) => { |
| const handleDelete = () => |
| handleChartDelete( |
| original, |
| addSuccessToast, |
| addDangerToast, |
| refreshData, |
| ); |
| const openEditModal = () => openChartEditModal(original); |
| const handleExport = () => handleBulkChartExport([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.slice_name}</b>? |
| </> |
| } |
| onConfirm={handleDelete} |
| > |
| {confirmDelete => ( |
| <TooltipWrapper |
| label="delete-action" |
| tooltip={t('Delete')} |
| placement="bottom" |
| > |
| <span |
| role="button" |
| tabIndex={0} |
| className="action-button" |
| onClick={confirmDelete} |
| > |
| <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={openEditModal} |
| > |
| <Icon name="edit-alt" /> |
| </span> |
| </TooltipWrapper> |
| )} |
| </span> |
| ); |
| }, |
| Header: t('Actions'), |
| id: 'actions', |
| disableSortBy: true, |
| hidden: !canEdit && !canDelete, |
| }, |
| ], |
| [canEdit, canDelete, canExport, favoriteStatus], |
| ); |
| |
| const filters: Filters = [ |
| { |
| Header: t('Owner'), |
| id: 'owners', |
| input: 'select', |
| operator: FilterOperators.relationManyMany, |
| unfilteredLabel: 'All', |
| fetchSelects: createFetchRelated( |
| 'chart', |
| 'owners', |
| createErrorHandler(errMsg => |
| addDangerToast( |
| t( |
| 'An error occurred while fetching chart owners values: %s', |
| errMsg, |
| ), |
| ), |
| ), |
| props.user.userId, |
| ), |
| paginate: true, |
| }, |
| { |
| Header: t('Created by'), |
| id: 'created_by', |
| input: 'select', |
| operator: FilterOperators.relationOneMany, |
| unfilteredLabel: 'All', |
| fetchSelects: createFetchRelated( |
| 'chart', |
| 'created_by', |
| createErrorHandler(errMsg => |
| addDangerToast( |
| t( |
| 'An error occurred while fetching chart created by values: %s', |
| errMsg, |
| ), |
| ), |
| ), |
| props.user.userId, |
| ), |
| paginate: true, |
| }, |
| { |
| Header: t('Viz type'), |
| id: 'viz_type', |
| input: 'select', |
| operator: FilterOperators.equals, |
| unfilteredLabel: 'All', |
| selects: registry |
| .keys() |
| .map(k => ({ label: registry.get(k)?.name || k, value: k })) |
| .sort((a, b) => { |
| if (!a.label || !b.label) { |
| return 0; |
| } |
| |
| if (a.label > b.label) { |
| return 1; |
| } |
| if (a.label < b.label) { |
| return -1; |
| } |
| |
| return 0; |
| }), |
| }, |
| { |
| Header: t('Dataset'), |
| id: 'datasource_id', |
| input: 'select', |
| operator: FilterOperators.equals, |
| unfilteredLabel: 'All', |
| fetchSelects: createFetchDatasets( |
| createErrorHandler(errMsg => |
| addDangerToast( |
| t( |
| 'An error occurred while fetching chart dataset values: %s', |
| errMsg, |
| ), |
| ), |
| ), |
| ), |
| paginate: false, |
| }, |
| { |
| Header: t('Favorite'), |
| id: 'id', |
| urlDisplay: 'favorite', |
| input: 'select', |
| operator: FilterOperators.chartIsFav, |
| unfilteredLabel: 'Any', |
| selects: [ |
| { label: t('Yes'), value: true }, |
| { label: t('No'), value: false }, |
| ], |
| }, |
| { |
| Header: t('Search'), |
| id: 'slice_name', |
| input: 'search', |
| operator: FilterOperators.chartAllText, |
| }, |
| ]; |
| |
| const sortTypes = [ |
| { |
| desc: false, |
| id: 'slice_name', |
| 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(chart: Chart) { |
| return ( |
| <ChartCard |
| chart={chart} |
| hasPerm={hasPerm} |
| openChartEditModal={openChartEditModal} |
| bulkSelectEnabled={bulkSelectEnabled} |
| addDangerToast={addDangerToast} |
| addSuccessToast={addSuccessToast} |
| refreshData={refreshData} |
| loading={loading} |
| favoriteStatus={favoriteStatus[chart.id]} |
| saveFavoriteStatus={saveFavoriteStatus} |
| /> |
| ); |
| } |
| 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('Chart')} |
| </> |
| ), |
| buttonStyle: 'primary', |
| onClick: () => { |
| window.location.assign('/chart/add'); |
| }, |
| }); |
| } |
| if (isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT)) { |
| subMenuButtons.push({ |
| name: <Icon name="import" />, |
| buttonStyle: 'link', |
| onClick: openChartImportModal, |
| }); |
| } |
| return ( |
| <> |
| <SubMenu name={t('Charts')} buttons={subMenuButtons} /> |
| {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'] = []; |
| 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: handleBulkChartExport, |
| }); |
| } |
| return ( |
| <ListView<Chart> |
| 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} |
| defaultViewMode={ |
| isFeatureEnabled(FeatureFlag.LISTVIEWS_DEFAULT_CARD_VIEW) |
| ? 'card' |
| : 'table' |
| } |
| /> |
| ); |
| }} |
| </ConfirmStatusChange> |
| |
| <ImportModelsModal |
| resourceName="chart" |
| resourceLabel={t('chart')} |
| passwordsNeededMessage={PASSWORDS_NEEDED_MESSAGE} |
| confirmOverwriteMessage={CONFIRM_OVERWRITE_MESSAGE} |
| addDangerToast={addDangerToast} |
| addSuccessToast={addSuccessToast} |
| onModelImport={handleChartImport} |
| show={importingChart} |
| onHide={closeChartImportModal} |
| passwordFields={passwordFields} |
| setPasswordFields={setPasswordFields} |
| /> |
| </> |
| ); |
| } |
| |
| export default withToasts(ChartList); |