/** * 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 { forwardRef, Key, ReactNode, RefObject, useCallback, useEffect, useImperativeHandle, useMemo, useState, } from 'react'; import ReactDOM from 'react-dom'; import { useDispatch, useSelector } from 'react-redux'; import { Behavior, BinaryQueryObjectFilterClause, Column, ContextMenuFilters, ensureIsArray, FeatureFlag, getChartMetadataRegistry, getExtensionsRegistry, isFeatureEnabled, logging, QueryFormData, t, useTheme, } from '@superset-ui/core'; import { RootState } from 'src/dashboard/types'; import { Menu } from '@superset-ui/core/components/Menu'; import { usePermissions } from 'src/hooks/usePermissions'; import { Dropdown } from '@superset-ui/core/components'; import { updateDataMask } from 'src/dataMask/actions'; import DrillByModal from 'src/components/Chart/DrillBy/DrillByModal'; import { useVerboseMap } from 'src/hooks/apiResources/datasets'; import { Dataset } from 'src/components/Chart/types'; import { cachedSupersetGet, supersetGetCache, } from 'src/utils/cachedSupersetGet'; import { DrillDetailMenuItems } from '../DrillDetail'; import { getMenuAdjustedY } from '../utils'; import { MenuItemTooltip } from '../DisabledMenuItemTooltip'; import { DrillByMenuItems } from '../DrillBy/DrillByMenuItems'; import DrillDetailModal from '../DrillDetail/DrillDetailModal'; export enum ContextMenuItem { CrossFilter, DrillToDetail, DrillBy, All, } export interface ChartContextMenuProps { id: number; formData: QueryFormData; onSelection: (args?: any) => void; onClose: () => void; additionalConfig?: { crossFilter?: Record; drillToDetail?: Record; drillBy?: Record; }; displayedItems?: ContextMenuItem[] | ContextMenuItem; } export interface ChartContextMenuRef { open: ( clientX: number, clientY: number, filters?: ContextMenuFilters, ) => void; } const ChartContextMenu = ( { id, formData, onSelection, onClose, displayedItems = ContextMenuItem.All, additionalConfig, }: ChartContextMenuProps, ref: RefObject, ) => { const theme = useTheme(); const dispatch = useDispatch(); const { canDrillToDetail, canDrillBy, canDownload } = usePermissions(); const crossFiltersEnabled = useSelector( ({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled, ); const dashboardId = useSelector( ({ dashboardInfo }) => dashboardInfo.id, ); const [openKeys, setOpenKeys] = useState([]); const [modalFilters, setFilters] = useState( [], ); const [visible, setVisible] = useState(false); const isDisplayed = (item: ContextMenuItem) => displayedItems === ContextMenuItem.All || ensureIsArray(displayedItems).includes(item); const [{ filters, clientX, clientY }, setState] = useState<{ clientX: number; clientY: number; filters?: ContextMenuFilters; }>({ clientX: 0, clientY: 0 }); const [drillModalIsOpen, setDrillModalIsOpen] = useState(false); const [drillByColumn, setDrillByColumn] = useState(); const [showDrillByModal, setShowDrillByModal] = useState(false); const [dataset, setDataset] = useState(); const [isLoadingDataset, setIsLoadingDataset] = useState(false); const verboseMap = useVerboseMap(dataset); const closeContextMenu = useCallback(() => { setVisible(false); setOpenKeys([]); onClose(); }, [onClose]); const handleDrillBy = useCallback((column: Column) => { setDrillByColumn(column); setShowDrillByModal(true); }, []); const loadDrillByOptionsExtension = getExtensionsRegistry().get( 'load.drillby.options', ); const handleCloseDrillByModal = useCallback(() => { setShowDrillByModal(false); }, []); const menuItems: React.JSX.Element[] = []; const showDrillToDetail = isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail && isDisplayed(ContextMenuItem.DrillToDetail); const showDrillBy = isFeatureEnabled(FeatureFlag.DrillBy) && canDrillBy && isDisplayed(ContextMenuItem.DrillBy); useEffect(() => { async function fetchDataset() { if (!visible || dataset || (!showDrillBy && !showDrillToDetail)) return; const datasetId = Number(formData.datasource.split('__')[0]); try { setIsLoadingDataset(true); let response; if (loadDrillByOptionsExtension) { response = await loadDrillByOptionsExtension(datasetId, formData); } else { const endpoint = `/api/v1/dataset/${datasetId}/drill_info/?q=(dashboard_id:${dashboardId})`; response = await cachedSupersetGet({ endpoint }); } const { json } = response; const { result } = json; setDataset(result); } catch (error) { logging.error('Failed to load dataset:', error); supersetGetCache.delete(`/api/v1/dataset/${datasetId}/drill_info/`); } finally { setIsLoadingDataset(false); } } fetchDataset(); }, [ visible, showDrillBy, showDrillToDetail, formData.datasource, loadDrillByOptionsExtension, dashboardId, ]); // Compute filteredDataset with all columns returned + a filtered list of valid drillable options const filteredDataset = useMemo(() => { if (!dataset || !showDrillBy) return dataset; const filteredColumns = ensureIsArray(dataset.columns).filter( column => // If using an extension, also filter by column.groupby since the extension might not do this (!loadDrillByOptionsExtension || column.groupby) && !ensureIsArray( formData[filters?.drillBy?.groupbyFieldName ?? ''], ).includes(column.column_name) && column.column_name !== formData.x_axis && ensureIsArray(additionalConfig?.drillBy?.excludedColumns)?.every( excludedCol => excludedCol.column_name !== column.column_name, ), ); return { ...dataset, drillable_columns: filteredColumns, }; }, [ dataset, showDrillBy, filters?.drillBy?.groupbyFieldName, formData.x_axis, formData[filters?.drillBy?.groupbyFieldName ?? ''], additionalConfig?.drillBy?.excludedColumns, loadDrillByOptionsExtension, ]); const showCrossFilters = isDisplayed(ContextMenuItem.CrossFilter); const isCrossFilteringSupportedByChart = getChartMetadataRegistry() .get(formData.viz_type) ?.behaviors?.includes(Behavior.InteractiveChart); let itemsCount = 0; if (showCrossFilters) { itemsCount += 1; } if (showDrillToDetail) { itemsCount += 2; // Drill to detail always has 2 top-level menu items } if (showDrillBy) { itemsCount += 1; } if (itemsCount === 0) { itemsCount = 1; // "No actions" appears if no actions in menu } if (showCrossFilters) { const isCrossFilterDisabled = !isCrossFilteringSupportedByChart || !crossFiltersEnabled || !filters?.crossFilter; let crossFilteringTooltipTitle: ReactNode = null; if (!isCrossFilterDisabled) { crossFilteringTooltipTitle = ( <>
{t( 'Cross-filter will be applied to all of the charts that use this dataset.', )}
{t('You can also just click on the chart to apply cross-filter.')}
); } else if (!crossFiltersEnabled) { crossFilteringTooltipTitle = ( <>
{t('Cross-filtering is not enabled for this dashboard.')}
); } else if (!isCrossFilteringSupportedByChart) { crossFilteringTooltipTitle = ( <>
{t('This visualization type does not support cross-filtering.')}
); } else if (!filters?.crossFilter) { crossFilteringTooltipTitle = ( <>
{t(`You can't apply cross-filter on this data point.`)}
); } menuItems.push( <> { if (filters?.crossFilter) { dispatch(updateDataMask(id, filters.crossFilter.dataMask)); } }} > {filters?.crossFilter?.isCurrentValueSelected ? ( t('Remove cross-filter') ) : (
{t('Add cross-filter')}
)}
{itemsCount > 1 && } , ); } if (showDrillToDetail) { menuItems.push( , ); } if (showDrillBy) { let submenuIndex = 0; if (showCrossFilters) { submenuIndex += 1; } if (showDrillToDetail) { submenuIndex += 2; } menuItems.push( , ); } const open = useCallback( (clientX: number, clientY: number, filters?: ContextMenuFilters) => { const adjustedY = getMenuAdjustedY(clientY, itemsCount); setState({ clientX, clientY: adjustedY, filters, }); // Since Ant Design's Dropdown does not offer an imperative API // and we can't attach event triggers to charts SVG elements, we // use a hidden span that gets clicked on when receiving click events // from the charts. document.getElementById(`hidden-span-${id}`)?.click(); }, [id, itemsCount], ); useImperativeHandle( ref, () => ({ open, }), [open], ); return ReactDOM.createPortal( <> ( { setVisible(false); setOpenKeys([]); onClose(); }} > {menuItems.length ? ( menuItems ) : ( {t('No actions')} )} )} trigger={['click']} onOpenChange={value => { setVisible(value); if (!value) { setOpenKeys([]); } }} open={visible} > {showDrillToDetail && ( { setDrillModalIsOpen(false); }} dataset={filteredDataset} /> )} {showDrillByModal && drillByColumn && dataset && filters?.drillBy && ( )} , document.body, ); }; export default forwardRef(ChartContextMenu);