mirror of
https://github.com/apache/superset.git
synced 2024-09-16 02:29:39 -04:00
feat: Implement breadcrumbs in Drill By modal (#23664)
This commit is contained in:
parent
95d71fff04
commit
a04e635416
@ -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}
|
||||
|
@ -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!}
|
||||
/>
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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 };
|
||||
const getNewGroupby = useCallback(
|
||||
(groupbyCol: Column) =>
|
||||
Array.isArray(formData[groupbyFieldName])
|
||||
? [groupbyCol.column_name]
|
||||
: groupbyCol.column_name,
|
||||
[formData, groupbyFieldName],
|
||||
);
|
||||
|
||||
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),
|
||||
);
|
||||
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] = Array.isArray(
|
||||
currentFormData[groupbyFieldName],
|
||||
)
|
||||
? [currentColumn.column_name]
|
||||
: currentColumn.column_name;
|
||||
updatedFormData[groupbyFieldName] = getNewGroupby(currentColumn);
|
||||
}
|
||||
|
||||
if (currentFilters) {
|
||||
const adhocFilters = currentFilters.map(filter =>
|
||||
simpleFilterToAdhoc(filter),
|
||||
);
|
||||
updatedFormData = {
|
||||
...updatedFormData,
|
||||
adhoc_filters: [
|
||||
...ensureIsArray(currentFormData.adhoc_filters),
|
||||
...adhocFilters,
|
||||
],
|
||||
};
|
||||
}
|
||||
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}
|
||||
|
@ -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 };
|
||||
};
|
@ -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();
|
||||
});
|
@ -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]);
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user