feat: Support further drill by in the modal (#23615)

This commit is contained in:
Kamil Gabryjelski 2023-04-12 13:43:09 +02:00 committed by GitHub
parent c8fa44e9e9
commit 587e7759b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 409 additions and 62 deletions

View File

@ -86,6 +86,10 @@ export type Props = Omit<SuperChartCoreProps, 'chartProps'> &
* If not defined, NoResultsComponent is used * If not defined, NoResultsComponent is used
*/ */
noResults?: ReactNode; noResults?: ReactNode;
/**
* Determines is the context menu related to the chart is open
*/
inContextMenu?: boolean;
}; };
type PropsWithDefault = Props & Readonly<typeof defaultProps>; type PropsWithDefault = Props & Readonly<typeof defaultProps>;

View File

@ -29,6 +29,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { import {
Behavior, Behavior,
ContextMenuFilters, ContextMenuFilters,
ensureIsArray,
FeatureFlag, FeatureFlag,
getChartMetadataRegistry, getChartMetadataRegistry,
isFeatureEnabled, isFeatureEnabled,
@ -39,21 +40,33 @@ import {
import { RootState } from 'src/dashboard/types'; import { RootState } from 'src/dashboard/types';
import { findPermission } from 'src/utils/findPermission'; import { findPermission } from 'src/utils/findPermission';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import { AntdDropdown as Dropdown } from 'src/components'; import { AntdDropdown as Dropdown } from 'src/components/index';
import { DrillDetailMenuItems } from './DrillDetail'; import { updateDataMask } from 'src/dataMask/actions';
import { getMenuAdjustedY } from './utils'; import { DrillDetailMenuItems } from '../DrillDetail';
import { updateDataMask } from '../../dataMask/actions'; import { getMenuAdjustedY } from '../utils';
import { MenuItemTooltip } from './DisabledMenuItemTooltip'; import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { DrillByMenuItems } from './DrillBy/DrillByMenuItems'; import { DrillByMenuItems } from '../DrillBy/DrillByMenuItems';
export enum ContextMenuItem {
CrossFilter,
DrillToDetail,
DrillBy,
All,
}
export interface ChartContextMenuProps { export interface ChartContextMenuProps {
id: number; id: number;
formData: QueryFormData; formData: QueryFormData;
onSelection: () => void; onSelection: () => void;
onClose: () => void; onClose: () => void;
additionalConfig?: {
crossFilter?: Record<string, any>;
drillToDetail?: Record<string, any>;
drillBy?: Record<string, any>;
};
displayedItems?: ContextMenuItem[] | ContextMenuItem;
} }
export interface Ref { export interface ChartContextMenuRef {
open: ( open: (
clientX: number, clientX: number,
clientY: number, clientY: number,
@ -62,8 +75,15 @@ export interface Ref {
} }
const ChartContextMenu = ( const ChartContextMenu = (
{ id, formData, onSelection, onClose }: ChartContextMenuProps, {
ref: RefObject<Ref>, id,
formData,
onSelection,
onClose,
displayedItems = ContextMenuItem.All,
additionalConfig,
}: ChartContextMenuProps,
ref: RefObject<ChartContextMenuRef>,
) => { ) => {
const theme = useTheme(); const theme = useTheme();
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -74,6 +94,10 @@ const ChartContextMenu = (
({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled, ({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled,
); );
const isDisplayed = (item: ContextMenuItem) =>
displayedItems === ContextMenuItem.All ||
ensureIsArray(displayedItems).includes(item);
const [{ filters, clientX, clientY }, setState] = useState<{ const [{ filters, clientX, clientY }, setState] = useState<{
clientX: number; clientX: number;
clientY: number; clientY: number;
@ -83,13 +107,19 @@ const ChartContextMenu = (
const menuItems = []; const menuItems = [];
const showDrillToDetail = const showDrillToDetail =
isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) && canExplore; isFeatureEnabled(FeatureFlag.DRILL_TO_DETAIL) &&
canExplore &&
isDisplayed(ContextMenuItem.DrillToDetail);
const showDrillBy = isFeatureEnabled(FeatureFlag.DRILL_BY) && canExplore; const showDrillBy =
isFeatureEnabled(FeatureFlag.DRILL_BY) &&
canExplore &&
isDisplayed(ContextMenuItem.DrillBy);
const showCrossFilters =
isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS) &&
isDisplayed(ContextMenuItem.CrossFilter);
const showCrossFilters = isFeatureEnabled(
FeatureFlag.DASHBOARD_CROSS_FILTERS,
);
const isCrossFilteringSupportedByChart = getChartMetadataRegistry() const isCrossFilteringSupportedByChart = getChartMetadataRegistry()
.get(formData.viz_type) .get(formData.viz_type)
?.behaviors?.includes(Behavior.INTERACTIVE_CHART); ?.behaviors?.includes(Behavior.INTERACTIVE_CHART);
@ -108,7 +138,7 @@ const ChartContextMenu = (
itemsCount = 1; // "No actions" appears if no actions in menu itemsCount = 1; // "No actions" appears if no actions in menu
} }
if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { if (showCrossFilters) {
const isCrossFilterDisabled = const isCrossFilterDisabled =
!isCrossFilteringSupportedByChart || !isCrossFilteringSupportedByChart ||
!crossFiltersEnabled || !crossFiltersEnabled ||
@ -190,6 +220,7 @@ const ChartContextMenu = (
contextMenuY={clientY} contextMenuY={clientY}
onSelection={onSelection} onSelection={onSelection}
submenuIndex={showCrossFilters ? 2 : 1} submenuIndex={showCrossFilters ? 2 : 1}
{...(additionalConfig?.drillToDetail || {})}
/>, />,
); );
} }
@ -205,9 +236,11 @@ const ChartContextMenu = (
<DrillByMenuItems <DrillByMenuItems
filters={filters?.drillBy?.filters} filters={filters?.drillBy?.filters}
groupbyFieldName={filters?.drillBy?.groupbyFieldName} groupbyFieldName={filters?.drillBy?.groupbyFieldName}
onSelection={onSelection}
formData={formData} formData={formData}
contextMenuY={clientY} contextMenuY={clientY}
submenuIndex={submenuIndex} submenuIndex={submenuIndex}
{...(additionalConfig?.drillBy || {})}
/>, />,
); );
} }
@ -241,7 +274,7 @@ const ChartContextMenu = (
return ReactDOM.createPortal( return ReactDOM.createPortal(
<Dropdown <Dropdown
overlay={ overlay={
<Menu> <Menu className="chart-context-menu" data-test="chart-context-menu">
{menuItems.length ? ( {menuItems.length ? (
menuItems menuItems
) : ( ) : (

View File

@ -0,0 +1,86 @@
/**
* 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 { FeatureFlag } from '@superset-ui/core';
import { render, screen } from 'spec/helpers/testing-library';
import { renderHook } from '@testing-library/react-hooks';
import mockState from 'spec/fixtures/mockState';
import { sliceId } from 'spec/fixtures/mockChartQueries';
import { noOp } from 'src/utils/common';
import { useContextMenu } from './useContextMenu';
import { ContextMenuItem } from './ChartContextMenu';
const CONTEXT_MENU_TEST_ID = 'chart-context-menu';
// @ts-ignore
global.featureFlags = {
[FeatureFlag.DASHBOARD_CROSS_FILTERS]: true,
[FeatureFlag.DRILL_TO_DETAIL]: true,
[FeatureFlag.DRILL_BY]: true,
};
const setup = ({
onSelection = noOp,
displayedItems = ContextMenuItem.All,
additionalConfig = {},
}: {
onSelection?: () => void;
displayedItems?: ContextMenuItem | ContextMenuItem[];
additionalConfig?: Record<string, any>;
} = {}) => {
const { result } = renderHook(() =>
useContextMenu(
sliceId,
{ datasource: '1__table', viz_type: 'pie' },
onSelection,
displayedItems,
additionalConfig,
),
);
render(result.current.contextMenu, {
useRedux: true,
initialState: {
...mockState,
user: {
...mockState.user,
roles: { Admin: [['can_explore', 'Superset']] },
},
},
});
return result;
};
test('Context menu renders', () => {
const result = setup();
expect(screen.queryByTestId(CONTEXT_MENU_TEST_ID)).not.toBeInTheDocument();
result.current.onContextMenu(0, 0, {});
expect(screen.getByTestId(CONTEXT_MENU_TEST_ID)).toBeInTheDocument();
expect(screen.getByText('Add cross-filter')).toBeInTheDocument();
expect(screen.getByText('Drill to detail')).toBeInTheDocument();
expect(screen.getByText('Drill by')).toBeInTheDocument();
});
test('Context menu contains all items only', () => {
const result = setup({
displayedItems: [ContextMenuItem.DrillToDetail, ContextMenuItem.DrillBy],
});
result.current.onContextMenu(0, 0, {});
expect(screen.queryByText('Add cross-filter')).not.toBeInTheDocument();
expect(screen.getByText('Drill to detail')).toBeInTheDocument();
expect(screen.getByText('Drill by')).toBeInTheDocument();
});

View File

@ -0,0 +1,82 @@
/**
* 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, { useCallback, useMemo, useRef, useState } from 'react';
import { BaseFormData, ContextMenuFilters } from '@superset-ui/core';
import ChartContextMenu, {
ChartContextMenuRef,
ContextMenuItem,
} from './ChartContextMenu';
export const useContextMenu = (
chartId: number,
formData: BaseFormData & { [key: string]: any },
onSelection?: (...args: any) => void,
displayedItems?: ContextMenuItem[] | ContextMenuItem,
additionalConfig?: {
crossFilter?: Record<string, any>;
drillToDetail?: Record<string, any>;
drillBy?: Record<string, any>;
},
) => {
const contextMenuRef = useRef<ChartContextMenuRef>(null);
const [inContextMenu, setInContextMenu] = useState(false);
const onContextMenu = (
offsetX: number,
offsetY: number,
filters: ContextMenuFilters,
) => {
contextMenuRef.current?.open(offsetX, offsetY, filters);
setInContextMenu(true);
};
const handleContextMenuSelected = useCallback(
(...args: any) => {
setInContextMenu(false);
onSelection?.(...args);
},
[onSelection],
);
const handleContextMenuClosed = useCallback(() => {
setInContextMenu(false);
}, []);
const contextMenu = useMemo(
() => (
<ChartContextMenu
ref={contextMenuRef}
id={chartId}
formData={formData}
onSelection={handleContextMenuSelected}
onClose={handleContextMenuClosed}
displayedItems={displayedItems}
additionalConfig={additionalConfig}
/>
),
[
additionalConfig,
chartId,
displayedItems,
formData,
handleContextMenuClosed,
handleContextMenuSelected,
],
);
return { contextMenu, inContextMenu, onContextMenu };
};

View File

@ -31,7 +31,7 @@ import {
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils'; import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
import { EmptyStateBig, EmptyStateSmall } from 'src/components/EmptyState'; import { EmptyStateBig, EmptyStateSmall } from 'src/components/EmptyState';
import { ChartSource } from 'src/types/ChartSource'; import { ChartSource } from 'src/types/ChartSource';
import ChartContextMenu from './ChartContextMenu'; import ChartContextMenu from './ChartContextMenu/ChartContextMenu';
const propTypes = { const propTypes = {
annotationData: PropTypes.object, annotationData: PropTypes.object,

View File

@ -19,7 +19,7 @@
import React from 'react'; import React from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library'; import { render, screen, waitFor } from 'spec/helpers/testing-library';
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries'; import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
import fetchMock from 'fetch-mock'; import { noOp } from 'src/utils/common';
import DrillByChart from './DrillByChart'; import DrillByChart from './DrillByChart';
const chart = chartQueries[sliceId]; const chart = chartQueries[sliceId];
@ -28,6 +28,8 @@ const setup = (overrides: Record<string, any> = {}, result?: any) =>
render( render(
<DrillByChart <DrillByChart
formData={{ ...chart.form_data, ...overrides }} formData={{ ...chart.form_data, ...overrides }}
onContextMenu={noOp}
inContextMenu={false}
result={result} result={result}
/>, />,
{ {
@ -38,8 +40,6 @@ const setup = (overrides: Record<string, any> = {}, result?: any) =>
const waitForRender = (overrides: Record<string, any> = {}) => const waitForRender = (overrides: Record<string, any> = {}) =>
waitFor(() => setup(overrides)); waitFor(() => setup(overrides));
afterEach(fetchMock.restore);
test('should render', async () => { test('should render', async () => {
const { container } = await waitForRender(); const { container } = await waitForRender();
expect(container).toBeInTheDocument(); expect(container).toBeInTheDocument();

View File

@ -16,21 +16,34 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { import {
BaseFormData, BaseFormData,
Behavior,
QueryData, QueryData,
SuperChart, SuperChart,
css, css,
ContextMenuFilters,
} from '@superset-ui/core'; } from '@superset-ui/core';
interface DrillByChartProps { interface DrillByChartProps {
formData: BaseFormData & { [key: string]: any }; formData: BaseFormData & { [key: string]: any };
result: QueryData[]; result: QueryData[];
onContextMenu: (
offsetX: number,
offsetY: number,
filters: ContextMenuFilters,
) => void;
inContextMenu: boolean;
} }
export default function DrillByChart({ formData, result }: DrillByChartProps) { export default function DrillByChart({
formData,
result,
onContextMenu,
inContextMenu,
}: DrillByChartProps) {
const hooks = useMemo(() => ({ onContextMenu }), [onContextMenu]);
return ( return (
<div <div
css={css` css={css`
@ -40,11 +53,12 @@ export default function DrillByChart({ formData, result }: DrillByChartProps) {
> >
<SuperChart <SuperChart
disableErrorBoundary disableErrorBoundary
behaviors={[Behavior.INTERACTIVE_CHART]}
chartType={formData.viz_type} chartType={formData.viz_type}
enableNoResults enableNoResults
formData={formData} formData={formData}
queriesData={result} queriesData={result}
hooks={hooks}
inContextMenu={inContextMenu}
height="100%" height="100%"
width="100%" width="100%"
/> />

View File

@ -32,7 +32,9 @@ import { DrillByMenuItems, DrillByMenuItemsProps } from './DrillByMenuItems';
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */ /* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
const datasetEndpointMatcher = 'glob:*/api/v1/dataset/7'; const DATASET_ENDPOINT = 'glob:*/api/v1/dataset/7';
const CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*';
const FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data';
const { form_data: defaultFormData } = chartQueries[sliceId]; const { form_data: defaultFormData } = chartQueries[sliceId];
const defaultColumns = [ const defaultColumns = [
@ -60,6 +62,7 @@ const defaultFilters = [
const renderMenu = ({ const renderMenu = ({
formData = defaultFormData, formData = defaultFormData,
filters = defaultFilters, filters = defaultFilters,
...rest
}: Partial<DrillByMenuItemsProps>) => }: Partial<DrillByMenuItemsProps>) =>
render( render(
<Menu> <Menu>
@ -67,6 +70,7 @@ const renderMenu = ({
formData={formData ?? defaultFormData} formData={formData ?? defaultFormData}
filters={filters} filters={filters}
groupbyFieldName="groupby" groupbyFieldName="groupby"
{...rest}
/> />
</Menu>, </Menu>,
{ useRouter: true, useRedux: true }, { useRouter: true, useRedux: true },
@ -134,19 +138,19 @@ test('render disabled menu item for supported chart, no filters', async () => {
}); });
test('render disabled menu item for supported chart, no columns', async () => { test('render disabled menu item for supported chart, no columns', async () => {
fetchMock.get(datasetEndpointMatcher, { result: { columns: [] } }); fetchMock.get(DATASET_ENDPOINT, { result: { columns: [] } });
renderMenu({}); renderMenu({});
await waitFor(() => fetchMock.called(datasetEndpointMatcher)); await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
await expectDrillByDisabled('No dimensions available for drill by'); await expectDrillByDisabled('No dimensions available for drill by');
}); });
test('render menu item with submenu without searchbox', async () => { test('render menu item with submenu without searchbox', async () => {
const slicedColumns = defaultColumns.slice(0, 9); const slicedColumns = defaultColumns.slice(0, 9);
fetchMock.get(datasetEndpointMatcher, { fetchMock.get(DATASET_ENDPOINT, {
result: { columns: slicedColumns }, result: { columns: slicedColumns },
}); });
renderMenu({}); renderMenu({});
await waitFor(() => fetchMock.called(datasetEndpointMatcher)); await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
await expectDrillByEnabled(); await expectDrillByEnabled();
slicedColumns.forEach(column => { slicedColumns.forEach(column => {
expect(screen.getByText(column.column_name)).toBeInTheDocument(); expect(screen.getByText(column.column_name)).toBeInTheDocument();
@ -155,11 +159,11 @@ test('render menu item with submenu without searchbox', async () => {
}); });
test('render menu item with submenu and searchbox', async () => { test('render menu item with submenu and searchbox', async () => {
fetchMock.get(datasetEndpointMatcher, { fetchMock.get(DATASET_ENDPOINT, {
result: { columns: defaultColumns }, result: { columns: defaultColumns },
}); });
renderMenu({}); renderMenu({});
await waitFor(() => fetchMock.called(datasetEndpointMatcher)); await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
await expectDrillByEnabled(); await expectDrillByEnabled();
defaultColumns.forEach(column => { defaultColumns.forEach(column => {
expect(screen.getByText(column.column_name)).toBeInTheDocument(); expect(screen.getByText(column.column_name)).toBeInTheDocument();
@ -184,3 +188,55 @@ test('render menu item with submenu and searchbox', async () => {
expect(screen.getByText(colName)).toBeInTheDocument(); expect(screen.getByText(colName)).toBeInTheDocument();
}); });
}); });
test('Do not display excluded column in the menu', async () => {
fetchMock.get(DATASET_ENDPOINT, {
result: { columns: defaultColumns },
});
const excludedColNames = ['col3', 'col5'];
renderMenu({
excludedColumns: excludedColNames.map(colName => ({
column_name: colName,
})),
});
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
await expectDrillByEnabled();
excludedColNames.forEach(colName => {
expect(screen.queryByText(colName)).not.toBeInTheDocument();
});
defaultColumns
.filter(column => !excludedColNames.includes(column.column_name))
.forEach(column => {
expect(screen.getByText(column.column_name)).toBeInTheDocument();
});
});
test('When menu item is clicked, call onSelection with clicked column and drill by filters', async () => {
fetchMock
.get(DATASET_ENDPOINT, {
result: { columns: defaultColumns },
})
.post(FORM_DATA_KEY_ENDPOINT, {})
.post(CHART_DATA_ENDPOINT, {});
const onSelectionMock = jest.fn();
renderMenu({
onSelection: onSelectionMock,
});
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
await expectDrillByEnabled();
userEvent.click(screen.getByText('col1'));
expect(onSelectionMock).toHaveBeenCalledWith(
{
column_name: 'col1',
groupby: true,
},
defaultFilters,
);
});

View File

@ -59,8 +59,10 @@ export interface DrillByMenuItemsProps {
contextMenuY?: number; contextMenuY?: number;
submenuIndex?: number; submenuIndex?: number;
groupbyFieldName?: string; groupbyFieldName?: string;
onSelection?: () => void; onSelection?: (...args: any) => void;
onClick?: (event: MouseEvent) => void; onClick?: (event: MouseEvent) => void;
openNewModal?: boolean;
excludedColumns?: Column[];
} }
export const DrillByMenuItems = ({ export const DrillByMenuItems = ({
@ -71,6 +73,8 @@ export const DrillByMenuItems = ({
submenuIndex = 0, submenuIndex = 0,
onSelection = () => {}, onSelection = () => {},
onClick = () => {}, onClick = () => {},
excludedColumns,
openNewModal = true,
...rest ...rest
}: DrillByMenuItemsProps) => { }: DrillByMenuItemsProps) => {
const theme = useTheme(); const theme = useTheme();
@ -80,14 +84,16 @@ export const DrillByMenuItems = ({
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [currentColumn, setCurrentColumn] = useState(); const [currentColumn, setCurrentColumn] = useState();
const openModal = useCallback( const handleSelection = useCallback(
(event, column) => { (event, column) => {
onClick(event); onClick(event);
onSelection(); onSelection(column, filters);
setCurrentColumn(column); setCurrentColumn(column);
setShowModal(true); if (openNewModal) {
setShowModal(true);
}
}, },
[onClick, onSelection], [filters, onClick, onSelection, openNewModal],
); );
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
setShowModal(false); setShowModal(false);
@ -142,12 +148,16 @@ export const DrillByMenuItems = ({
const filteredColumns = useMemo( const filteredColumns = useMemo(
() => () =>
columns.filter(column => columns.filter(
(column.verbose_name || column.column_name) column =>
.toLowerCase() (column.verbose_name || column.column_name)
.includes(searchInput.toLowerCase()), .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(
@ -231,7 +241,7 @@ export const DrillByMenuItems = ({
key={`drill-by-item-${column.column_name}`} key={`drill-by-item-${column.column_name}`}
tooltipText={column.verbose_name || column.column_name} tooltipText={column.verbose_name || column.column_name}
{...rest} {...rest}
onClick={e => openModal(e, column)} onClick={e => handleSelection(e, column)}
> >
{column.verbose_name || column.column_name} {column.verbose_name || column.column_name}
</MenuItemWithTruncation> </MenuItemWithTruncation>

View File

@ -17,7 +17,13 @@
* under the License. * under the License.
*/ */
import React, { useContext, useEffect, useMemo, useState } from 'react'; import React, {
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { import {
BaseFormData, BaseFormData,
BinaryQueryObjectFilterClause, BinaryQueryObjectFilterClause,
@ -34,7 +40,7 @@ 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 { Radio } from 'src/components/Radio';
import { DashboardLayout, 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';
import { noOp } from 'src/utils/common'; import { noOp } from 'src/utils/common';
@ -43,6 +49,8 @@ import { useDatasetMetadataBar } from 'src/features/datasets/metadataBar/useData
import { SingleQueryResultPane } from 'src/explore/components/DataTablesPane/components/SingleQueryResultPane'; import { SingleQueryResultPane } from 'src/explore/components/DataTablesPane/components/SingleQueryResultPane';
import { Dataset, DrillByType } from '../types'; import { Dataset, DrillByType } from '../types';
import DrillByChart from './DrillByChart'; import DrillByChart from './DrillByChart';
import { ContextMenuItem } from '../ChartContextMenu/ChartContextMenu';
import { useContextMenu } from '../ChartContextMenu/useContextMenu';
import { getChartDataRequest } from '../chartAction'; import { getChartDataRequest } from '../chartAction';
const DATA_SIZE = 15; const DATA_SIZE = 15;
@ -119,31 +127,35 @@ export default function DrillByModal({
() => formData.datasource.split('__'), () => formData.datasource.split('__'),
[formData.datasource], [formData.datasource],
); );
const dashboardLayout = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout.present, const [currentColumn, setCurrentColumn] = useState(column);
); const [currentFormData, setCurrentFormData] = useState(formData);
const chartLayoutItem = Object.values(dashboardLayout).find( const [currentFilters, setCurrentFilters] = useState(filters);
layoutItem => layoutItem.meta?.chartId === formData.slice_id, const [usedGroupbyColumns, setUsedGroupbyColumns] = useState([
); ...ensureIsArray(formData[groupbyFieldName]).map(colName =>
const chartName = dataset.columns?.find(col => col.column_name === colName),
chartLayoutItem?.meta.sliceNameOverride || chartLayoutItem?.meta.sliceName; ),
column,
]);
const updatedFormData = useMemo(() => { const updatedFormData = useMemo(() => {
let updatedFormData = { ...formData }; let updatedFormData = { ...currentFormData };
if (column) { if (currentColumn) {
updatedFormData[groupbyFieldName] = Array.isArray( updatedFormData[groupbyFieldName] = Array.isArray(
formData[groupbyFieldName], currentFormData[groupbyFieldName],
) )
? [column.column_name] ? [currentColumn.column_name]
: column.column_name; : currentColumn.column_name;
} }
if (filters) { if (currentFilters) {
const adhocFilters = filters.map(filter => simpleFilterToAdhoc(filter)); const adhocFilters = currentFilters.map(filter =>
simpleFilterToAdhoc(filter),
);
updatedFormData = { updatedFormData = {
...updatedFormData, ...updatedFormData,
adhoc_filters: [ adhoc_filters: [
...ensureIsArray(formData.adhoc_filters), ...ensureIsArray(currentFormData.adhoc_filters),
...adhocFilters, ...adhocFilters,
], ],
}; };
@ -152,7 +164,46 @@ export default function DrillByModal({
delete updatedFormData.slice_name; delete updatedFormData.slice_name;
delete updatedFormData.dashboards; delete updatedFormData.dashboards;
return updatedFormData; return updatedFormData;
}, [column, filters, formData, groupbyFieldName]); }, [currentColumn, currentFormData, currentFilters, groupbyFieldName]);
useEffect(() => {
setUsedGroupbyColumns(cols =>
cols.includes(currentColumn) ? cols : [...cols, currentColumn],
);
}, [currentColumn]);
const onSelection = useCallback(
(newColumn: Column, filters: BinaryQueryObjectFilterClause[]) => {
setCurrentColumn(newColumn);
setCurrentFormData(updatedFormData);
setCurrentFilters(filters);
},
[updatedFormData],
);
const additionalConfig = useMemo(
() => ({
drillBy: { excludedColumns: usedGroupbyColumns, openNewModal: false },
}),
[usedGroupbyColumns],
);
const { contextMenu, inContextMenu, onContextMenu } = useContextMenu(
0,
currentFormData,
onSelection,
ContextMenuItem.DrillBy,
additionalConfig,
);
const chartName = useSelector<RootState, string | undefined>(state => {
const chartLayoutItem = Object.values(state.dashboardLayout.present).find(
layoutItem => layoutItem.meta?.chartId === formData.slice_id,
);
return (
chartLayoutItem?.meta.sliceNameOverride || chartLayoutItem?.meta.sliceName
);
});
useEffect(() => { useEffect(() => {
if (updatedFormData) { if (updatedFormData) {
@ -228,7 +279,12 @@ export default function DrillByModal({
</div> </div>
{!chartDataResult && <Loading />} {!chartDataResult && <Loading />}
{drillByDisplayMode === DrillByType.Chart && chartDataResult && ( {drillByDisplayMode === DrillByType.Chart && chartDataResult && (
<DrillByChart formData={updatedFormData} result={chartDataResult} /> <DrillByChart
formData={updatedFormData}
result={chartDataResult}
onContextMenu={onContextMenu}
inContextMenu={inContextMenu}
/>
)} )}
{drillByDisplayMode === DrillByType.Table && chartDataResult && ( {drillByDisplayMode === DrillByType.Table && chartDataResult && (
<div <div
@ -248,6 +304,7 @@ export default function DrillByModal({
/> />
</div> </div>
)} )}
{contextMenu}
</div> </div>
</Modal> </Modal>
); );

View File

@ -16,6 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { Column } from '@superset-ui/core';
export enum DrillByType { export enum DrillByType {
Chart, Chart,
@ -39,4 +40,5 @@ export type Dataset = {
first_name: string; first_name: string;
last_name: string; last_name: string;
}[]; }[];
columns?: Column[];
}; };

View File

@ -89,6 +89,9 @@ export const filterCardPopoverStyle = (theme: SupersetTheme) => css`
`; `;
export const chartContextMenuStyles = (theme: SupersetTheme) => css` export const chartContextMenuStyles = (theme: SupersetTheme) => css`
.ant-dropdown-menu.chart-context-menu {
min-width: ${theme.gridUnit * 43}px;
}
.ant-dropdown-menu-submenu.chart-context-submenu { .ant-dropdown-menu-submenu.chart-context-submenu {
max-width: ${theme.gridUnit * 60}px; max-width: ${theme.gridUnit * 60}px;
min-width: ${theme.gridUnit * 40}px; min-width: ${theme.gridUnit * 40}px;