diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChart.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChart.tsx index 99d7b6dbec..098fd48365 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChart.tsx +++ b/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChart.tsx @@ -86,6 +86,10 @@ export type Props = Omit & * If not defined, NoResultsComponent is used */ noResults?: ReactNode; + /** + * Determines is the context menu related to the chart is open + */ + inContextMenu?: boolean; }; type PropsWithDefault = Props & Readonly; diff --git a/superset-frontend/src/components/Chart/ChartContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx similarity index 80% rename from superset-frontend/src/components/Chart/ChartContextMenu.tsx rename to superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx index 3ddbc91b22..063ed787b1 100644 --- a/superset-frontend/src/components/Chart/ChartContextMenu.tsx +++ b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx @@ -29,6 +29,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { Behavior, ContextMenuFilters, + ensureIsArray, FeatureFlag, getChartMetadataRegistry, isFeatureEnabled, @@ -39,21 +40,33 @@ import { 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'; -import { DrillDetailMenuItems } from './DrillDetail'; -import { getMenuAdjustedY } from './utils'; -import { updateDataMask } from '../../dataMask/actions'; -import { MenuItemTooltip } from './DisabledMenuItemTooltip'; -import { DrillByMenuItems } from './DrillBy/DrillByMenuItems'; +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 Ref { +export interface ChartContextMenuRef { open: ( clientX: number, clientY: number, @@ -62,8 +75,15 @@ export interface Ref { } const ChartContextMenu = ( - { id, formData, onSelection, onClose }: ChartContextMenuProps, - ref: RefObject, + { + id, + formData, + onSelection, + onClose, + displayedItems = ContextMenuItem.All, + additionalConfig, + }: ChartContextMenuProps, + ref: RefObject, ) => { const theme = useTheme(); const dispatch = useDispatch(); @@ -74,6 +94,10 @@ const ChartContextMenu = ( ({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled, ); + const isDisplayed = (item: ContextMenuItem) => + displayedItems === ContextMenuItem.All || + ensureIsArray(displayedItems).includes(item); + const [{ filters, clientX, clientY }, setState] = useState<{ clientX: number; clientY: number; @@ -83,13 +107,19 @@ const ChartContextMenu = ( const menuItems = []; const showDrillToDetail = - isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) && canExplore; + isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) && + canExplore && + isDisplayed(ContextMenuItem.DrillToDetail); - const showDrillBy = isFeatureEnabled(FeatureFlag.DRILL_BY) && canExplore; + const showDrillBy = + isFeatureEnabled(FeatureFlag.DRILL_BY) && + canExplore && + isDisplayed(ContextMenuItem.DrillBy); + + const showCrossFilters = + isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS) && + isDisplayed(ContextMenuItem.CrossFilter); - const showCrossFilters = isFeatureEnabled( - FeatureFlag.DASHBOARD_CROSS_FILTERS, - ); const isCrossFilteringSupportedByChart = getChartMetadataRegistry() .get(formData.viz_type) ?.behaviors?.includes(Behavior.INTERACTIVE_CHART); @@ -108,7 +138,7 @@ const ChartContextMenu = ( itemsCount = 1; // "No actions" appears if no actions in menu } - if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { + if (showCrossFilters) { const isCrossFilterDisabled = !isCrossFilteringSupportedByChart || !crossFiltersEnabled || @@ -190,6 +220,7 @@ const ChartContextMenu = ( contextMenuY={clientY} onSelection={onSelection} submenuIndex={showCrossFilters ? 2 : 1} + {...(additionalConfig?.drillToDetail || {})} />, ); } @@ -205,9 +236,11 @@ const ChartContextMenu = ( , ); } @@ -241,7 +274,7 @@ const ChartContextMenu = ( return ReactDOM.createPortal( + {menuItems.length ? ( menuItems ) : ( diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.test.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.test.tsx new file mode 100644 index 0000000000..ebab36b14f --- /dev/null +++ b/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.test.tsx @@ -0,0 +1,86 @@ +/** + * 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 { FeatureFlag } from '@superset-ui/core'; +import { render, screen } from 'spec/helpers/testing-library'; +import { renderHook } from '@testing-library/react-hooks'; +import mockState from 'spec/fixtures/mockState'; +import { sliceId } from 'spec/fixtures/mockChartQueries'; +import { noOp } from 'src/utils/common'; +import { useContextMenu } from './useContextMenu'; +import { ContextMenuItem } from './ChartContextMenu'; + +const CONTEXT_MENU_TEST_ID = 'chart-context-menu'; + +// @ts-ignore +global.featureFlags = { + [FeatureFlag.DASHBOARD_CROSS_FILTERS]: true, + [FeatureFlag.DRILL_TO_DETAIL]: true, + [FeatureFlag.DRILL_BY]: true, +}; + +const setup = ({ + onSelection = noOp, + displayedItems = ContextMenuItem.All, + additionalConfig = {}, +}: { + onSelection?: () => void; + displayedItems?: ContextMenuItem | ContextMenuItem[]; + additionalConfig?: Record; +} = {}) => { + const { result } = renderHook(() => + useContextMenu( + sliceId, + { datasource: '1__table', viz_type: 'pie' }, + onSelection, + displayedItems, + additionalConfig, + ), + ); + render(result.current.contextMenu, { + useRedux: true, + initialState: { + ...mockState, + user: { + ...mockState.user, + roles: { Admin: [['can_explore', 'Superset']] }, + }, + }, + }); + return result; +}; + +test('Context menu renders', () => { + const result = setup(); + expect(screen.queryByTestId(CONTEXT_MENU_TEST_ID)).not.toBeInTheDocument(); + result.current.onContextMenu(0, 0, {}); + expect(screen.getByTestId(CONTEXT_MENU_TEST_ID)).toBeInTheDocument(); + expect(screen.getByText('Add cross-filter')).toBeInTheDocument(); + expect(screen.getByText('Drill to detail')).toBeInTheDocument(); + expect(screen.getByText('Drill by')).toBeInTheDocument(); +}); + +test('Context menu contains all items only', () => { + const result = setup({ + displayedItems: [ContextMenuItem.DrillToDetail, ContextMenuItem.DrillBy], + }); + result.current.onContextMenu(0, 0, {}); + expect(screen.queryByText('Add cross-filter')).not.toBeInTheDocument(); + expect(screen.getByText('Drill to detail')).toBeInTheDocument(); + expect(screen.getByText('Drill by')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.tsx new file mode 100644 index 0000000000..1343eb31c3 --- /dev/null +++ b/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.tsx @@ -0,0 +1,82 @@ +/** + * 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, { useCallback, useMemo, useRef, useState } from 'react'; +import { BaseFormData, ContextMenuFilters } from '@superset-ui/core'; +import ChartContextMenu, { + ChartContextMenuRef, + ContextMenuItem, +} from './ChartContextMenu'; + +export const useContextMenu = ( + chartId: number, + formData: BaseFormData & { [key: string]: any }, + onSelection?: (...args: any) => void, + displayedItems?: ContextMenuItem[] | ContextMenuItem, + additionalConfig?: { + crossFilter?: Record; + drillToDetail?: Record; + drillBy?: Record; + }, +) => { + const contextMenuRef = useRef(null); + const [inContextMenu, setInContextMenu] = useState(false); + const onContextMenu = ( + offsetX: number, + offsetY: number, + filters: ContextMenuFilters, + ) => { + contextMenuRef.current?.open(offsetX, offsetY, filters); + setInContextMenu(true); + }; + + const handleContextMenuSelected = useCallback( + (...args: any) => { + setInContextMenu(false); + onSelection?.(...args); + }, + [onSelection], + ); + + const handleContextMenuClosed = useCallback(() => { + setInContextMenu(false); + }, []); + + const contextMenu = useMemo( + () => ( + + ), + [ + additionalConfig, + chartId, + displayedItems, + formData, + handleContextMenuClosed, + handleContextMenuSelected, + ], + ); + return { contextMenu, inContextMenu, onContextMenu }; +}; diff --git a/superset-frontend/src/components/Chart/ChartRenderer.jsx b/superset-frontend/src/components/Chart/ChartRenderer.jsx index fa2c0ddc0d..55f6b66df7 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.jsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.jsx @@ -31,7 +31,7 @@ import { import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils'; import { EmptyStateBig, EmptyStateSmall } from 'src/components/EmptyState'; import { ChartSource } from 'src/types/ChartSource'; -import ChartContextMenu from './ChartContextMenu'; +import ChartContextMenu from './ChartContextMenu/ChartContextMenu'; const propTypes = { annotationData: PropTypes.object, diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByChart.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByChart.test.tsx index 02d1754939..497e2bb065 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByChart.test.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByChart.test.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { render, screen, waitFor } from 'spec/helpers/testing-library'; import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries'; -import fetchMock from 'fetch-mock'; +import { noOp } from 'src/utils/common'; import DrillByChart from './DrillByChart'; const chart = chartQueries[sliceId]; @@ -28,6 +28,8 @@ const setup = (overrides: Record = {}, result?: any) => render( , { @@ -38,8 +40,6 @@ const setup = (overrides: Record = {}, result?: any) => const waitForRender = (overrides: Record = {}) => waitFor(() => setup(overrides)); -afterEach(fetchMock.restore); - test('should render', async () => { const { container } = await waitForRender(); expect(container).toBeInTheDocument(); diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByChart.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByChart.tsx index d19dbe9137..e69a201f5c 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByChart.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByChart.tsx @@ -16,21 +16,34 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { BaseFormData, - Behavior, QueryData, SuperChart, css, + ContextMenuFilters, } from '@superset-ui/core'; interface DrillByChartProps { formData: BaseFormData & { [key: string]: any }; result: QueryData[]; + onContextMenu: ( + offsetX: number, + offsetY: number, + filters: ContextMenuFilters, + ) => void; + inContextMenu: boolean; } -export default function DrillByChart({ formData, result }: DrillByChartProps) { +export default function DrillByChart({ + formData, + result, + onContextMenu, + inContextMenu, +}: DrillByChartProps) { + const hooks = useMemo(() => ({ onContextMenu }), [onContextMenu]); + return (
diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx index f22472e17a..b0e3bf62b5 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx @@ -32,7 +32,9 @@ import { DrillByMenuItems, DrillByMenuItemsProps } from './DrillByMenuItems'; /* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */ -const datasetEndpointMatcher = 'glob:*/api/v1/dataset/7'; +const DATASET_ENDPOINT = 'glob:*/api/v1/dataset/7'; +const CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*'; +const FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data'; const { form_data: defaultFormData } = chartQueries[sliceId]; const defaultColumns = [ @@ -60,6 +62,7 @@ const defaultFilters = [ const renderMenu = ({ formData = defaultFormData, filters = defaultFilters, + ...rest }: Partial) => render( @@ -67,6 +70,7 @@ const renderMenu = ({ formData={formData ?? defaultFormData} filters={filters} groupbyFieldName="groupby" + {...rest} /> , { useRouter: true, useRedux: true }, @@ -134,19 +138,19 @@ test('render disabled menu item for supported chart, no filters', async () => { }); test('render disabled menu item for supported chart, no columns', async () => { - fetchMock.get(datasetEndpointMatcher, { result: { columns: [] } }); + fetchMock.get(DATASET_ENDPOINT, { result: { columns: [] } }); renderMenu({}); - await waitFor(() => fetchMock.called(datasetEndpointMatcher)); + await waitFor(() => fetchMock.called(DATASET_ENDPOINT)); await expectDrillByDisabled('No dimensions available for drill by'); }); test('render menu item with submenu without searchbox', async () => { const slicedColumns = defaultColumns.slice(0, 9); - fetchMock.get(datasetEndpointMatcher, { + fetchMock.get(DATASET_ENDPOINT, { result: { columns: slicedColumns }, }); renderMenu({}); - await waitFor(() => fetchMock.called(datasetEndpointMatcher)); + await waitFor(() => fetchMock.called(DATASET_ENDPOINT)); await expectDrillByEnabled(); slicedColumns.forEach(column => { expect(screen.getByText(column.column_name)).toBeInTheDocument(); @@ -155,11 +159,11 @@ test('render menu item with submenu without searchbox', async () => { }); test('render menu item with submenu and searchbox', async () => { - fetchMock.get(datasetEndpointMatcher, { + fetchMock.get(DATASET_ENDPOINT, { result: { columns: defaultColumns }, }); renderMenu({}); - await waitFor(() => fetchMock.called(datasetEndpointMatcher)); + await waitFor(() => fetchMock.called(DATASET_ENDPOINT)); await expectDrillByEnabled(); defaultColumns.forEach(column => { expect(screen.getByText(column.column_name)).toBeInTheDocument(); @@ -184,3 +188,55 @@ test('render menu item with submenu and searchbox', async () => { expect(screen.getByText(colName)).toBeInTheDocument(); }); }); + +test('Do not display excluded column in the menu', async () => { + fetchMock.get(DATASET_ENDPOINT, { + result: { columns: defaultColumns }, + }); + + const excludedColNames = ['col3', 'col5']; + renderMenu({ + excludedColumns: excludedColNames.map(colName => ({ + column_name: colName, + })), + }); + + await waitFor(() => fetchMock.called(DATASET_ENDPOINT)); + await expectDrillByEnabled(); + + excludedColNames.forEach(colName => { + expect(screen.queryByText(colName)).not.toBeInTheDocument(); + }); + + defaultColumns + .filter(column => !excludedColNames.includes(column.column_name)) + .forEach(column => { + expect(screen.getByText(column.column_name)).toBeInTheDocument(); + }); +}); + +test('When menu item is clicked, call onSelection with clicked column and drill by filters', async () => { + fetchMock + .get(DATASET_ENDPOINT, { + result: { columns: defaultColumns }, + }) + .post(FORM_DATA_KEY_ENDPOINT, {}) + .post(CHART_DATA_ENDPOINT, {}); + + const onSelectionMock = jest.fn(); + renderMenu({ + onSelection: onSelectionMock, + }); + + await waitFor(() => fetchMock.called(DATASET_ENDPOINT)); + await expectDrillByEnabled(); + + userEvent.click(screen.getByText('col1')); + expect(onSelectionMock).toHaveBeenCalledWith( + { + column_name: 'col1', + groupby: true, + }, + defaultFilters, + ); +}); diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx index 4064006fac..9b57e2fdec 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx @@ -59,8 +59,10 @@ export interface DrillByMenuItemsProps { contextMenuY?: number; submenuIndex?: number; groupbyFieldName?: string; - onSelection?: () => void; + onSelection?: (...args: any) => void; onClick?: (event: MouseEvent) => void; + openNewModal?: boolean; + excludedColumns?: Column[]; } export const DrillByMenuItems = ({ @@ -71,6 +73,8 @@ export const DrillByMenuItems = ({ submenuIndex = 0, onSelection = () => {}, onClick = () => {}, + excludedColumns, + openNewModal = true, ...rest }: DrillByMenuItemsProps) => { const theme = useTheme(); @@ -80,14 +84,16 @@ export const DrillByMenuItems = ({ const [showModal, setShowModal] = useState(false); const [currentColumn, setCurrentColumn] = useState(); - const openModal = useCallback( + const handleSelection = useCallback( (event, column) => { onClick(event); - onSelection(); + onSelection(column, filters); setCurrentColumn(column); - setShowModal(true); + if (openNewModal) { + setShowModal(true); + } }, - [onClick, onSelection], + [filters, onClick, onSelection, openNewModal], ); const closeModal = useCallback(() => { setShowModal(false); @@ -142,12 +148,16 @@ export const DrillByMenuItems = ({ const filteredColumns = useMemo( () => - columns.filter(column => - (column.verbose_name || column.column_name) - .toLowerCase() - .includes(searchInput.toLowerCase()), + columns.filter( + column => + (column.verbose_name || column.column_name) + .toLowerCase() + .includes(searchInput.toLowerCase()) && + !ensureIsArray(excludedColumns)?.some( + col => col.column_name === column.column_name, + ), ), - [columns, searchInput], + [columns, excludedColumns, searchInput], ); const submenuYOffset = useMemo( @@ -231,7 +241,7 @@ export const DrillByMenuItems = ({ key={`drill-by-item-${column.column_name}`} tooltipText={column.verbose_name || column.column_name} {...rest} - onClick={e => openModal(e, column)} + onClick={e => handleSelection(e, column)} > {column.verbose_name || column.column_name} diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx index 776346705c..6a9a82b573 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx @@ -17,7 +17,13 @@ * under the License. */ -import React, { useContext, useEffect, useMemo, useState } from 'react'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import { BaseFormData, BinaryQueryObjectFilterClause, @@ -34,7 +40,7 @@ import Modal from 'src/components/Modal'; import Loading from 'src/components/Loading'; import Button from 'src/components/Button'; import { Radio } from 'src/components/Radio'; -import { DashboardLayout, RootState } from 'src/dashboard/types'; +import { RootState } from 'src/dashboard/types'; import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage'; import { postFormData } from 'src/explore/exploreUtils/formData'; import { noOp } from 'src/utils/common'; @@ -43,6 +49,8 @@ import { useDatasetMetadataBar } from 'src/features/datasets/metadataBar/useData import { SingleQueryResultPane } from 'src/explore/components/DataTablesPane/components/SingleQueryResultPane'; import { Dataset, DrillByType } from '../types'; import DrillByChart from './DrillByChart'; +import { ContextMenuItem } from '../ChartContextMenu/ChartContextMenu'; +import { useContextMenu } from '../ChartContextMenu/useContextMenu'; import { getChartDataRequest } from '../chartAction'; const DATA_SIZE = 15; @@ -119,31 +127,35 @@ export default function DrillByModal({ () => formData.datasource.split('__'), [formData.datasource], ); - const dashboardLayout = useSelector( - state => state.dashboardLayout.present, - ); - const chartLayoutItem = Object.values(dashboardLayout).find( - layoutItem => layoutItem.meta?.chartId === formData.slice_id, - ); - const chartName = - chartLayoutItem?.meta.sliceNameOverride || chartLayoutItem?.meta.sliceName; + + const [currentColumn, setCurrentColumn] = useState(column); + const [currentFormData, setCurrentFormData] = useState(formData); + const [currentFilters, setCurrentFilters] = useState(filters); + const [usedGroupbyColumns, setUsedGroupbyColumns] = useState([ + ...ensureIsArray(formData[groupbyFieldName]).map(colName => + dataset.columns?.find(col => col.column_name === colName), + ), + column, + ]); const updatedFormData = useMemo(() => { - let updatedFormData = { ...formData }; - if (column) { + let updatedFormData = { ...currentFormData }; + if (currentColumn) { updatedFormData[groupbyFieldName] = Array.isArray( - formData[groupbyFieldName], + currentFormData[groupbyFieldName], ) - ? [column.column_name] - : column.column_name; + ? [currentColumn.column_name] + : currentColumn.column_name; } - if (filters) { - const adhocFilters = filters.map(filter => simpleFilterToAdhoc(filter)); + if (currentFilters) { + const adhocFilters = currentFilters.map(filter => + simpleFilterToAdhoc(filter), + ); updatedFormData = { ...updatedFormData, adhoc_filters: [ - ...ensureIsArray(formData.adhoc_filters), + ...ensureIsArray(currentFormData.adhoc_filters), ...adhocFilters, ], }; @@ -152,7 +164,46 @@ export default function DrillByModal({ delete updatedFormData.slice_name; delete updatedFormData.dashboards; return updatedFormData; - }, [column, filters, formData, groupbyFieldName]); + }, [currentColumn, currentFormData, currentFilters, groupbyFieldName]); + + useEffect(() => { + setUsedGroupbyColumns(cols => + cols.includes(currentColumn) ? cols : [...cols, currentColumn], + ); + }, [currentColumn]); + + const onSelection = useCallback( + (newColumn: Column, filters: BinaryQueryObjectFilterClause[]) => { + setCurrentColumn(newColumn); + setCurrentFormData(updatedFormData); + setCurrentFilters(filters); + }, + [updatedFormData], + ); + + const additionalConfig = useMemo( + () => ({ + drillBy: { excludedColumns: usedGroupbyColumns, openNewModal: false }, + }), + [usedGroupbyColumns], + ); + + const { contextMenu, inContextMenu, onContextMenu } = useContextMenu( + 0, + currentFormData, + onSelection, + ContextMenuItem.DrillBy, + additionalConfig, + ); + + const chartName = useSelector(state => { + const chartLayoutItem = Object.values(state.dashboardLayout.present).find( + layoutItem => layoutItem.meta?.chartId === formData.slice_id, + ); + return ( + chartLayoutItem?.meta.sliceNameOverride || chartLayoutItem?.meta.sliceName + ); + }); useEffect(() => { if (updatedFormData) { @@ -228,7 +279,12 @@ export default function DrillByModal({
{!chartDataResult && } {drillByDisplayMode === DrillByType.Chart && chartDataResult && ( - + )} {drillByDisplayMode === DrillByType.Table && chartDataResult && (
)} + {contextMenu} ); diff --git a/superset-frontend/src/components/Chart/types.ts b/superset-frontend/src/components/Chart/types.ts index 7dee034f34..2b3eb1cea0 100644 --- a/superset-frontend/src/components/Chart/types.ts +++ b/superset-frontend/src/components/Chart/types.ts @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { Column } from '@superset-ui/core'; export enum DrillByType { Chart, @@ -39,4 +40,5 @@ export type Dataset = { first_name: string; last_name: string; }[]; + columns?: Column[]; }; diff --git a/superset-frontend/src/dashboard/styles.ts b/superset-frontend/src/dashboard/styles.ts index 5d431f50b6..18290915bb 100644 --- a/superset-frontend/src/dashboard/styles.ts +++ b/superset-frontend/src/dashboard/styles.ts @@ -89,6 +89,9 @@ export const filterCardPopoverStyle = (theme: SupersetTheme) => css` `; export const chartContextMenuStyles = (theme: SupersetTheme) => css` + .ant-dropdown-menu.chart-context-menu { + min-width: ${theme.gridUnit * 43}px; + } .ant-dropdown-menu-submenu.chart-context-submenu { max-width: ${theme.gridUnit * 60}px; min-width: ${theme.gridUnit * 40}px;