feat: Implement breadcrumbs in Drill By modal (#23664)

This commit is contained in:
Kamil Gabryjelski 2023-04-13 19:15:46 +02:00 committed by GitHub
parent 95d71fff04
commit a04e635416
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 415 additions and 84 deletions

View File

@ -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}

View File

@ -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!}
/>

View File

@ -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();
});

View File

@ -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}

View File

@ -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 };
};

View File

@ -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();
});

View File

@ -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]);

View File

@ -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,