/** * 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. */ // Test comment for pre-commit import { forwardRef, ReactNode, RefObject, useCallback, useImperativeHandle, useMemo, useState, } from 'react'; import ReactDOM from 'react-dom'; import { useDispatch, useSelector } from 'react-redux'; import { t } from '@apache-superset/core/translation'; import { Behavior, BinaryQueryObjectFilterClause, Column, ContextMenuFilters, ensureIsArray, FeatureFlag, getChartMetadataRegistry, getExtensionsRegistry, isFeatureEnabled, QueryFormData, } from '@superset-ui/core'; import { useTheme } from '@apache-superset/core/theme'; import { RootState } from 'src/dashboard/types'; import { MenuItem } 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 { useDatasetDrillInfo } from 'src/hooks/apiResources/datasets'; import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; import { useDrillDetailMenuItems } from '../useDrillDetailMenuItems'; import { getMenuAdjustedY } from '../utils'; import { DrillBySubmenu } from '../DrillBy/DrillBySubmenu'; import DrillDetailModal from '../DrillDetail/DrillDetailModal'; import { MenuItemTooltip } from '../DisabledMenuItemTooltip'; import { Dataset } from '../types'; 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 dispatch = useDispatch(); const theme = useTheme(); const { canDrillToDetail, canDrillBy, canDownload } = usePermissions(); const crossFiltersEnabled = useSelector( ({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled, ); const dashboardId = useSelector( ({ dashboardInfo }) => dashboardInfo.id, ); 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 }); // Extract matrixifyContext if present and merge cell filters const enhancedFilters = useMemo(() => { if (!filters) return filters; // Check if this is from a matrixified cell const matrixifyContext = (filters as any)?.matrixifyContext; if (!matrixifyContext) return filters; // Merge cell filters with drill filters const enhancedDrillBy = filters.drillBy ? { ...filters.drillBy, filters: [ ...(filters.drillBy.filters || []), ...(matrixifyContext.cellFilters || []), ], } : undefined; return { ...filters, drillBy: enhancedDrillBy, }; }, [filters]); // Use cell's formData for drill-to-detail if from matrixified cell const drillFormData = useMemo(() => { const matrixifyContext = (filters as any)?.matrixifyContext; // If this is from a matrixified cell, use the cell's formData which includes adhoc_filters return matrixifyContext?.cellFormData || formData; }, [filters, formData]); const [drillModalIsOpen, setDrillModalIsOpen] = useState(false); const [drillByColumn, setDrillByColumn] = useState(); // Drill by config as selected in the submenu (e.g. with the chosen // x-axis/series filter scope applied), used over the raw context filters const [selectedDrillByConfig, setSelectedDrillByConfig] = useState(); const [showDrillByModal, setShowDrillByModal] = useState(false); const closeContextMenu = useCallback(() => { setVisible(false); onClose(); }, [onClose]); const handleDrillBy = useCallback( ( column: Column, _dataset: Dataset, drillByConfig?: ContextMenuFilters['drillBy'], ) => { setDrillByColumn(column); setSelectedDrillByConfig(drillByConfig); setShowDrillByModal(true); }, [], ); const loadDrillByOptionsExtension = getExtensionsRegistry().get( 'load.drillby.options', ); const handleCloseDrillByModal = useCallback(() => { setShowDrillByModal(false); }, []); const drillByModalConfig = selectedDrillByConfig ?? enhancedFilters?.drillBy; const menuItems: MenuItem[] = []; const showDrillToDetail = isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail && isDisplayed(ContextMenuItem.DrillToDetail); const showDrillBy = isFeatureEnabled(FeatureFlag.DrillBy) && canDrillBy && isDisplayed(ContextMenuItem.DrillBy) && !( formData.matrixify_enable === true && ((formData.matrixify_mode_rows !== undefined && formData.matrixify_mode_rows !== 'disabled') || (formData.matrixify_mode_columns !== undefined && formData.matrixify_mode_columns !== 'disabled')) ); // Disable drill by when matrixify is enabled const datasetResource = useDatasetDrillInfo( formData.datasource, dashboardId, formData, !canDrillToDetail && !canDrillBy, ); const isLoadingDataset = datasetResource.status === ResourceStatus.Loading; // Compute filteredDataset with all columns returned + a filtered list of valid drillable options const filteredDataset = useMemo(() => { // Short circuit if still loading if (datasetResource.status !== ResourceStatus.Complete) { return undefined; } // No need to filter the dataset if Drill By is not allowed if (!showDrillBy) { return datasetResource.result; } const dataset = datasetResource.result; 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, }; }, [ datasetResource.status, datasetResource.result, showDrillBy, enhancedFilters?.drillBy?.groupbyFieldName, formData.x_axis, formData[enhancedFilters?.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 } const drillDetailMenuItems = useDrillDetailMenuItems({ formData: drillFormData, filters: filters?.drillToDetail, setFilters, isContextMenu: true, contextMenuY: clientY, onSelection, submenuIndex: showCrossFilters ? 2 : 1, setShowModal: setDrillModalIsOpen, dataset: filteredDataset, isLoadingDataset, ...additionalConfig?.drillToDetail, }); 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( { key: 'cross-filtering-menu-item', label: filters?.crossFilter?.isCurrentValueSelected ? ( t('Remove cross-filter') ) : ( {t('Add cross-filter')} ), disabled: isCrossFilterDisabled, onClick: () => { if (filters?.crossFilter) { dispatch(updateDataMask(id, filters.crossFilter.dataMask)); } }, }, ...(itemsCount > 1 ? [{ key: 'divider-1', type: 'divider' as const }] : []), ); } if (showDrillToDetail) { menuItems.push(...drillDetailMenuItems); } if (showDrillBy) { if (menuItems.length > 0) { menuItems.push({ key: 'divider-drill-by', type: 'divider' as const }); } const hasDrillBy = enhancedFilters?.drillBy?.groupbyFieldName; const handlesDimensionContextMenu = getChartMetadataRegistry() .get(formData.viz_type) ?.behaviors.find(behavior => behavior === Behavior.DrillBy); const isDrillByDisabled = !handlesDimensionContextMenu || !hasDrillBy; // Add a custom render component for DrillBy submenu to support react-window menuItems.push({ key: 'drill-by-submenu', disabled: isDrillByDisabled, label: ( ), }); } 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( <> 0 ? menuItems : [{ key: 'no-actions', label: t('No actions'), disabled: true }], onClick: () => { setVisible(false); onClose(); }, }} dropdownRender={menu => (
{menu}
)} trigger={['click']} onOpenChange={value => { setVisible(value); if (!value) { onClose(); } }} open={visible} >
{showDrillToDetail && ( { setDrillModalIsOpen(false); }} dataset={filteredDataset} /> )} {showDrillByModal && drillByColumn && filteredDataset && drillByModalConfig && ( )} , document.body, ); }; export default forwardRef(ChartContextMenu);