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/13 17:15:55 UTC

[superset] branch master updated: feat: Implement breadcrumbs in Drill By modal (#23664)

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 a04e635416 feat: Implement breadcrumbs in Drill By modal (#23664)
a04e635416 is described below

commit a04e635416c64183fc8da6b5584b06ca4b650f50
Author: Kamil Gabryjelski <ka...@gmail.com>
AuthorDate: Thu Apr 13 19:15:46 2023 +0200

    feat: Implement breadcrumbs in Drill By modal (#23664)
---
 .../Chart/ChartContextMenu/ChartContextMenu.tsx    |   1 +
 .../components/Chart/DrillBy/DrillByMenuItems.tsx  |  30 ++--
 .../components/Chart/DrillBy/DrillByModal.test.tsx |  52 +++++-
 .../src/components/Chart/DrillBy/DrillByModal.tsx  | 184 +++++++++++++--------
 .../Chart/DrillBy/useDisplayModeToggle.tsx         |  64 +++++++
 .../Chart/DrillBy/useDrillByBreadcrumbs.test.ts    |  72 ++++++++
 .../Chart/DrillBy/useDrillByBreadcrumbs.tsx        |  93 +++++++++++
 superset-frontend/src/components/index.ts          |   1 +
 8 files changed, 414 insertions(+), 83 deletions(-)

diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx
index 063ed787b1..401c3fa992 100644
--- a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx
+++ b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx
@@ -236,6 +236,7 @@ const ChartContextMenu = (
       <DrillByMenuItems
         filters={filters?.drillBy?.filters}
         groupbyFieldName={filters?.drillBy?.groupbyFieldName}
+        adhocFilterFieldName={filters?.drillBy?.adhocFilterFieldName}
         onSelection={onSelection}
         formData={formData}
         contextMenuY={clientY}
diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx
index 9b57e2fdec..e5b919ff6d 100644
--- a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx
+++ b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx
@@ -59,6 +59,7 @@ export interface DrillByMenuItemsProps {
   contextMenuY?: number;
   submenuIndex?: number;
   groupbyFieldName?: string;
+  adhocFilterFieldName?: string;
   onSelection?: (...args: any) => void;
   onClick?: (event: MouseEvent) => void;
   openNewModal?: boolean;
@@ -68,6 +69,7 @@ export interface DrillByMenuItemsProps {
 export const DrillByMenuItems = ({
   filters,
   groupbyFieldName,
+  adhocFilterFieldName,
   formData,
   contextMenuY = 0,
   submenuIndex = 0,
@@ -130,6 +132,11 @@ export const DrillByMenuItems = ({
                 column =>
                   !ensureIsArray(formData[groupbyFieldName]).includes(
                     column.column_name,
+                  ) &&
+                  column.column_name !== formData.x_axis &&
+                  ensureIsArray(excludedColumns)?.every(
+                    excludedCol =>
+                      excludedCol.column_name !== column.column_name,
                   ),
               ),
           );
@@ -138,7 +145,13 @@ export const DrillByMenuItems = ({
           supersetGetCache.delete(`/api/v1/dataset/${datasetId}`);
         });
     }
-  }, [formData, groupbyFieldName, handlesDimensionContextMenu, hasDrillBy]);
+  }, [
+    excludedColumns,
+    formData,
+    groupbyFieldName,
+    handlesDimensionContextMenu,
+    hasDrillBy,
+  ]);
 
   const handleInput = useCallback((e: ChangeEvent<HTMLInputElement>) => {
     e.stopPropagation();
@@ -148,16 +161,12 @@ export const DrillByMenuItems = ({
 
   const filteredColumns = useMemo(
     () =>
-      columns.filter(
-        column =>
-          (column.verbose_name || column.column_name)
-            .toLowerCase()
-            .includes(searchInput.toLowerCase()) &&
-          !ensureIsArray(excludedColumns)?.some(
-            col => col.column_name === column.column_name,
-          ),
+      columns.filter(column =>
+        (column.verbose_name || column.column_name)
+          .toLowerCase()
+          .includes(searchInput.toLowerCase()),
       ),
-    [columns, excludedColumns, searchInput],
+    [columns, searchInput],
   );
 
   const submenuYOffset = useMemo(
@@ -260,6 +269,7 @@ export const DrillByMenuItems = ({
           filters={filters}
           formData={formData}
           groupbyFieldName={groupbyFieldName}
+          adhocFilterFieldName={adhocFilterFieldName}
           onHideModal={closeModal}
           dataset={dataset!}
         />
diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx
index f08a9701a8..f0768253e0 100644
--- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx
+++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx
@@ -21,12 +21,12 @@ import React, { useState } from 'react';
 import fetchMock from 'fetch-mock';
 import { omit, isUndefined, omitBy } from 'lodash';
 import userEvent from '@testing-library/user-event';
-import { waitFor } from '@testing-library/react';
+import { waitFor, within } from '@testing-library/react';
 import { render, screen } from 'spec/helpers/testing-library';
 import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
 import mockState from 'spec/fixtures/mockState';
 import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
-import DrillByModal from './DrillByModal';
+import DrillByModal, { DrillByModalProps } from './DrillByModal';
 
 const CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*';
 const FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data';
@@ -60,9 +60,15 @@ const dataset = {
       last_name: 'Connor',
     },
   ],
+  columns: [
+    {
+      column_name: 'gender',
+    },
+    { column_name: 'name' },
+  ],
 };
 
-const renderModal = async () => {
+const renderModal = async (modalProps: Partial<DrillByModalProps> = {}) => {
   const DrillByModalWrapper = () => {
     const [showModal, setShowModal] = useState(false);
 
@@ -76,6 +82,7 @@ const renderModal = async () => {
             formData={formData}
             onHideModal={() => setShowModal(false)}
             dataset={dataset}
+            {...modalProps}
           />
         )}
       </DashboardPageIdContext.Provider>
@@ -127,7 +134,10 @@ test('should render loading indicator', async () => {
 });
 
 test('should generate Explore url', async () => {
-  await renderModal();
+  await renderModal({
+    column: { column_name: 'name' },
+    filters: [{ col: 'gender', op: '==', val: 'boy' }],
+  });
   await waitFor(() => fetchMock.called(CHART_DATA_ENDPOINT));
   const expectedRequestPayload = {
     form_data: {
@@ -135,6 +145,18 @@ test('should generate Explore url', async () => {
         omit(formData, ['slice_id', 'slice_name', 'dashboards']),
         isUndefined,
       ),
+      groupby: ['name'],
+      adhoc_filters: [
+        ...formData.adhoc_filters,
+        {
+          clause: 'WHERE',
+          comparator: 'boy',
+          expressionType: 'SIMPLE',
+          operator: '==',
+          operatorId: 'EQUALS',
+          subject: 'gender',
+        },
+      ],
       slice_id: 0,
       result_format: 'json',
       result_type: 'full',
@@ -170,3 +192,25 @@ test('should render radio buttons', async () => {
   expect(chartRadio).not.toBeChecked();
   expect(tableRadio).toBeChecked();
 });
+
+test('render breadcrumbs', async () => {
+  await renderModal({
+    column: { column_name: 'name' },
+    filters: [{ col: 'gender', op: '==', val: 'boy' }],
+  });
+
+  const breadcrumbItems = screen.getAllByTestId('drill-by-breadcrumb-item');
+  expect(breadcrumbItems).toHaveLength(2);
+  expect(
+    within(breadcrumbItems[0]).getByText('gender (boy)'),
+  ).toBeInTheDocument();
+  expect(within(breadcrumbItems[1]).getByText('name')).toBeInTheDocument();
+
+  userEvent.click(screen.getByText('gender (boy)'));
+
+  const newBreadcrumbItems = screen.getAllByTestId('drill-by-breadcrumb-item');
+  // we need to assert that there is only 1 element now
+  // eslint-disable-next-line jest-dom/prefer-in-document
+  expect(newBreadcrumbItems).toHaveLength(1);
+  expect(within(breadcrumbItems[0]).getByText('gender')).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx
index 6a9a82b573..7f65576854 100644
--- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx
+++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx
@@ -31,6 +31,7 @@ import {
   QueryData,
   css,
   ensureIsArray,
+  isDefined,
   t,
   useTheme,
 } from '@superset-ui/core';
@@ -39,7 +40,6 @@ import { Link } from 'react-router-dom';
 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 { RootState } from 'src/dashboard/types';
 import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
 import { postFormData } from 'src/explore/exploreUtils/formData';
@@ -52,6 +52,11 @@ import DrillByChart from './DrillByChart';
 import { ContextMenuItem } from '../ChartContextMenu/ChartContextMenu';
 import { useContextMenu } from '../ChartContextMenu/useContextMenu';
 import { getChartDataRequest } from '../chartAction';
+import { useDisplayModeToggle } from './useDisplayModeToggle';
+import {
+  DrillByBreadcrumb,
+  useDrillByBreadcrumbs,
+} from './useDrillByBreadcrumbs';
 
 const DATA_SIZE = 15;
 interface ModalFooterProps {
@@ -101,12 +106,13 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
   );
 };
 
-interface DrillByModalProps {
+export interface DrillByModalProps {
   column?: Column;
   dataset: Dataset;
   filters?: BinaryQueryObjectFilterClause[];
   formData: BaseFormData & { [key: string]: any };
   groupbyFieldName?: string;
+  adhocFilterFieldName?: string;
   onHideModal: () => void;
 }
 
@@ -116,69 +122,134 @@ export default function DrillByModal({
   filters,
   formData,
   groupbyFieldName = 'groupby',
+  adhocFilterFieldName = 'adhoc_filters',
   onHideModal,
 }: DrillByModalProps) {
   const theme = useTheme();
-  const [chartDataResult, setChartDataResult] = useState<QueryData[]>();
-  const [drillByDisplayMode, setDrillByDisplayMode] = useState<DrillByType>(
-    DrillByType.Chart,
+
+  const initialGroupbyColumns = useMemo(
+    () =>
+      ensureIsArray(formData[groupbyFieldName])
+        .map(colName =>
+          dataset.columns?.find(col => col.column_name === colName),
+        )
+        .filter(isDefined),
+    [dataset.columns, formData, groupbyFieldName],
   );
+
+  const { displayModeToggle, drillByDisplayMode } = useDisplayModeToggle();
+  const [chartDataResult, setChartDataResult] = useState<QueryData[]>();
   const [datasourceId] = useMemo(
     () => formData.datasource.split('__'),
     [formData.datasource],
   );
 
-  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),
-    ),
+  const [currentColumn, setCurrentColumn] = useState<Column | undefined>(
     column,
+  );
+  const [currentFormData, setCurrentFormData] = useState(formData);
+  const [currentFilters, setCurrentFilters] = useState(filters || []);
+  const [usedGroupbyColumns, setUsedGroupbyColumns] = useState<Column[]>(
+    [...initialGroupbyColumns, column].filter(isDefined),
+  );
+  const [breadcrumbsData, setBreadcrumbsData] = useState<DrillByBreadcrumb[]>([
+    { groupby: initialGroupbyColumns, filters },
+    { groupby: column || [] },
   ]);
 
-  const updatedFormData = useMemo(() => {
-    let updatedFormData = { ...currentFormData };
-    if (currentColumn) {
-      updatedFormData[groupbyFieldName] = Array.isArray(
-        currentFormData[groupbyFieldName],
-      )
-        ? [currentColumn.column_name]
-        : currentColumn.column_name;
-    }
+  const getNewGroupby = useCallback(
+    (groupbyCol: Column) =>
+      Array.isArray(formData[groupbyFieldName])
+        ? [groupbyCol.column_name]
+        : groupbyCol.column_name,
+    [formData, groupbyFieldName],
+  );
 
-    if (currentFilters) {
-      const adhocFilters = currentFilters.map(filter =>
-        simpleFilterToAdhoc(filter),
+  const onBreadcrumbClick = useCallback(
+    (breadcrumb: DrillByBreadcrumb, index: number) => {
+      const newGroupbyCol =
+        index === 0 ? undefined : (breadcrumb.groupby as Column);
+      setCurrentColumn(newGroupbyCol);
+      setCurrentFilters(filters => filters.slice(0, index));
+      setBreadcrumbsData(prevBreadcrumbs => {
+        const newBreadcrumbs = prevBreadcrumbs.slice(0, index + 1);
+        delete newBreadcrumbs[newBreadcrumbs.length - 1].filters;
+        return newBreadcrumbs;
+      });
+      setUsedGroupbyColumns(prevUsedGroupbyColumns =>
+        prevUsedGroupbyColumns.slice(0, index),
       );
-      updatedFormData = {
-        ...updatedFormData,
-        adhoc_filters: [
-          ...ensureIsArray(currentFormData.adhoc_filters),
-          ...adhocFilters,
+      setCurrentFormData(prevFormData => ({
+        ...prevFormData,
+        [groupbyFieldName]: newGroupbyCol
+          ? getNewGroupby(newGroupbyCol)
+          : formData[groupbyFieldName],
+        [adhocFilterFieldName]: [
+          ...formData[adhocFilterFieldName],
+          ...prevFormData[adhocFilterFieldName].slice(
+            formData[adhocFilterFieldName].length,
+            formData[adhocFilterFieldName].length + index,
+          ),
         ],
-      };
+      }));
+    },
+    [adhocFilterFieldName, formData, getNewGroupby, groupbyFieldName],
+  );
+
+  const breadcrumbs = useDrillByBreadcrumbs(breadcrumbsData, onBreadcrumbClick);
+
+  const drilledFormData = useMemo(() => {
+    let updatedFormData = { ...formData };
+    if (currentColumn) {
+      updatedFormData[groupbyFieldName] = getNewGroupby(currentColumn);
     }
+
+    const adhocFilters = currentFilters.map(filter =>
+      simpleFilterToAdhoc(filter),
+    );
+    updatedFormData = {
+      ...updatedFormData,
+      [adhocFilterFieldName]: [
+        ...ensureIsArray(formData[adhocFilterFieldName]),
+        ...adhocFilters,
+      ],
+    };
     updatedFormData.slice_id = 0;
     delete updatedFormData.slice_name;
     delete updatedFormData.dashboards;
     return updatedFormData;
-  }, [currentColumn, currentFormData, currentFilters, groupbyFieldName]);
+  }, [
+    formData,
+    currentColumn,
+    currentFilters,
+    groupbyFieldName,
+    getNewGroupby,
+    adhocFilterFieldName,
+  ]);
 
   useEffect(() => {
-    setUsedGroupbyColumns(cols =>
-      cols.includes(currentColumn) ? cols : [...cols, currentColumn],
+    setUsedGroupbyColumns(usedCols =>
+      !currentColumn ||
+      usedCols.some(
+        usedCol => usedCol.column_name === currentColumn.column_name,
+      )
+        ? usedCols
+        : [...usedCols, currentColumn],
     );
   }, [currentColumn]);
 
   const onSelection = useCallback(
     (newColumn: Column, filters: BinaryQueryObjectFilterClause[]) => {
       setCurrentColumn(newColumn);
-      setCurrentFormData(updatedFormData);
-      setCurrentFilters(filters);
+      setCurrentFormData(drilledFormData);
+      setCurrentFilters(prevFilters => [...prevFilters, ...filters]);
+      setBreadcrumbsData(prevBreadcrumbs => {
+        const newBreadcrumbs = [...prevBreadcrumbs, { groupby: newColumn }];
+        newBreadcrumbs[newBreadcrumbs.length - 2].filters = filters;
+        return newBreadcrumbs;
+      });
     },
-    [updatedFormData],
+    [drilledFormData],
   );
 
   const additionalConfig = useMemo(
@@ -206,14 +277,15 @@ export default function DrillByModal({
   });
 
   useEffect(() => {
-    if (updatedFormData) {
+    if (drilledFormData) {
+      setChartDataResult(undefined);
       getChartDataRequest({
-        formData: updatedFormData,
+        formData: drilledFormData,
       }).then(({ json }) => {
         setChartDataResult(json.result);
       });
     }
-  }, [updatedFormData]);
+  }, [drilledFormData]);
   const { metadataBar } = useDatasetMetadataBar({ dataset });
 
   return (
@@ -226,7 +298,7 @@ export default function DrillByModal({
       show
       onHide={onHideModal ?? (() => null)}
       title={t('Drill by: %s', chartName)}
-      footer={<ModalFooter formData={updatedFormData} />}
+      footer={<ModalFooter formData={drilledFormData} />}
       responsive
       resizable
       resizableConfig={{
@@ -249,38 +321,12 @@ export default function DrillByModal({
         `}
       >
         {metadataBar}
-        <div
-          css={css`
-            margin-bottom: ${theme.gridUnit * 6}px;
-            .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):focus-within {
-              box-shadow: none;
-            }
-          `}
-        >
-          <Radio.Group
-            onChange={({ target: { value } }) => {
-              setDrillByDisplayMode(value);
-            }}
-            defaultValue={DrillByType.Chart}
-          >
-            <Radio.Button
-              value={DrillByType.Chart}
-              data-test="drill-by-chart-radio"
-            >
-              {t('Chart')}
-            </Radio.Button>
-            <Radio.Button
-              value={DrillByType.Table}
-              data-test="drill-by-table-radio"
-            >
-              {t('Table')}
-            </Radio.Button>
-          </Radio.Group>
-        </div>
+        {breadcrumbs}
+        {displayModeToggle}
         {!chartDataResult && <Loading />}
         {drillByDisplayMode === DrillByType.Chart && chartDataResult && (
           <DrillByChart
-            formData={updatedFormData}
+            formData={drilledFormData}
             result={chartDataResult}
             onContextMenu={onContextMenu}
             inContextMenu={inContextMenu}
diff --git a/superset-frontend/src/components/Chart/DrillBy/useDisplayModeToggle.tsx b/superset-frontend/src/components/Chart/DrillBy/useDisplayModeToggle.tsx
new file mode 100644
index 0000000000..5e7b812cc4
--- /dev/null
+++ b/superset-frontend/src/components/Chart/DrillBy/useDisplayModeToggle.tsx
@@ -0,0 +1,64 @@
+/**
+ * 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, { useMemo, useState } from 'react';
+import { css, SupersetTheme, t } from '@superset-ui/core';
+import { Radio } from 'src/components/Radio';
+import { DrillByType } from '../types';
+
+export const useDisplayModeToggle = () => {
+  const [drillByDisplayMode, setDrillByDisplayMode] = useState<DrillByType>(
+    DrillByType.Chart,
+  );
+
+  const displayModeToggle = useMemo(
+    () => (
+      <div
+        css={(theme: SupersetTheme) => css`
+          margin-bottom: ${theme.gridUnit * 6}px;
+          .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):focus-within {
+            box-shadow: none;
+          }
+        `}
+      >
+        <Radio.Group
+          onChange={({ target: { value } }) => {
+            setDrillByDisplayMode(value);
+          }}
+          defaultValue={DrillByType.Chart}
+        >
+          <Radio.Button
+            value={DrillByType.Chart}
+            data-test="drill-by-chart-radio"
+          >
+            {t('Chart')}
+          </Radio.Button>
+          <Radio.Button
+            value={DrillByType.Table}
+            data-test="drill-by-table-radio"
+          >
+            {t('Table')}
+          </Radio.Button>
+        </Radio.Group>
+      </div>
+    ),
+    [],
+  );
+  return { displayModeToggle, drillByDisplayMode };
+};
diff --git a/superset-frontend/src/components/Chart/DrillBy/useDrillByBreadcrumbs.test.ts b/superset-frontend/src/components/Chart/DrillBy/useDrillByBreadcrumbs.test.ts
new file mode 100644
index 0000000000..48cc328f7a
--- /dev/null
+++ b/superset-frontend/src/components/Chart/DrillBy/useDrillByBreadcrumbs.test.ts
@@ -0,0 +1,72 @@
+/**
+ * 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 { renderHook } from '@testing-library/react-hooks';
+import userEvent from '@testing-library/user-event';
+import { render, screen } from 'spec/helpers/testing-library';
+import {
+  DrillByBreadcrumb,
+  useDrillByBreadcrumbs,
+} from './useDrillByBreadcrumbs';
+
+const BREADCRUMBS_DATA: DrillByBreadcrumb[] = [
+  {
+    groupby: [{ column_name: 'col1' }, { column_name: 'col2' }],
+    filters: [
+      { col: 'col1', op: '==', val: 'col1 filter' },
+      { col: 'col2', op: '==', val: 'col2 filter' },
+    ],
+  },
+  {
+    groupby: [{ column_name: 'col3', verbose_name: 'Column 3' }],
+    filters: [{ col: 'col3', op: '==', val: 'col3 filter' }],
+  },
+  { groupby: [{ column_name: 'col4' }] },
+];
+
+test('Render breadcrumbs', () => {
+  const { result } = renderHook(() => useDrillByBreadcrumbs(BREADCRUMBS_DATA));
+  render(result.current);
+  expect(screen.getAllByTestId('drill-by-breadcrumb-item')).toHaveLength(3);
+  expect(
+    screen.getByText('col1, col2 (col1 filter, col2 filter)'),
+  ).toBeInTheDocument();
+  expect(screen.getByText('Column 3 (col3 filter)')).toBeInTheDocument();
+  expect(screen.getByText('col4')).toBeInTheDocument();
+});
+
+test('Call click handler with correct arguments when breadcrumb is clicked', () => {
+  const onClick = jest.fn();
+  const { result } = renderHook(() =>
+    useDrillByBreadcrumbs(BREADCRUMBS_DATA, onClick),
+  );
+  render(result.current);
+
+  userEvent.click(screen.getByText('col1, col2 (col1 filter, col2 filter)'));
+  expect(onClick).toHaveBeenCalledWith(BREADCRUMBS_DATA[0], 0);
+  onClick.mockClear();
+
+  userEvent.click(screen.getByText('Column 3 (col3 filter)'));
+  expect(onClick).toHaveBeenCalledWith(BREADCRUMBS_DATA[1], 1);
+  onClick.mockClear();
+
+  userEvent.click(screen.getByText('col4'));
+  expect(onClick).not.toHaveBeenCalled();
+  onClick.mockClear();
+});
diff --git a/superset-frontend/src/components/Chart/DrillBy/useDrillByBreadcrumbs.tsx b/superset-frontend/src/components/Chart/DrillBy/useDrillByBreadcrumbs.tsx
new file mode 100644
index 0000000000..fc7c0b2bf5
--- /dev/null
+++ b/superset-frontend/src/components/Chart/DrillBy/useDrillByBreadcrumbs.tsx
@@ -0,0 +1,93 @@
+/**
+ * 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, { useMemo } from 'react';
+import {
+  BinaryQueryObjectFilterClause,
+  Column,
+  css,
+  ensureIsArray,
+  styled,
+  SupersetTheme,
+} from '@superset-ui/core';
+import { AntdBreadcrumb } from 'src/components/index';
+import { noOp } from 'src/utils/common';
+
+export interface DrillByBreadcrumb {
+  groupby: Column | Column[];
+  filters?: BinaryQueryObjectFilterClause[];
+}
+
+const BreadcrumbItem = styled(AntdBreadcrumb.Item)<{ isClickable: boolean }>`
+  ${({ theme, isClickable }) => css`
+    cursor: ${isClickable ? 'pointer' : 'auto'};
+    color: ${theme.colors.grayscale.light1};
+    transition: color ease-in ${theme.transitionTiming}s;
+    .ant-breadcrumb > span:last-child > & {
+      color: ${theme.colors.grayscale.dark1};
+    }
+    &:hover {
+      color: ${isClickable ? theme.colors.grayscale.dark1 : 'inherit'};
+    }
+  `}
+`;
+
+export const useDrillByBreadcrumbs = (
+  breadcrumbsData: DrillByBreadcrumb[],
+  onBreadcrumbClick: (
+    breadcrumb: DrillByBreadcrumb,
+    index: number,
+  ) => void = noOp,
+) =>
+  useMemo(() => {
+    // the last breadcrumb is not clickable
+    const isClickable = (index: number) => index < breadcrumbsData.length - 1;
+    const getBreadcrumbText = (breadcrumb: DrillByBreadcrumb) =>
+      `${ensureIsArray(breadcrumb.groupby)
+        .map(column => column.verbose_name || column.column_name)
+        .join(', ')} ${
+        breadcrumb.filters
+          ? `(${breadcrumb.filters
+              .map(filter => filter.formattedVal || filter.val)
+              .join(', ')})`
+          : ''
+      }`;
+    return (
+      <AntdBreadcrumb
+        css={(theme: SupersetTheme) => css`
+          margin: ${theme.gridUnit * 2}px 0 ${theme.gridUnit * 4}px;
+        `}
+      >
+        {breadcrumbsData.map((breadcrumb, index) => (
+          <BreadcrumbItem
+            key={index}
+            isClickable={isClickable(index)}
+            onClick={
+              isClickable(index)
+                ? () => onBreadcrumbClick(breadcrumb, index)
+                : noOp
+            }
+            data-test="drill-by-breadcrumb-item"
+          >
+            {getBreadcrumbText(breadcrumb)}
+          </BreadcrumbItem>
+        ))}
+      </AntdBreadcrumb>
+    );
+  }, [breadcrumbsData, onBreadcrumbClick]);
diff --git a/superset-frontend/src/components/index.ts b/superset-frontend/src/components/index.ts
index bfa341a9dd..4c425b0534 100644
--- a/superset-frontend/src/components/index.ts
+++ b/superset-frontend/src/components/index.ts
@@ -55,6 +55,7 @@ export {
  * or extending the components in src/components.
  */
 export {
+  Breadcrumb as AntdBreadcrumb,
   Button as AntdButton,
   Card as AntdCard,
   Checkbox as AntdCheckbox,