You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by kg...@apache.org on 2023/04/12 11:43:19 UTC

[superset] branch master updated: feat: Support further drill by in the modal (#23615)

This is an automated email from the ASF dual-hosted git repository.

kgabryje pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 587e7759b1 feat: Support further drill by in the modal (#23615)
587e7759b1 is described below

commit 587e7759b1b674440ac0aa705ebae6599564875f
Author: Kamil Gabryjelski <ka...@gmail.com>
AuthorDate: Wed Apr 12 13:43:09 2023 +0200

    feat: Support further drill by in the modal (#23615)
---
 .../src/chart/components/SuperChart.tsx            |  4 +
 .../{ => ChartContextMenu}/ChartContextMenu.tsx    | 65 +++++++++++----
 .../Chart/ChartContextMenu/useContextMenu.test.tsx | 86 +++++++++++++++++++
 .../Chart/ChartContextMenu/useContextMenu.tsx      | 82 ++++++++++++++++++
 .../src/components/Chart/ChartRenderer.jsx         |  2 +-
 .../components/Chart/DrillBy/DrillByChart.test.tsx |  6 +-
 .../src/components/Chart/DrillBy/DrillByChart.tsx  | 22 ++++-
 .../Chart/DrillBy/DrillByMenuItems.test.tsx        | 70 ++++++++++++++--
 .../components/Chart/DrillBy/DrillByMenuItems.tsx  | 32 ++++---
 .../src/components/Chart/DrillBy/DrillByModal.tsx  | 97 +++++++++++++++++-----
 superset-frontend/src/components/Chart/types.ts    |  2 +
 superset-frontend/src/dashboard/styles.ts          |  3 +
 12 files changed, 409 insertions(+), 62 deletions(-)

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<SuperChartCoreProps, 'chartProps'> &
      * 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<typeof defaultProps>;
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<string, any>;
+    drillToDetail?: Record<string, any>;
+    drillBy?: Record<string, any>;
+  };
+  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<Ref>,
+  {
+    id,
+    formData,
+    onSelection,
+    onClose,
+    displayedItems = ContextMenuItem.All,
+    additionalConfig,
+  }: ChartContextMenuProps,
+  ref: RefObject<ChartContextMenuRef>,
 ) => {
   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 = (
       <DrillByMenuItems
         filters={filters?.drillBy?.filters}
         groupbyFieldName={filters?.drillBy?.groupbyFieldName}
+        onSelection={onSelection}
         formData={formData}
         contextMenuY={clientY}
         submenuIndex={submenuIndex}
+        {...(additionalConfig?.drillBy || {})}
       />,
     );
   }
@@ -241,7 +274,7 @@ const ChartContextMenu = (
   return ReactDOM.createPortal(
     <Dropdown
       overlay={
-        <Menu>
+        <Menu className="chart-context-menu" data-test="chart-context-menu">
           {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<string, any>;
+} = {}) => {
+  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<string, any>;
+    drillToDetail?: Record<string, any>;
+    drillBy?: Record<string, any>;
+  },
+) => {
+  const contextMenuRef = useRef<ChartContextMenuRef>(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(
+    () => (
+      <ChartContextMenu
+        ref={contextMenuRef}
+        id={chartId}
+        formData={formData}
+        onSelection={handleContextMenuSelected}
+        onClose={handleContextMenuClosed}
+        displayedItems={displayedItems}
+        additionalConfig={additionalConfig}
+      />
+    ),
+    [
+      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<string, any> = {}, result?: any) =>
   render(
     <DrillByChart
       formData={{ ...chart.form_data, ...overrides }}
+      onContextMenu={noOp}
+      inContextMenu={false}
       result={result}
     />,
     {
@@ -38,8 +40,6 @@ const setup = (overrides: Record<string, any> = {}, result?: any) =>
 const waitForRender = (overrides: Record<string, any> = {}) =>
   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 (
     <div
       css={css`
@@ -40,11 +53,12 @@ export default function DrillByChart({ formData, result }: DrillByChartProps) {
     >
       <SuperChart
         disableErrorBoundary
-        behaviors={[Behavior.INTERACTIVE_CHART]}
         chartType={formData.viz_type}
         enableNoResults
         formData={formData}
         queriesData={result}
+        hooks={hooks}
+        inContextMenu={inContextMenu}
         height="100%"
         width="100%"
       />
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<DrillByMenuItemsProps>) =>
   render(
     <Menu>
@@ -67,6 +70,7 @@ const renderMenu = ({
         formData={formData ?? defaultFormData}
         filters={filters}
         groupbyFieldName="groupby"
+        {...rest}
       />
     </Menu>,
     { 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}
                 </MenuItemWithTruncation>
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<RootState, DashboardLayout>(
-    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<RootState, string | undefined>(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({
         </div>
         {!chartDataResult && <Loading />}
         {drillByDisplayMode === DrillByType.Chart && chartDataResult && (
-          <DrillByChart formData={updatedFormData} result={chartDataResult} />
+          <DrillByChart
+            formData={updatedFormData}
+            result={chartDataResult}
+            onContextMenu={onContextMenu}
+            inContextMenu={inContextMenu}
+          />
         )}
         {drillByDisplayMode === DrillByType.Table && chartDataResult && (
           <div
@@ -248,6 +304,7 @@ export default function DrillByModal({
             />
           </div>
         )}
+        {contextMenu}
       </div>
     </Modal>
   );
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;