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,