/** * 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 React, { forwardRef, ReactNode, RefObject, useCallback, useImperativeHandle, useState, } from 'react'; import ReactDOM from 'react-dom'; import { useDispatch, useSelector } from 'react-redux'; import { Behavior, ContextMenuFilters, ensureIsArray, FeatureFlag, getChartMetadataRegistry, isFeatureEnabled, QueryFormData, t, useTheme, } from '@superset-ui/core'; import { RootState } from 'src/dashboard/types'; import { findPermission } from 'src/utils/findPermission'; import { Menu } from 'src/components/Menu'; import { AntdDropdown as Dropdown } from 'src/components/index'; import { updateDataMask } from 'src/dataMask/actions'; import { DrillDetailMenuItems } from '../DrillDetail'; import { getMenuAdjustedY } from '../utils'; import { MenuItemTooltip } from '../DisabledMenuItemTooltip'; import { DrillByMenuItems } from '../DrillBy/DrillByMenuItems'; export enum ContextMenuItem { CrossFilter, DrillToDetail, DrillBy, All, } export interface ChartContextMenuProps { id: number; formData: QueryFormData; onSelection: () => 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 canExplore = useSelector((state: RootState) => findPermission('can_explore', 'Superset', state.user?.roles), ); const canViewDrill = useSelector((state: RootState) => findPermission('can_view_and_drill', 'Dashboard', state.user?.roles), ); const canDatasourceSamples = useSelector((state: RootState) => findPermission('can_samples', 'Datasource', state.user?.roles), ); const crossFiltersEnabled = useSelector( ({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled, ); 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 menuItems = []; const canExploreOrView = canExplore || canViewDrill; const showDrillToDetail = isFeatureEnabled(FeatureFlag.DrillToDetail) && canExploreOrView && canDatasourceSamples && isDisplayed(ContextMenuItem.DrillToDetail); const showDrillBy = isFeatureEnabled(FeatureFlag.DrillBy) && canExploreOrView && isDisplayed(ContextMenuItem.DrillBy); const showCrossFilters = isFeatureEnabled(FeatureFlag.DashboardCrossFilters) && 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( {menuItems.length ? ( menuItems ) : ( No actions )} } trigger={['click']} onVisibleChange={value => !value && onClose()} > , document.body, ); }; export default forwardRef(ChartContextMenu);