mirror of
https://github.com/apache/superset.git
synced 2024-09-18 19:49:37 -04:00
feat: Support further drill by in the modal (#23615)
This commit is contained in:
parent
c8fa44e9e9
commit
587e7759b1
@ -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>;
|
||||||
|
@ -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
|
||||||
) : (
|
) : (
|
@ -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();
|
||||||
|
});
|
@ -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 };
|
||||||
|
};
|
@ -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,
|
||||||
|
@ -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();
|
||||||
|
@ -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%"
|
||||||
/>
|
/>
|
||||||
|
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
@ -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);
|
||||||
|
if (openNewModal) {
|
||||||
setShowModal(true);
|
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 =>
|
||||||
(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(
|
||||||
@ -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>
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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[];
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
Loading…
Reference in New Issue
Block a user