mirror of
https://github.com/apache/superset.git
synced 2024-09-18 11:39:49 -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
|
<DrillByMenuItems
|
||||||
filters={filters?.drillBy?.filters}
|
filters={filters?.drillBy?.filters}
|
||||||
groupbyFieldName={filters?.drillBy?.groupbyFieldName}
|
groupbyFieldName={filters?.drillBy?.groupbyFieldName}
|
||||||
|
adhocFilterFieldName={filters?.drillBy?.adhocFilterFieldName}
|
||||||
onSelection={onSelection}
|
onSelection={onSelection}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
contextMenuY={clientY}
|
contextMenuY={clientY}
|
||||||
|
@ -59,6 +59,7 @@ export interface DrillByMenuItemsProps {
|
|||||||
contextMenuY?: number;
|
contextMenuY?: number;
|
||||||
submenuIndex?: number;
|
submenuIndex?: number;
|
||||||
groupbyFieldName?: string;
|
groupbyFieldName?: string;
|
||||||
|
adhocFilterFieldName?: string;
|
||||||
onSelection?: (...args: any) => void;
|
onSelection?: (...args: any) => void;
|
||||||
onClick?: (event: MouseEvent) => void;
|
onClick?: (event: MouseEvent) => void;
|
||||||
openNewModal?: boolean;
|
openNewModal?: boolean;
|
||||||
@ -68,6 +69,7 @@ export interface DrillByMenuItemsProps {
|
|||||||
export const DrillByMenuItems = ({
|
export const DrillByMenuItems = ({
|
||||||
filters,
|
filters,
|
||||||
groupbyFieldName,
|
groupbyFieldName,
|
||||||
|
adhocFilterFieldName,
|
||||||
formData,
|
formData,
|
||||||
contextMenuY = 0,
|
contextMenuY = 0,
|
||||||
submenuIndex = 0,
|
submenuIndex = 0,
|
||||||
@ -130,6 +132,11 @@ export const DrillByMenuItems = ({
|
|||||||
column =>
|
column =>
|
||||||
!ensureIsArray(formData[groupbyFieldName]).includes(
|
!ensureIsArray(formData[groupbyFieldName]).includes(
|
||||||
column.column_name,
|
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}`);
|
supersetGetCache.delete(`/api/v1/dataset/${datasetId}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [formData, groupbyFieldName, handlesDimensionContextMenu, hasDrillBy]);
|
}, [
|
||||||
|
excludedColumns,
|
||||||
|
formData,
|
||||||
|
groupbyFieldName,
|
||||||
|
handlesDimensionContextMenu,
|
||||||
|
hasDrillBy,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleInput = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
const handleInput = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -148,16 +161,12 @@ export const DrillByMenuItems = ({
|
|||||||
|
|
||||||
const filteredColumns = useMemo(
|
const filteredColumns = useMemo(
|
||||||
() =>
|
() =>
|
||||||
columns.filter(
|
columns.filter(column =>
|
||||||
column =>
|
|
||||||
(column.verbose_name || column.column_name)
|
(column.verbose_name || column.column_name)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(searchInput.toLowerCase()) &&
|
.includes(searchInput.toLowerCase()),
|
||||||
!ensureIsArray(excludedColumns)?.some(
|
|
||||||
col => col.column_name === column.column_name,
|
|
||||||
),
|
),
|
||||||
),
|
[columns, searchInput],
|
||||||
[columns, excludedColumns, searchInput],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const submenuYOffset = useMemo(
|
const submenuYOffset = useMemo(
|
||||||
@ -260,6 +269,7 @@ export const DrillByMenuItems = ({
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
groupbyFieldName={groupbyFieldName}
|
groupbyFieldName={groupbyFieldName}
|
||||||
|
adhocFilterFieldName={adhocFilterFieldName}
|
||||||
onHideModal={closeModal}
|
onHideModal={closeModal}
|
||||||
dataset={dataset!}
|
dataset={dataset!}
|
||||||
/>
|
/>
|
||||||
|
@ -21,12 +21,12 @@ import React, { useState } from 'react';
|
|||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
import { omit, isUndefined, omitBy } from 'lodash';
|
import { omit, isUndefined, omitBy } from 'lodash';
|
||||||
import userEvent from '@testing-library/user-event';
|
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 { render, screen } from 'spec/helpers/testing-library';
|
||||||
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
|
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
|
||||||
import mockState from 'spec/fixtures/mockState';
|
import mockState from 'spec/fixtures/mockState';
|
||||||
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
|
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 CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*';
|
||||||
const FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data';
|
const FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data';
|
||||||
@ -60,9 +60,15 @@ const dataset = {
|
|||||||
last_name: 'Connor',
|
last_name: 'Connor',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
column_name: 'gender',
|
||||||
|
},
|
||||||
|
{ column_name: 'name' },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderModal = async () => {
|
const renderModal = async (modalProps: Partial<DrillByModalProps> = {}) => {
|
||||||
const DrillByModalWrapper = () => {
|
const DrillByModalWrapper = () => {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
@ -76,6 +82,7 @@ const renderModal = async () => {
|
|||||||
formData={formData}
|
formData={formData}
|
||||||
onHideModal={() => setShowModal(false)}
|
onHideModal={() => setShowModal(false)}
|
||||||
dataset={dataset}
|
dataset={dataset}
|
||||||
|
{...modalProps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DashboardPageIdContext.Provider>
|
</DashboardPageIdContext.Provider>
|
||||||
@ -127,7 +134,10 @@ test('should render loading indicator', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should generate Explore url', 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));
|
await waitFor(() => fetchMock.called(CHART_DATA_ENDPOINT));
|
||||||
const expectedRequestPayload = {
|
const expectedRequestPayload = {
|
||||||
form_data: {
|
form_data: {
|
||||||
@ -135,6 +145,18 @@ test('should generate Explore url', async () => {
|
|||||||
omit(formData, ['slice_id', 'slice_name', 'dashboards']),
|
omit(formData, ['slice_id', 'slice_name', 'dashboards']),
|
||||||
isUndefined,
|
isUndefined,
|
||||||
),
|
),
|
||||||
|
groupby: ['name'],
|
||||||
|
adhoc_filters: [
|
||||||
|
...formData.adhoc_filters,
|
||||||
|
{
|
||||||
|
clause: 'WHERE',
|
||||||
|
comparator: 'boy',
|
||||||
|
expressionType: 'SIMPLE',
|
||||||
|
operator: '==',
|
||||||
|
operatorId: 'EQUALS',
|
||||||
|
subject: 'gender',
|
||||||
|
},
|
||||||
|
],
|
||||||
slice_id: 0,
|
slice_id: 0,
|
||||||
result_format: 'json',
|
result_format: 'json',
|
||||||
result_type: 'full',
|
result_type: 'full',
|
||||||
@ -170,3 +192,25 @@ test('should render radio buttons', async () => {
|
|||||||
expect(chartRadio).not.toBeChecked();
|
expect(chartRadio).not.toBeChecked();
|
||||||
expect(tableRadio).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,
|
QueryData,
|
||||||
css,
|
css,
|
||||||
ensureIsArray,
|
ensureIsArray,
|
||||||
|
isDefined,
|
||||||
t,
|
t,
|
||||||
useTheme,
|
useTheme,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
@ -39,7 +40,6 @@ import { Link } from 'react-router-dom';
|
|||||||
import Modal from 'src/components/Modal';
|
import Modal from 'src/components/Modal';
|
||||||
import Loading from 'src/components/Loading';
|
import Loading from 'src/components/Loading';
|
||||||
import Button from 'src/components/Button';
|
import Button from 'src/components/Button';
|
||||||
import { Radio } from 'src/components/Radio';
|
|
||||||
import { RootState } from 'src/dashboard/types';
|
import { RootState } from 'src/dashboard/types';
|
||||||
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
|
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
|
||||||
import { postFormData } from 'src/explore/exploreUtils/formData';
|
import { postFormData } from 'src/explore/exploreUtils/formData';
|
||||||
@ -52,6 +52,11 @@ import DrillByChart from './DrillByChart';
|
|||||||
import { ContextMenuItem } from '../ChartContextMenu/ChartContextMenu';
|
import { ContextMenuItem } from '../ChartContextMenu/ChartContextMenu';
|
||||||
import { useContextMenu } from '../ChartContextMenu/useContextMenu';
|
import { useContextMenu } from '../ChartContextMenu/useContextMenu';
|
||||||
import { getChartDataRequest } from '../chartAction';
|
import { getChartDataRequest } from '../chartAction';
|
||||||
|
import { useDisplayModeToggle } from './useDisplayModeToggle';
|
||||||
|
import {
|
||||||
|
DrillByBreadcrumb,
|
||||||
|
useDrillByBreadcrumbs,
|
||||||
|
} from './useDrillByBreadcrumbs';
|
||||||
|
|
||||||
const DATA_SIZE = 15;
|
const DATA_SIZE = 15;
|
||||||
interface ModalFooterProps {
|
interface ModalFooterProps {
|
||||||
@ -101,12 +106,13 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface DrillByModalProps {
|
export interface DrillByModalProps {
|
||||||
column?: Column;
|
column?: Column;
|
||||||
dataset: Dataset;
|
dataset: Dataset;
|
||||||
filters?: BinaryQueryObjectFilterClause[];
|
filters?: BinaryQueryObjectFilterClause[];
|
||||||
formData: BaseFormData & { [key: string]: any };
|
formData: BaseFormData & { [key: string]: any };
|
||||||
groupbyFieldName?: string;
|
groupbyFieldName?: string;
|
||||||
|
adhocFilterFieldName?: string;
|
||||||
onHideModal: () => void;
|
onHideModal: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,69 +122,134 @@ export default function DrillByModal({
|
|||||||
filters,
|
filters,
|
||||||
formData,
|
formData,
|
||||||
groupbyFieldName = 'groupby',
|
groupbyFieldName = 'groupby',
|
||||||
|
adhocFilterFieldName = 'adhoc_filters',
|
||||||
onHideModal,
|
onHideModal,
|
||||||
}: DrillByModalProps) {
|
}: DrillByModalProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [chartDataResult, setChartDataResult] = useState<QueryData[]>();
|
|
||||||
const [drillByDisplayMode, setDrillByDisplayMode] = useState<DrillByType>(
|
const initialGroupbyColumns = useMemo(
|
||||||
DrillByType.Chart,
|
() =>
|
||||||
|
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(
|
const [datasourceId] = useMemo(
|
||||||
() => formData.datasource.split('__'),
|
() => formData.datasource.split('__'),
|
||||||
[formData.datasource],
|
[formData.datasource],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [currentColumn, setCurrentColumn] = useState(column);
|
const [currentColumn, setCurrentColumn] = useState<Column | undefined>(
|
||||||
const [currentFormData, setCurrentFormData] = useState(formData);
|
|
||||||
const [currentFilters, setCurrentFilters] = useState(filters);
|
|
||||||
const [usedGroupbyColumns, setUsedGroupbyColumns] = useState([
|
|
||||||
...ensureIsArray(formData[groupbyFieldName]).map(colName =>
|
|
||||||
dataset.columns?.find(col => col.column_name === colName),
|
|
||||||
),
|
|
||||||
column,
|
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(() => {
|
const getNewGroupby = useCallback(
|
||||||
let updatedFormData = { ...currentFormData };
|
(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) {
|
if (currentColumn) {
|
||||||
updatedFormData[groupbyFieldName] = Array.isArray(
|
updatedFormData[groupbyFieldName] = getNewGroupby(currentColumn);
|
||||||
currentFormData[groupbyFieldName],
|
|
||||||
)
|
|
||||||
? [currentColumn.column_name]
|
|
||||||
: currentColumn.column_name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentFilters) {
|
|
||||||
const adhocFilters = currentFilters.map(filter =>
|
const adhocFilters = currentFilters.map(filter =>
|
||||||
simpleFilterToAdhoc(filter),
|
simpleFilterToAdhoc(filter),
|
||||||
);
|
);
|
||||||
updatedFormData = {
|
updatedFormData = {
|
||||||
...updatedFormData,
|
...updatedFormData,
|
||||||
adhoc_filters: [
|
[adhocFilterFieldName]: [
|
||||||
...ensureIsArray(currentFormData.adhoc_filters),
|
...ensureIsArray(formData[adhocFilterFieldName]),
|
||||||
...adhocFilters,
|
...adhocFilters,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
|
||||||
updatedFormData.slice_id = 0;
|
updatedFormData.slice_id = 0;
|
||||||
delete updatedFormData.slice_name;
|
delete updatedFormData.slice_name;
|
||||||
delete updatedFormData.dashboards;
|
delete updatedFormData.dashboards;
|
||||||
return updatedFormData;
|
return updatedFormData;
|
||||||
}, [currentColumn, currentFormData, currentFilters, groupbyFieldName]);
|
}, [
|
||||||
|
formData,
|
||||||
|
currentColumn,
|
||||||
|
currentFilters,
|
||||||
|
groupbyFieldName,
|
||||||
|
getNewGroupby,
|
||||||
|
adhocFilterFieldName,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUsedGroupbyColumns(cols =>
|
setUsedGroupbyColumns(usedCols =>
|
||||||
cols.includes(currentColumn) ? cols : [...cols, currentColumn],
|
!currentColumn ||
|
||||||
|
usedCols.some(
|
||||||
|
usedCol => usedCol.column_name === currentColumn.column_name,
|
||||||
|
)
|
||||||
|
? usedCols
|
||||||
|
: [...usedCols, currentColumn],
|
||||||
);
|
);
|
||||||
}, [currentColumn]);
|
}, [currentColumn]);
|
||||||
|
|
||||||
const onSelection = useCallback(
|
const onSelection = useCallback(
|
||||||
(newColumn: Column, filters: BinaryQueryObjectFilterClause[]) => {
|
(newColumn: Column, filters: BinaryQueryObjectFilterClause[]) => {
|
||||||
setCurrentColumn(newColumn);
|
setCurrentColumn(newColumn);
|
||||||
setCurrentFormData(updatedFormData);
|
setCurrentFormData(drilledFormData);
|
||||||
setCurrentFilters(filters);
|
setCurrentFilters(prevFilters => [...prevFilters, ...filters]);
|
||||||
|
setBreadcrumbsData(prevBreadcrumbs => {
|
||||||
|
const newBreadcrumbs = [...prevBreadcrumbs, { groupby: newColumn }];
|
||||||
|
newBreadcrumbs[newBreadcrumbs.length - 2].filters = filters;
|
||||||
|
return newBreadcrumbs;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[updatedFormData],
|
[drilledFormData],
|
||||||
);
|
);
|
||||||
|
|
||||||
const additionalConfig = useMemo(
|
const additionalConfig = useMemo(
|
||||||
@ -206,14 +277,15 @@ export default function DrillByModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (updatedFormData) {
|
if (drilledFormData) {
|
||||||
|
setChartDataResult(undefined);
|
||||||
getChartDataRequest({
|
getChartDataRequest({
|
||||||
formData: updatedFormData,
|
formData: drilledFormData,
|
||||||
}).then(({ json }) => {
|
}).then(({ json }) => {
|
||||||
setChartDataResult(json.result);
|
setChartDataResult(json.result);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [updatedFormData]);
|
}, [drilledFormData]);
|
||||||
const { metadataBar } = useDatasetMetadataBar({ dataset });
|
const { metadataBar } = useDatasetMetadataBar({ dataset });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -226,7 +298,7 @@ export default function DrillByModal({
|
|||||||
show
|
show
|
||||||
onHide={onHideModal ?? (() => null)}
|
onHide={onHideModal ?? (() => null)}
|
||||||
title={t('Drill by: %s', chartName)}
|
title={t('Drill by: %s', chartName)}
|
||||||
footer={<ModalFooter formData={updatedFormData} />}
|
footer={<ModalFooter formData={drilledFormData} />}
|
||||||
responsive
|
responsive
|
||||||
resizable
|
resizable
|
||||||
resizableConfig={{
|
resizableConfig={{
|
||||||
@ -249,38 +321,12 @@ export default function DrillByModal({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{metadataBar}
|
{metadataBar}
|
||||||
<div
|
{breadcrumbs}
|
||||||
css={css`
|
{displayModeToggle}
|
||||||
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>
|
|
||||||
{!chartDataResult && <Loading />}
|
{!chartDataResult && <Loading />}
|
||||||
{drillByDisplayMode === DrillByType.Chart && chartDataResult && (
|
{drillByDisplayMode === DrillByType.Chart && chartDataResult && (
|
||||||
<DrillByChart
|
<DrillByChart
|
||||||
formData={updatedFormData}
|
formData={drilledFormData}
|
||||||
result={chartDataResult}
|
result={chartDataResult}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
inContextMenu={inContextMenu}
|
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.
|
* or extending the components in src/components.
|
||||||
*/
|
*/
|
||||||
export {
|
export {
|
||||||
|
Breadcrumb as AntdBreadcrumb,
|
||||||
Button as AntdButton,
|
Button as AntdButton,
|
||||||
Card as AntdCard,
|
Card as AntdCard,
|
||||||
Checkbox as AntdCheckbox,
|
Checkbox as AntdCheckbox,
|
||||||
|
Loading…
Reference in New Issue
Block a user