mirror of
https://github.com/apache/superset.git
synced 2026-04-18 07:35:09 +00:00
fix: Cosmetic issues (#35122)
This commit is contained in:
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
import {
|
||||
forwardRef,
|
||||
Key,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
useCallback,
|
||||
@@ -43,18 +42,18 @@ import {
|
||||
useTheme,
|
||||
} from '@superset-ui/core';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { usePermissions } from 'src/hooks/usePermissions';
|
||||
import { Dropdown } from '@superset-ui/core/components';
|
||||
import { updateDataMask } from 'src/dataMask/actions';
|
||||
import DrillByModal from 'src/components/Chart/DrillBy/DrillByModal';
|
||||
import { useDatasetDrillInfo } from 'src/hooks/apiResources/datasets';
|
||||
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
|
||||
import { DrillDetailMenuItems } from '../DrillDetail';
|
||||
import { useDrillDetailMenuItems } from '../useDrillDetailMenuItems';
|
||||
import { getMenuAdjustedY } from '../utils';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { DrillByMenuItems } from '../DrillBy/DrillByMenuItems';
|
||||
import { DrillBySubmenu } from '../DrillBy/DrillBySubmenu';
|
||||
import DrillDetailModal from '../DrillDetail/DrillDetailModal';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
|
||||
export enum ContextMenuItem {
|
||||
CrossFilter,
|
||||
@@ -94,8 +93,8 @@ const ChartContextMenu = (
|
||||
}: ChartContextMenuProps,
|
||||
ref: RefObject<ChartContextMenuRef>,
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const theme = useTheme();
|
||||
const { canDrillToDetail, canDrillBy, canDownload } = usePermissions();
|
||||
|
||||
const crossFiltersEnabled = useSelector<RootState, boolean>(
|
||||
@@ -104,7 +103,6 @@ const ChartContextMenu = (
|
||||
const dashboardId = useSelector<RootState, number>(
|
||||
({ dashboardInfo }) => dashboardInfo.id,
|
||||
);
|
||||
const [openKeys, setOpenKeys] = useState<Key[]>([]);
|
||||
|
||||
const [modalFilters, setFilters] = useState<BinaryQueryObjectFilterClause[]>(
|
||||
[],
|
||||
@@ -160,7 +158,6 @@ const ChartContextMenu = (
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setVisible(false);
|
||||
setOpenKeys([]);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -177,7 +174,7 @@ const ChartContextMenu = (
|
||||
setShowDrillByModal(false);
|
||||
}, []);
|
||||
|
||||
const menuItems: React.JSX.Element[] = [];
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
const showDrillToDetail =
|
||||
isFeatureEnabled(FeatureFlag.DrillToDetail) &&
|
||||
@@ -264,6 +261,20 @@ const ChartContextMenu = (
|
||||
itemsCount = 1; // "No actions" appears if no actions in menu
|
||||
}
|
||||
|
||||
const drillDetailMenuItems = useDrillDetailMenuItems({
|
||||
formData: drillFormData,
|
||||
filters: filters?.drillToDetail,
|
||||
setFilters,
|
||||
isContextMenu: true,
|
||||
contextMenuY: clientY,
|
||||
onSelection,
|
||||
submenuIndex: showCrossFilters ? 2 : 1,
|
||||
setShowModal: setDrillModalIsOpen,
|
||||
dataset: filteredDataset,
|
||||
isLoadingDataset,
|
||||
...(additionalConfig?.drillToDetail || {}),
|
||||
});
|
||||
|
||||
if (showCrossFilters) {
|
||||
const isCrossFilterDisabled =
|
||||
!isCrossFilteringSupportedByChart ||
|
||||
@@ -305,74 +316,65 @@ const ChartContextMenu = (
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
menuItems.push(
|
||||
<>
|
||||
<Menu.Item
|
||||
key="cross-filtering-menu-item"
|
||||
disabled={isCrossFilterDisabled}
|
||||
onClick={() => {
|
||||
if (filters?.crossFilter) {
|
||||
dispatch(updateDataMask(id, filters.crossFilter.dataMask));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{filters?.crossFilter?.isCurrentValueSelected ? (
|
||||
t('Remove cross-filter')
|
||||
) : (
|
||||
<div>
|
||||
{t('Add cross-filter')}
|
||||
<MenuItemTooltip
|
||||
title={crossFilteringTooltipTitle}
|
||||
color={!isCrossFilterDisabled ? theme.colorIcon : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Menu.Item>
|
||||
{itemsCount > 1 && <Menu.Divider />}
|
||||
</>,
|
||||
{
|
||||
key: 'cross-filtering-menu-item',
|
||||
label: filters?.crossFilter?.isCurrentValueSelected ? (
|
||||
t('Remove cross-filter')
|
||||
) : (
|
||||
<span>
|
||||
{t('Add cross-filter')}
|
||||
<MenuItemTooltip
|
||||
title={crossFilteringTooltipTitle}
|
||||
color={!isCrossFilterDisabled ? theme.colorIcon : undefined}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
disabled: isCrossFilterDisabled,
|
||||
onClick: () => {
|
||||
if (filters?.crossFilter) {
|
||||
dispatch(updateDataMask(id, filters.crossFilter.dataMask));
|
||||
}
|
||||
},
|
||||
},
|
||||
...(itemsCount > 1
|
||||
? [{ key: 'divider-1', type: 'divider' as const }]
|
||||
: []),
|
||||
);
|
||||
}
|
||||
if (showDrillToDetail) {
|
||||
menuItems.push(
|
||||
<DrillDetailMenuItems
|
||||
formData={drillFormData}
|
||||
filters={filters?.drillToDetail}
|
||||
setFilters={setFilters}
|
||||
isContextMenu
|
||||
contextMenuY={clientY}
|
||||
onSelection={onSelection}
|
||||
submenuIndex={showCrossFilters ? 2 : 1}
|
||||
setShowModal={setDrillModalIsOpen}
|
||||
dataset={filteredDataset}
|
||||
isLoadingDataset={isLoadingDataset}
|
||||
{...(additionalConfig?.drillToDetail || {})}
|
||||
/>,
|
||||
);
|
||||
menuItems.push(...drillDetailMenuItems);
|
||||
}
|
||||
|
||||
if (showDrillBy) {
|
||||
let submenuIndex = 0;
|
||||
if (showCrossFilters) {
|
||||
submenuIndex += 1;
|
||||
if (menuItems.length > 0) {
|
||||
menuItems.push({ key: 'divider-drill-by', type: 'divider' as const });
|
||||
}
|
||||
if (showDrillToDetail) {
|
||||
submenuIndex += 2;
|
||||
}
|
||||
menuItems.push(
|
||||
<DrillByMenuItems
|
||||
drillByConfig={enhancedFilters?.drillBy}
|
||||
onSelection={onSelection}
|
||||
onCloseMenu={closeContextMenu}
|
||||
formData={formData}
|
||||
contextMenuY={clientY}
|
||||
submenuIndex={submenuIndex}
|
||||
open={openKeys.includes('drill-by-submenu')}
|
||||
key="drill-by-submenu"
|
||||
onDrillBy={handleDrillBy}
|
||||
dataset={filteredDataset}
|
||||
isLoadingDataset={isLoadingDataset}
|
||||
{...(additionalConfig?.drillBy || {})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hasDrillBy = enhancedFilters?.drillBy?.groupbyFieldName;
|
||||
const handlesDimensionContextMenu = getChartMetadataRegistry()
|
||||
.get(formData.viz_type)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillBy);
|
||||
const isDrillByDisabled = !handlesDimensionContextMenu || !hasDrillBy;
|
||||
|
||||
// Add a custom render component for DrillBy submenu to support react-window
|
||||
menuItems.push({
|
||||
key: 'drill-by-submenu',
|
||||
disabled: isDrillByDisabled,
|
||||
label: (
|
||||
<DrillBySubmenu
|
||||
drillByConfig={enhancedFilters?.drillBy}
|
||||
onSelection={onSelection}
|
||||
onCloseMenu={closeContextMenu}
|
||||
formData={formData}
|
||||
onDrillBy={handleDrillBy}
|
||||
dataset={filteredDataset}
|
||||
isLoadingDataset={isLoadingDataset}
|
||||
{...(additionalConfig?.drillBy || {})}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const open = useCallback(
|
||||
@@ -404,30 +406,22 @@ const ChartContextMenu = (
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
<Dropdown
|
||||
popupRender={() => (
|
||||
<Menu
|
||||
className="chart-context-menu"
|
||||
data-test="chart-context-menu"
|
||||
onOpenChange={setOpenKeys}
|
||||
onClick={() => {
|
||||
setVisible(false);
|
||||
setOpenKeys([]);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{menuItems.length ? (
|
||||
menuItems
|
||||
) : (
|
||||
<Menu.Item disabled>{t('No actions')}</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
menu={{
|
||||
items:
|
||||
menuItems.length > 0
|
||||
? menuItems
|
||||
: [{ key: 'no-actions', label: t('No actions'), disabled: true }],
|
||||
onClick: () => {
|
||||
setVisible(false);
|
||||
onClose();
|
||||
},
|
||||
}}
|
||||
dropdownRender={menu => (
|
||||
<div data-test="chart-context-menu">{menu}</div>
|
||||
)}
|
||||
trigger={['click']}
|
||||
onOpenChange={value => {
|
||||
setVisible(value);
|
||||
if (!value) {
|
||||
setOpenKeys([]);
|
||||
}
|
||||
}}
|
||||
open={visible}
|
||||
>
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
/**
|
||||
* 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 {
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import {
|
||||
BaseFormData,
|
||||
Behavior,
|
||||
Column,
|
||||
ContextMenuFilters,
|
||||
css,
|
||||
ensureIsArray,
|
||||
getChartMetadataRegistry,
|
||||
t,
|
||||
useTheme,
|
||||
} from '@superset-ui/core';
|
||||
import { Constants, Input, Loading } from '@superset-ui/core/components';
|
||||
import { debounce } from 'lodash';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { InputRef } from 'antd';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { getSubmenuYOffset } from '../utils';
|
||||
import { VirtualizedMenuItem } from '../MenuItemWithTruncation';
|
||||
import { Dataset } from '../types';
|
||||
|
||||
const SUBMENU_HEIGHT = 200;
|
||||
const SHOW_COLUMNS_SEARCH_THRESHOLD = 10;
|
||||
const SEARCH_INPUT_HEIGHT = 48;
|
||||
|
||||
export interface DrillByMenuItemsProps {
|
||||
drillByConfig?: ContextMenuFilters['drillBy'];
|
||||
formData: BaseFormData & { [key: string]: any };
|
||||
contextMenuY?: number;
|
||||
submenuIndex?: number;
|
||||
onSelection?: (...args: any) => void;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
onCloseMenu?: () => void;
|
||||
openNewModal?: boolean;
|
||||
excludedColumns?: Column[];
|
||||
open: boolean;
|
||||
onDrillBy?: (column: Column, dataset: Dataset) => void;
|
||||
dataset?: Dataset;
|
||||
isLoadingDataset?: boolean;
|
||||
}
|
||||
|
||||
export const DrillByMenuItems = ({
|
||||
drillByConfig,
|
||||
formData,
|
||||
contextMenuY = 0,
|
||||
submenuIndex = 0,
|
||||
onSelection = () => {},
|
||||
onClick = () => {},
|
||||
onCloseMenu = () => {},
|
||||
excludedColumns,
|
||||
openNewModal = true,
|
||||
open,
|
||||
onDrillBy,
|
||||
dataset,
|
||||
isLoadingDataset = false,
|
||||
...rest
|
||||
}: DrillByMenuItemsProps) => {
|
||||
const theme = useTheme();
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
|
||||
const ref = useRef<InputRef>(null);
|
||||
const columns = dataset ? ensureIsArray(dataset.drillable_columns) : [];
|
||||
const showSearch = columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
|
||||
|
||||
const handleSelection = useCallback(
|
||||
(event, column) => {
|
||||
onClick(event);
|
||||
onSelection(column, drillByConfig);
|
||||
if (openNewModal && onDrillBy && dataset) {
|
||||
onDrillBy(column, dataset);
|
||||
}
|
||||
onCloseMenu();
|
||||
},
|
||||
[drillByConfig, onClick, onSelection, openNewModal, onDrillBy, dataset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
ref.current?.input?.focus({ preventScroll: true });
|
||||
} else {
|
||||
// Reset search input when menu is closed
|
||||
setSearchInput('');
|
||||
setDebouncedSearchInput('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const hasDrillBy = drillByConfig?.groupbyFieldName;
|
||||
|
||||
const handlesDimensionContextMenu = useMemo(
|
||||
() =>
|
||||
getChartMetadataRegistry()
|
||||
.get(formData.viz_type)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillBy),
|
||||
[formData.viz_type],
|
||||
);
|
||||
|
||||
const debouncedSetSearchInput = useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
setDebouncedSearchInput(value);
|
||||
}, Constants.FAST_DEBOUNCE),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
setSearchInput(value);
|
||||
debouncedSetSearchInput(value);
|
||||
};
|
||||
|
||||
const filteredColumns = useMemo(
|
||||
() =>
|
||||
columns.filter(column =>
|
||||
(column.verbose_name || column.column_name)
|
||||
.toLowerCase()
|
||||
.includes(debouncedSearchInput.toLowerCase()),
|
||||
),
|
||||
[columns, debouncedSearchInput],
|
||||
);
|
||||
|
||||
const submenuYOffset = useMemo(
|
||||
() =>
|
||||
getSubmenuYOffset(
|
||||
contextMenuY,
|
||||
filteredColumns.length || 1,
|
||||
submenuIndex,
|
||||
SUBMENU_HEIGHT,
|
||||
showSearch ? SEARCH_INPUT_HEIGHT : 0,
|
||||
),
|
||||
[contextMenuY, filteredColumns.length, submenuIndex, showSearch],
|
||||
);
|
||||
|
||||
let tooltip: ReactNode;
|
||||
|
||||
if (!handlesDimensionContextMenu) {
|
||||
tooltip = t('Drill by is not yet supported for this chart type');
|
||||
} else if (!hasDrillBy) {
|
||||
tooltip = t('Drill by is not available for this data point');
|
||||
}
|
||||
|
||||
if (!handlesDimensionContextMenu || !hasDrillBy) {
|
||||
return (
|
||||
<Menu.Item key="drill-by-disabled" disabled {...rest}>
|
||||
<div>
|
||||
{t('Drill by')}
|
||||
<MenuItemTooltip title={tooltip} />
|
||||
</div>
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
const Row = ({
|
||||
index,
|
||||
data,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
data: { columns: Column[] };
|
||||
style: CSSProperties;
|
||||
}) => {
|
||||
const { columns, ...rest } = data;
|
||||
const column = columns[index];
|
||||
return (
|
||||
<VirtualizedMenuItem
|
||||
tooltipText={column.verbose_name || column.column_name}
|
||||
onClick={e => handleSelection(e, column)}
|
||||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
{column.verbose_name || column.column_name}
|
||||
</VirtualizedMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
// Don't render drill by menu items when matrixify is enabled
|
||||
if (
|
||||
formData.matrixify_enable_vertical_layout === true ||
|
||||
formData.matrixify_enable_horizontal_layout === true
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.SubMenu
|
||||
key="drill-by-submenu"
|
||||
title={t('Drill by')}
|
||||
popupClassName="chart-context-submenu"
|
||||
popupOffset={[0, submenuYOffset]}
|
||||
{...rest}
|
||||
>
|
||||
<div data-test="drill-by-submenu">
|
||||
{showSearch && (
|
||||
<Input
|
||||
ref={ref}
|
||||
prefix={
|
||||
<Icons.SearchOutlined
|
||||
iconSize="l"
|
||||
iconColor={theme.colorIcon}
|
||||
/>
|
||||
}
|
||||
onChange={e => {
|
||||
e.stopPropagation();
|
||||
handleInput(e.target.value);
|
||||
}}
|
||||
placeholder={t('Search columns')}
|
||||
onClick={e => {
|
||||
// prevent closing menu when clicking on input
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
}}
|
||||
allowClear
|
||||
css={css`
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
margin: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
|
||||
box-shadow: none;
|
||||
`}
|
||||
value={searchInput}
|
||||
/>
|
||||
)}
|
||||
{isLoadingDataset ? (
|
||||
<div
|
||||
css={css`
|
||||
padding: ${theme.sizeUnit * 3}px 0;
|
||||
`}
|
||||
>
|
||||
<Loading position="inline-centered" />
|
||||
</div>
|
||||
) : filteredColumns.length ? (
|
||||
<List
|
||||
width="100%"
|
||||
height={SUBMENU_HEIGHT}
|
||||
itemSize={35}
|
||||
itemCount={filteredColumns.length}
|
||||
itemData={{ columns: filteredColumns, ...rest }}
|
||||
overscanCount={20}
|
||||
>
|
||||
{Row}
|
||||
</List>
|
||||
) : (
|
||||
<Menu.Item disabled key="no-drill-by-columns-found" {...rest}>
|
||||
{t('No columns found')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</div>
|
||||
</Menu.SubMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -30,9 +30,8 @@ import {
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { supersetGetCache } from 'src/utils/cachedSupersetGet';
|
||||
import { DrillByMenuItems, DrillByMenuItemsProps } from './DrillByMenuItems';
|
||||
import { DrillBySubmenu, DrillBySubmenuProps } from './DrillBySubmenu';
|
||||
|
||||
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
|
||||
|
||||
@@ -79,37 +78,29 @@ const defaultFilters = [
|
||||
},
|
||||
];
|
||||
|
||||
const renderMenu = ({
|
||||
const renderSubmenu = ({
|
||||
formData = defaultFormData,
|
||||
drillByConfig = { filters: defaultFilters, groupbyFieldName: 'groupby' },
|
||||
dataset = mockDataset,
|
||||
...rest
|
||||
}: Partial<DrillByMenuItemsProps>) =>
|
||||
}: Partial<DrillBySubmenuProps>) =>
|
||||
render(
|
||||
<Menu forceSubMenuRender>
|
||||
<DrillByMenuItems
|
||||
formData={formData ?? defaultFormData}
|
||||
drillByConfig={drillByConfig}
|
||||
dataset={dataset}
|
||||
open
|
||||
{...rest}
|
||||
/>
|
||||
</Menu>,
|
||||
<DrillBySubmenu
|
||||
formData={formData ?? defaultFormData}
|
||||
drillByConfig={drillByConfig}
|
||||
dataset={dataset}
|
||||
{...rest}
|
||||
/>,
|
||||
{ useRouter: true, useRedux: true },
|
||||
);
|
||||
|
||||
const expectDrillByDisabled = async (tooltipContent: string) => {
|
||||
const drillByMenuItem = screen
|
||||
.getAllByRole('menuitem')
|
||||
.find(menuItem => within(menuItem).queryByText('Drill by'));
|
||||
const drillByButton = screen.getByRole('button', { name: /drill by/i });
|
||||
expect(drillByButton).toBeInTheDocument();
|
||||
expect(drillByButton).toBeVisible();
|
||||
expect(drillByButton).toHaveAttribute('tabindex', '-1');
|
||||
|
||||
expect(drillByMenuItem).toBeDefined();
|
||||
expect(drillByMenuItem).toBeVisible();
|
||||
expect(drillByMenuItem).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
const tooltipTrigger = within(drillByMenuItem!).getByTestId(
|
||||
'tooltip-trigger',
|
||||
);
|
||||
const tooltipTrigger = within(drillByButton).getByTestId('tooltip-trigger');
|
||||
userEvent.hover(tooltipTrigger as HTMLElement);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip', { name: tooltipContent });
|
||||
@@ -117,20 +108,17 @@ const expectDrillByDisabled = async (tooltipContent: string) => {
|
||||
};
|
||||
|
||||
const expectDrillByEnabled = async () => {
|
||||
const drillByMenuItem = screen.getByRole('menuitem', {
|
||||
name: 'Drill by',
|
||||
});
|
||||
expect(drillByMenuItem).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(drillByMenuItem).not.toHaveAttribute('aria-disabled'),
|
||||
);
|
||||
const tooltipTrigger =
|
||||
within(drillByMenuItem).queryByTestId('tooltip-trigger');
|
||||
const drillByButton = screen.getByRole('button', { name: /drill by/i });
|
||||
expect(drillByButton).toBeInTheDocument();
|
||||
expect(drillByButton).not.toHaveAttribute('tabindex', '-1');
|
||||
|
||||
const tooltipTrigger = within(drillByButton).queryByTestId('tooltip-trigger');
|
||||
expect(tooltipTrigger).not.toBeInTheDocument();
|
||||
|
||||
userEvent.hover(within(drillByMenuItem).getByText('Drill by'));
|
||||
const drillBySubmenus = await screen.findAllByTestId('drill-by-submenu');
|
||||
expect(drillBySubmenus[0]).toBeInTheDocument();
|
||||
userEvent.hover(drillByButton);
|
||||
|
||||
const popover = await screen.findByRole('menu');
|
||||
expect(popover).toBeInTheDocument();
|
||||
};
|
||||
|
||||
getChartMetadataRegistry().registerValue(
|
||||
@@ -149,7 +137,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
test('render disabled menu item for unsupported chart', async () => {
|
||||
renderMenu({
|
||||
renderSubmenu({
|
||||
formData: { ...defaultFormData, viz_type: 'unsupported_viz' },
|
||||
});
|
||||
await expectDrillByDisabled(
|
||||
@@ -158,89 +146,75 @@ test('render disabled menu item for unsupported chart', async () => {
|
||||
});
|
||||
|
||||
test('render enabled menu item for supported chart, no filters', async () => {
|
||||
renderMenu({ drillByConfig: { filters: [], groupbyFieldName: 'groupby' } });
|
||||
renderSubmenu({
|
||||
drillByConfig: { filters: [], groupbyFieldName: 'groupby' },
|
||||
});
|
||||
await expectDrillByEnabled();
|
||||
});
|
||||
|
||||
test('render disabled menu item for supported chart, no columns', async () => {
|
||||
const emptyDataset = { ...mockDataset, columns: [], drillable_columns: [] };
|
||||
renderMenu({ dataset: emptyDataset });
|
||||
renderSubmenu({ dataset: emptyDataset });
|
||||
await expectDrillByEnabled();
|
||||
screen.getByText('No columns found');
|
||||
|
||||
const noColumnsText = await screen.findByText('No columns found');
|
||||
expect(noColumnsText).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('render menu item with submenu without searchbox', async () => {
|
||||
const slicedColumns = defaultColumns.slice(0, 9);
|
||||
const slicedColumns = defaultColumns.slice(0, 1); // Use only 1 column to avoid search box
|
||||
const datasetWithSlicedColumns = {
|
||||
...mockDataset,
|
||||
columns: slicedColumns,
|
||||
drillable_columns: slicedColumns,
|
||||
};
|
||||
renderMenu({ dataset: datasetWithSlicedColumns });
|
||||
renderSubmenu({ dataset: datasetWithSlicedColumns });
|
||||
await expectDrillByEnabled();
|
||||
|
||||
// Check that each column appears in the drill-by submenu
|
||||
slicedColumns.forEach(column => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0]; // Use the first submenu
|
||||
expect(within(submenu).getByText(column.column_name)).toBeInTheDocument();
|
||||
});
|
||||
// Check that the column appears in the popover
|
||||
const col1Element = await screen.findByText('col1');
|
||||
expect(col1Element).toBeInTheDocument();
|
||||
|
||||
// Should not have search box for small number of columns
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Add global timeout for all tests
|
||||
jest.setTimeout(20000);
|
||||
|
||||
test('render menu item with submenu and searchbox', async () => {
|
||||
renderMenu({ dataset: mockDataset });
|
||||
renderSubmenu({ dataset: mockDataset });
|
||||
await expectDrillByEnabled();
|
||||
|
||||
// Wait for all columns to be visible
|
||||
await waitFor(
|
||||
() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
defaultColumns.forEach(column => {
|
||||
expect(
|
||||
within(submenu).getByText(column.column_name),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
// Wait for first column to ensure menu is loaded
|
||||
await screen.findByText('col1');
|
||||
|
||||
const searchbox = await waitFor(
|
||||
() => screen.getAllByPlaceholderText('Search columns')[0],
|
||||
);
|
||||
// Then check all columns are visible
|
||||
defaultColumns.forEach(column => {
|
||||
expect(screen.getByText(column.column_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const searchbox = screen.getByPlaceholderText('Search columns');
|
||||
expect(searchbox).toBeInTheDocument();
|
||||
|
||||
userEvent.type(searchbox, 'col1');
|
||||
|
||||
const expectedFilteredColumnNames = ['col1', 'col10', 'col11'];
|
||||
|
||||
// Wait for filtered results
|
||||
// Wait for filtering to take effect by checking for first filtered item
|
||||
await waitFor(() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
expectedFilteredColumnNames.forEach(colName => {
|
||||
expect(within(submenu).getByText(colName)).toBeInTheDocument();
|
||||
});
|
||||
// Check that non-matching columns are not visible
|
||||
expect(screen.queryByText('col2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
// Then verify all expected columns are visible
|
||||
expectedFilteredColumnNames.forEach(colName => {
|
||||
expect(screen.getByText(colName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that non-matching columns are not visible
|
||||
defaultColumns
|
||||
.filter(col => !expectedFilteredColumnNames.includes(col.column_name))
|
||||
.forEach(col => {
|
||||
expect(
|
||||
within(submenu).queryByText(col.column_name),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(col.column_name)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expectedFilteredColumnNames.forEach(colName => {
|
||||
expect(within(submenu).getByText(colName)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('Do not display excluded column in the menu', async () => {
|
||||
@@ -252,7 +226,7 @@ test('Do not display excluded column in the menu', async () => {
|
||||
...mockDataset,
|
||||
drillable_columns: filteredColumns,
|
||||
};
|
||||
renderMenu({
|
||||
renderSubmenu({
|
||||
dataset: datasetWithFilteredColumns,
|
||||
excludedColumns: excludedColNames.map(colName => ({
|
||||
column_name: colName,
|
||||
@@ -261,32 +235,24 @@ test('Do not display excluded column in the menu', async () => {
|
||||
|
||||
await expectDrillByEnabled();
|
||||
|
||||
// Wait for menu items to be loaded
|
||||
await waitFor(
|
||||
() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
defaultColumns
|
||||
.filter(column => !excludedColNames.includes(column.column_name))
|
||||
.forEach(column => {
|
||||
expect(
|
||||
within(submenu).getByText(column.column_name),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
// Wait for first column to ensure menu is loaded
|
||||
await screen.findByText('col1');
|
||||
|
||||
// Then check all non-excluded columns are visible
|
||||
defaultColumns
|
||||
.filter(column => !excludedColNames.includes(column.column_name))
|
||||
.forEach(column => {
|
||||
expect(screen.getByText(column.column_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
excludedColNames.forEach(colName => {
|
||||
expect(within(submenu).queryByText(colName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(colName)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('When menu item is clicked, call onSelection with clicked column and drill by filters', async () => {
|
||||
const onSelectionMock = jest.fn();
|
||||
renderMenu({
|
||||
renderSubmenu({
|
||||
dataset: mockDataset,
|
||||
onSelection: onSelectionMock,
|
||||
});
|
||||
@@ -294,11 +260,7 @@ test('When menu item is clicked, call onSelection with clicked column and drill
|
||||
await expectDrillByEnabled();
|
||||
|
||||
// Wait for col1 to be visible before clicking
|
||||
const col1Element = await waitFor(() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
return within(submenu).getByText('col1');
|
||||
});
|
||||
const col1Element = await screen.findByText('col1');
|
||||
userEvent.click(col1Element);
|
||||
|
||||
expect(onSelectionMock).toHaveBeenCalledWith(
|
||||
@@ -309,3 +271,10 @@ test('When menu item is clicked, call onSelection with clicked column and drill
|
||||
{ filters: defaultFilters, groupbyFieldName: 'groupby' },
|
||||
);
|
||||
});
|
||||
|
||||
test('matrixify_enable_vertical_layout should not render component', () => {
|
||||
const { container } = renderSubmenu({
|
||||
formData: { ...defaultFormData, matrixify_enable_vertical_layout: true },
|
||||
});
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* 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 {
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
BaseFormData,
|
||||
Behavior,
|
||||
Column,
|
||||
ContextMenuFilters,
|
||||
css,
|
||||
ensureIsArray,
|
||||
getChartMetadataRegistry,
|
||||
t,
|
||||
useTheme,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
Constants,
|
||||
Input,
|
||||
Loading,
|
||||
Popover,
|
||||
Icons,
|
||||
} from '@superset-ui/core/components';
|
||||
import { debounce } from 'lodash';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { InputRef } from 'antd';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { VirtualizedMenuItem } from '../MenuItemWithTruncation';
|
||||
import { Dataset } from '../types';
|
||||
|
||||
const SUBMENU_HEIGHT = 200;
|
||||
const SHOW_COLUMNS_SEARCH_THRESHOLD = 10;
|
||||
|
||||
export interface DrillBySubmenuProps {
|
||||
drillByConfig?: ContextMenuFilters['drillBy'];
|
||||
formData: BaseFormData & { [key: string]: any };
|
||||
onSelection?: (...args: any) => void;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
onCloseMenu?: () => void;
|
||||
openNewModal?: boolean;
|
||||
excludedColumns?: Column[];
|
||||
onDrillBy?: (column: Column, dataset: Dataset) => void;
|
||||
dataset?: Dataset;
|
||||
isLoadingDataset?: boolean;
|
||||
}
|
||||
|
||||
export const DrillBySubmenu = ({
|
||||
drillByConfig,
|
||||
formData,
|
||||
onSelection = () => {},
|
||||
onClick = () => {},
|
||||
onCloseMenu = () => {},
|
||||
openNewModal = true,
|
||||
excludedColumns,
|
||||
onDrillBy,
|
||||
dataset,
|
||||
isLoadingDataset = false,
|
||||
...rest
|
||||
}: DrillBySubmenuProps) => {
|
||||
const theme = useTheme();
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const ref = useRef<InputRef>(null);
|
||||
const menuItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const columns = useMemo(
|
||||
() => (dataset ? ensureIsArray(dataset.drillable_columns) : []),
|
||||
[dataset],
|
||||
);
|
||||
const showSearch = columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
|
||||
|
||||
const handleSelection = useCallback(
|
||||
(event, column) => {
|
||||
onClick(event as MouseEvent);
|
||||
onSelection(column, drillByConfig);
|
||||
if (openNewModal && onDrillBy && dataset) {
|
||||
onDrillBy(column, dataset);
|
||||
}
|
||||
setPopoverOpen(false);
|
||||
onCloseMenu();
|
||||
},
|
||||
[
|
||||
drillByConfig,
|
||||
onClick,
|
||||
onSelection,
|
||||
openNewModal,
|
||||
onDrillBy,
|
||||
dataset,
|
||||
onCloseMenu,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
if (popoverOpen) {
|
||||
// Small delay to ensure popover is rendered
|
||||
timeoutId = setTimeout(() => {
|
||||
ref.current?.input?.focus({ preventScroll: true });
|
||||
}, 100);
|
||||
} else {
|
||||
// Reset search input when menu is closed
|
||||
setSearchInput('');
|
||||
setDebouncedSearchInput('');
|
||||
}
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [popoverOpen]);
|
||||
|
||||
const hasDrillBy = drillByConfig?.groupbyFieldName;
|
||||
|
||||
const handlesDimensionContextMenu = useMemo(
|
||||
() =>
|
||||
getChartMetadataRegistry()
|
||||
.get(formData.viz_type)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillBy),
|
||||
[formData.viz_type],
|
||||
);
|
||||
|
||||
const debouncedSetSearchInput = useMemo(
|
||||
() =>
|
||||
debounce((value: string) => {
|
||||
setDebouncedSearchInput(value);
|
||||
}, Constants.FAST_DEBOUNCE),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
setSearchInput(value);
|
||||
debouncedSetSearchInput(value);
|
||||
};
|
||||
|
||||
const filteredColumns = useMemo(
|
||||
() =>
|
||||
columns
|
||||
.filter(column => {
|
||||
// Filter out excluded columns
|
||||
const excludedColumnNames =
|
||||
excludedColumns?.map(col => col.column_name) || [];
|
||||
return !excludedColumnNames.includes(column.column_name);
|
||||
})
|
||||
.filter(column =>
|
||||
(column.verbose_name || column.column_name)
|
||||
.toLowerCase()
|
||||
.includes(debouncedSearchInput.toLowerCase()),
|
||||
),
|
||||
[columns, debouncedSearchInput, excludedColumns],
|
||||
);
|
||||
|
||||
let tooltip: ReactNode;
|
||||
|
||||
if (!handlesDimensionContextMenu) {
|
||||
tooltip = t('Drill by is not yet supported for this chart type');
|
||||
} else if (!hasDrillBy) {
|
||||
tooltip = t('Drill by is not available for this data point');
|
||||
}
|
||||
|
||||
if (
|
||||
formData.matrixify_enable_vertical_layout === true ||
|
||||
formData.matrixify_enable_horizontal_layout === true
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDisabled = !handlesDimensionContextMenu || !hasDrillBy;
|
||||
|
||||
const Row = ({
|
||||
index,
|
||||
data,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
data: { columns: Column[] };
|
||||
style: CSSProperties;
|
||||
}) => {
|
||||
const { columns } = data;
|
||||
const column = columns[index];
|
||||
return (
|
||||
<VirtualizedMenuItem
|
||||
tooltipText={column.verbose_name || column.column_name}
|
||||
onClick={e => handleSelection(e, column)}
|
||||
style={style}
|
||||
>
|
||||
{column.verbose_name || column.column_name}
|
||||
</VirtualizedMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const popoverContent = (
|
||||
<div
|
||||
role="menu"
|
||||
tabIndex={0}
|
||||
data-test="drill-by-submenu"
|
||||
css={css`
|
||||
width: 220px;
|
||||
max-width: 220px;
|
||||
.ant-input-affix-wrapper {
|
||||
margin-bottom: ${theme.sizeUnit * 2}px;
|
||||
}
|
||||
`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{showSearch && (
|
||||
<Input
|
||||
ref={ref}
|
||||
prefix={
|
||||
<Icons.SearchOutlined iconSize="l" iconColor={theme.colorIcon} />
|
||||
}
|
||||
onChange={e => {
|
||||
e.stopPropagation();
|
||||
handleInput(e.target.value);
|
||||
}}
|
||||
placeholder={t('Search columns')}
|
||||
onClick={e => {
|
||||
// prevent closing menu when clicking on input
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
}}
|
||||
allowClear
|
||||
css={css`
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
`}
|
||||
value={searchInput}
|
||||
/>
|
||||
)}
|
||||
{isLoadingDataset ? (
|
||||
<div
|
||||
css={css`
|
||||
padding: ${theme.sizeUnit * 3}px 0;
|
||||
`}
|
||||
>
|
||||
<Loading position="inline-centered" />
|
||||
</div>
|
||||
) : filteredColumns.length ? (
|
||||
<List
|
||||
width="100%"
|
||||
height={SUBMENU_HEIGHT}
|
||||
itemSize={35}
|
||||
itemCount={filteredColumns.length}
|
||||
itemData={{ columns: filteredColumns }}
|
||||
overscanCount={20}
|
||||
>
|
||||
{Row}
|
||||
</List>
|
||||
) : (
|
||||
<div
|
||||
css={css`
|
||||
padding: ${theme.sizeUnit * 2}px;
|
||||
color: ${theme.colorTextDisabled};
|
||||
text-align: center;
|
||||
`}
|
||||
>
|
||||
{t('No columns found')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const menuItem = (
|
||||
<div
|
||||
ref={menuItemRef}
|
||||
role="button"
|
||||
tabIndex={isDisabled ? -1 : 0}
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
|
||||
color: ${isDisabled ? theme.colorTextDisabled : 'inherit'};
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
`}
|
||||
onClick={() => !isDisabled && setPopoverOpen(!popoverOpen)}
|
||||
onKeyDown={e => {
|
||||
if (!isDisabled && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
setPopoverOpen(!popoverOpen);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{t('Drill by')}</span>
|
||||
{isDisabled ? (
|
||||
<MenuItemTooltip title={tooltip} />
|
||||
) : (
|
||||
<Icons.RightOutlined iconSize="s" iconColor={theme.colorTextTertiary} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isDisabled) {
|
||||
return menuItem;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={popoverContent}
|
||||
placement="rightTop"
|
||||
open={popoverOpen}
|
||||
onOpenChange={setPopoverOpen}
|
||||
trigger={['hover', 'click']}
|
||||
arrow={false}
|
||||
styles={{
|
||||
root: {
|
||||
paddingLeft: 0,
|
||||
},
|
||||
body: {
|
||||
padding: theme.sizeUnit * 2,
|
||||
boxShadow: theme.boxShadow,
|
||||
borderRadius: theme.borderRadius,
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{menuItem}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -1,253 +0,0 @@
|
||||
/**
|
||||
* 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 {
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { isEmpty } from 'lodash';
|
||||
import {
|
||||
Behavior,
|
||||
BinaryQueryObjectFilterClause,
|
||||
css,
|
||||
extractQueryFields,
|
||||
getChartMetadataRegistry,
|
||||
QueryFormData,
|
||||
removeHTMLTags,
|
||||
styled,
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { getSubmenuYOffset } from '../utils';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
|
||||
import { Dataset } from '../types';
|
||||
|
||||
const DRILL_TO_DETAIL = t('Drill to detail');
|
||||
const DRILL_TO_DETAIL_BY = t('Drill to detail by');
|
||||
const DISABLED_REASONS = {
|
||||
DATABASE: t(
|
||||
'Drill to detail is disabled for this database. Change the database settings to enable it.',
|
||||
),
|
||||
NO_AGGREGATIONS: t(
|
||||
'Drill to detail is disabled because this chart does not group data by dimension value.',
|
||||
),
|
||||
NO_FILTERS: t(
|
||||
'Right-click on a dimension value to drill to detail by that value.',
|
||||
),
|
||||
NOT_SUPPORTED: t(
|
||||
'Drill to detail by value is not yet supported for this chart type.',
|
||||
),
|
||||
};
|
||||
|
||||
const DisabledMenuItem = ({
|
||||
children,
|
||||
menuKey,
|
||||
...rest
|
||||
}: {
|
||||
children: ReactNode;
|
||||
menuKey: string;
|
||||
}) => (
|
||||
<Menu.Item disabled key={menuKey} {...rest}>
|
||||
<div
|
||||
css={css`
|
||||
white-space: normal;
|
||||
max-width: 160px;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Menu.Item>
|
||||
);
|
||||
|
||||
const Filter = ({
|
||||
children,
|
||||
stripHTML = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
stripHTML: boolean;
|
||||
}) => {
|
||||
const content =
|
||||
stripHTML && typeof children === 'string'
|
||||
? removeHTMLTags(children)
|
||||
: children;
|
||||
return <span>{content}</span>;
|
||||
};
|
||||
|
||||
const StyledFilter = styled(Filter)`
|
||||
${({ theme }) => `
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
color: ${theme.colorPrimary};
|
||||
`}
|
||||
`;
|
||||
|
||||
export type DrillDetailMenuItemsProps = {
|
||||
formData: QueryFormData;
|
||||
filters?: BinaryQueryObjectFilterClause[];
|
||||
setFilters: Dispatch<SetStateAction<BinaryQueryObjectFilterClause[]>>;
|
||||
isContextMenu?: boolean;
|
||||
contextMenuY?: number;
|
||||
onSelection?: () => void;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
submenuIndex?: number;
|
||||
setShowModal: (show: boolean) => void;
|
||||
key?: string;
|
||||
forceSubmenuRender?: boolean;
|
||||
dataset?: Dataset;
|
||||
isLoadingDataset?: boolean;
|
||||
};
|
||||
|
||||
const DrillDetailMenuItems = ({
|
||||
formData,
|
||||
filters = [],
|
||||
isContextMenu = false,
|
||||
contextMenuY = 0,
|
||||
onSelection = () => null,
|
||||
onClick = () => null,
|
||||
submenuIndex = 0,
|
||||
setFilters,
|
||||
setShowModal,
|
||||
key,
|
||||
...props
|
||||
}: DrillDetailMenuItemsProps) => {
|
||||
const drillToDetailDisabled = useSelector<RootState, boolean | undefined>(
|
||||
({ datasources }) =>
|
||||
datasources[formData.datasource]?.database?.disable_drill_to_detail,
|
||||
);
|
||||
|
||||
const openModal = useCallback(
|
||||
(filters, event) => {
|
||||
onClick(event);
|
||||
onSelection();
|
||||
setFilters(filters);
|
||||
setShowModal(true);
|
||||
},
|
||||
[onClick, onSelection],
|
||||
);
|
||||
|
||||
// Check for Behavior.DRILL_TO_DETAIL to tell if plugin handles the `contextmenu`
|
||||
// event for dimensions. If it doesn't, tell the user that drill to detail by
|
||||
// dimension is not supported. If it does, and the `contextmenu` handler didn't
|
||||
// pass any filters, tell the user that they didn't select a dimension.
|
||||
const handlesDimensionContextMenu = useMemo(
|
||||
() =>
|
||||
getChartMetadataRegistry()
|
||||
.get(formData.viz_type)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail),
|
||||
[formData.viz_type],
|
||||
);
|
||||
|
||||
// Check metrics to see if chart's current configuration lacks
|
||||
// aggregations, in which case Drill to Detail should be disabled.
|
||||
const noAggregations = useMemo(() => {
|
||||
const { metrics } = extractQueryFields(formData);
|
||||
return isEmpty(metrics);
|
||||
}, [formData]);
|
||||
|
||||
// Ensure submenu doesn't appear offscreen
|
||||
const submenuYOffset = useMemo(
|
||||
() =>
|
||||
getSubmenuYOffset(
|
||||
contextMenuY,
|
||||
filters.length > 1 ? filters.length + 1 : filters.length,
|
||||
submenuIndex,
|
||||
),
|
||||
[contextMenuY, filters.length, submenuIndex],
|
||||
);
|
||||
|
||||
let drillDisabled;
|
||||
let drillByDisabled;
|
||||
if (drillToDetailDisabled) {
|
||||
drillDisabled = DISABLED_REASONS.DATABASE;
|
||||
drillByDisabled = DISABLED_REASONS.DATABASE;
|
||||
} else if (handlesDimensionContextMenu) {
|
||||
if (noAggregations) {
|
||||
drillDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
|
||||
drillByDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
|
||||
} else if (!filters?.length) {
|
||||
drillByDisabled = DISABLED_REASONS.NO_FILTERS;
|
||||
}
|
||||
} else {
|
||||
drillByDisabled = DISABLED_REASONS.NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
const drillToDetailMenuItem = drillDisabled ? (
|
||||
<DisabledMenuItem menuKey="drill-to-detail-disabled" {...props}>
|
||||
{DRILL_TO_DETAIL}
|
||||
<MenuItemTooltip title={drillDisabled} />
|
||||
</DisabledMenuItem>
|
||||
) : (
|
||||
<Menu.Item key="drill-to-detail" onClick={openModal.bind(null, [])}>
|
||||
{DRILL_TO_DETAIL}
|
||||
</Menu.Item>
|
||||
);
|
||||
|
||||
const drillToDetailByMenuItem = drillByDisabled ? (
|
||||
<DisabledMenuItem menuKey="drill-to-detail-by-disabled" {...props}>
|
||||
{DRILL_TO_DETAIL_BY}
|
||||
<MenuItemTooltip title={drillByDisabled} />
|
||||
</DisabledMenuItem>
|
||||
) : (
|
||||
<Menu.SubMenu
|
||||
popupOffset={[0, submenuYOffset]}
|
||||
popupClassName="chart-context-submenu"
|
||||
title={DRILL_TO_DETAIL_BY}
|
||||
key={key}
|
||||
{...props}
|
||||
>
|
||||
<div data-test="drill-to-detail-by-submenu">
|
||||
{filters.map((filter, i) => (
|
||||
<MenuItemWithTruncation
|
||||
tooltipText={`${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`}
|
||||
menuKey={`drill-detail-filter-${i}`}
|
||||
onClick={openModal.bind(null, [filter])}
|
||||
>
|
||||
{`${DRILL_TO_DETAIL_BY} `}
|
||||
<StyledFilter stripHTML>{filter.formattedVal}</StyledFilter>
|
||||
</MenuItemWithTruncation>
|
||||
))}
|
||||
{filters.length > 1 && (
|
||||
<Menu.Item
|
||||
key="drill-detail-filter-all"
|
||||
onClick={openModal.bind(null, filters)}
|
||||
>
|
||||
<div>
|
||||
{`${DRILL_TO_DETAIL_BY} `}
|
||||
<StyledFilter stripHTML={false}>{t('all')}</StyledFilter>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</div>
|
||||
</Menu.SubMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{drillToDetailMenuItem}
|
||||
{isContextMenu && drillToDetailByMenuItem}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrillDetailMenuItems;
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { default as DrillDetailMenuItems } from './DrillDetailMenuItems';
|
||||
export { useDrillDetailMenuItems } from './useDrillDetailMenuItems';
|
||||
@@ -37,11 +37,12 @@ import {
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { type ItemType } from '@superset-ui/core/components/Menu';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { getSubmenuYOffset } from '../utils';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { useMenuItemWithTruncation } from '../MenuItemWithTruncation';
|
||||
import { TruncatedMenuLabel } from '../MenuItemWithTruncation';
|
||||
import { Dataset } from '../types';
|
||||
|
||||
const DRILL_TO_DETAIL = t('Drill to detail');
|
||||
const DRILL_TO_DETAIL_BY = t('Drill to detail by');
|
||||
@@ -60,28 +61,6 @@ const DISABLED_REASONS = {
|
||||
),
|
||||
};
|
||||
|
||||
function getDisabledMenuItem(
|
||||
children: ReactNode,
|
||||
menuKey: string,
|
||||
...rest: unknown[]
|
||||
): MenuItem {
|
||||
return {
|
||||
disabled: true,
|
||||
key: menuKey,
|
||||
label: (
|
||||
<div
|
||||
css={css`
|
||||
white-space: normal;
|
||||
max-width: 160px;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
const Filter = ({
|
||||
children,
|
||||
stripHTML = false,
|
||||
@@ -103,7 +82,7 @@ const StyledFilter = styled(Filter)`
|
||||
`}
|
||||
`;
|
||||
|
||||
export type DrillDetailMenuItemsArgs = {
|
||||
export type DrillDetailMenuItemsProps = {
|
||||
formData: QueryFormData;
|
||||
filters?: BinaryQueryObjectFilterClause[];
|
||||
setFilters: Dispatch<SetStateAction<BinaryQueryObjectFilterClause[]>>;
|
||||
@@ -115,6 +94,8 @@ export type DrillDetailMenuItemsArgs = {
|
||||
setShowModal: (show: boolean) => void;
|
||||
key?: string;
|
||||
forceSubmenuRender?: boolean;
|
||||
dataset?: Dataset;
|
||||
isLoadingDataset?: boolean;
|
||||
};
|
||||
|
||||
export const useDrillDetailMenuItems = ({
|
||||
@@ -129,7 +110,7 @@ export const useDrillDetailMenuItems = ({
|
||||
setShowModal,
|
||||
key,
|
||||
...props
|
||||
}: DrillDetailMenuItemsArgs) => {
|
||||
}: DrillDetailMenuItemsProps): ItemType[] => {
|
||||
const drillToDetailDisabled = useSelector<RootState, boolean | undefined>(
|
||||
({ datasources }) =>
|
||||
datasources[formData.datasource]?.database?.disable_drill_to_detail,
|
||||
@@ -142,7 +123,7 @@ export const useDrillDetailMenuItems = ({
|
||||
setFilters(filters);
|
||||
setShowModal(true);
|
||||
},
|
||||
[onClick, onSelection],
|
||||
[onClick, onSelection, setFilters, setShowModal],
|
||||
);
|
||||
|
||||
// Check for Behavior.DRILL_TO_DETAIL to tell if plugin handles the `contextmenu`
|
||||
@@ -191,79 +172,110 @@ export const useDrillDetailMenuItems = ({
|
||||
drillByDisabled = DISABLED_REASONS.NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
const drillToDetailMenuItem: MenuItem = drillDisabled
|
||||
? getDisabledMenuItem(
|
||||
<>
|
||||
{DRILL_TO_DETAIL}
|
||||
<MenuItemTooltip title={drillDisabled} />
|
||||
</>,
|
||||
'drill-to-detail-disabled',
|
||||
props,
|
||||
)
|
||||
const drillToDetailMenuItem: ItemType = drillDisabled
|
||||
? {
|
||||
key: 'drill-to-detail-disabled',
|
||||
disabled: true,
|
||||
label: (
|
||||
<div
|
||||
css={css`
|
||||
white-space: normal;
|
||||
max-width: 160px;
|
||||
`}
|
||||
>
|
||||
{DRILL_TO_DETAIL}
|
||||
<MenuItemTooltip title={drillDisabled} />
|
||||
</div>
|
||||
),
|
||||
...props,
|
||||
}
|
||||
: {
|
||||
key: 'drill-to-detail',
|
||||
label: DRILL_TO_DETAIL,
|
||||
onClick: openModal.bind(null, []),
|
||||
...props,
|
||||
label: DRILL_TO_DETAIL,
|
||||
};
|
||||
|
||||
const getMenuItemWithTruncation = useMenuItemWithTruncation();
|
||||
|
||||
const drillToDetailByMenuItem: MenuItem = drillByDisabled
|
||||
? getDisabledMenuItem(
|
||||
<>
|
||||
{DRILL_TO_DETAIL_BY}
|
||||
<MenuItemTooltip title={drillByDisabled} />
|
||||
</>,
|
||||
'drill-to-detail-by-disabled',
|
||||
props,
|
||||
)
|
||||
: {
|
||||
key: key || 'drill-to-detail-by',
|
||||
label: DRILL_TO_DETAIL_BY,
|
||||
children: [
|
||||
...filters.map((filter, i) => ({
|
||||
key: `drill-detail-filter-${i}`,
|
||||
label: getMenuItemWithTruncation({
|
||||
tooltipText: `${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`,
|
||||
onClick: openModal.bind(null, [filter]),
|
||||
const drillToDetailByMenuItem: ItemType | null = !isContextMenu
|
||||
? null
|
||||
: drillByDisabled
|
||||
? {
|
||||
key: 'drill-to-detail-by-disabled',
|
||||
disabled: true,
|
||||
label: (
|
||||
<div
|
||||
css={css`
|
||||
white-space: normal;
|
||||
max-width: 160px;
|
||||
`}
|
||||
>
|
||||
{DRILL_TO_DETAIL_BY}
|
||||
<MenuItemTooltip title={drillByDisabled} />
|
||||
</div>
|
||||
),
|
||||
...props,
|
||||
}
|
||||
: {
|
||||
key: key || 'drill-to-detail-by',
|
||||
label: DRILL_TO_DETAIL_BY,
|
||||
popupOffset: [0, submenuYOffset],
|
||||
popupClassName: 'chart-context-submenu',
|
||||
children: [
|
||||
...filters.map((filter, i) => ({
|
||||
key: `drill-detail-filter-${i}`,
|
||||
children: (
|
||||
<>
|
||||
{`${DRILL_TO_DETAIL_BY} `}
|
||||
<StyledFilter stripHTML>{filter.formattedVal}</StyledFilter>
|
||||
</>
|
||||
onClick: openModal.bind(null, [filter]),
|
||||
label: (
|
||||
<div
|
||||
css={css`
|
||||
max-width: 200px;
|
||||
`}
|
||||
>
|
||||
<TruncatedMenuLabel
|
||||
tooltipText={`${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`}
|
||||
aria-label={`${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`}
|
||||
>
|
||||
<span
|
||||
css={css`
|
||||
display: inline;
|
||||
`}
|
||||
>
|
||||
{DRILL_TO_DETAIL_BY}{' '}
|
||||
<StyledFilter stripHTML>
|
||||
{filter.formattedVal}
|
||||
</StyledFilter>
|
||||
</span>
|
||||
</TruncatedMenuLabel>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
})),
|
||||
filters.length > 1 && {
|
||||
key: 'drill-detail-filter-all',
|
||||
label: getMenuItemWithTruncation({
|
||||
tooltipText: `${DRILL_TO_DETAIL_BY} ${t('all')}`,
|
||||
onClick: openModal.bind(null, filters),
|
||||
key: 'drill-detail-filter-all',
|
||||
children: (
|
||||
<>
|
||||
{`${DRILL_TO_DETAIL_BY} `}
|
||||
<StyledFilter stripHTML={false}>{t('all')}</StyledFilter>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
},
|
||||
].filter(Boolean) as MenuItem[],
|
||||
onClick: openModal.bind(null, filters),
|
||||
forceSubmenuRender: true,
|
||||
popupOffset: [0, submenuYOffset],
|
||||
popupClassName: 'chart-context-submenu',
|
||||
...props,
|
||||
};
|
||||
if (isContextMenu) {
|
||||
return {
|
||||
drillToDetailMenuItem,
|
||||
drillToDetailByMenuItem,
|
||||
};
|
||||
})),
|
||||
...(filters.length > 1
|
||||
? [
|
||||
{
|
||||
key: 'drill-detail-filter-all',
|
||||
onClick: openModal.bind(null, filters),
|
||||
label: (
|
||||
<div
|
||||
aria-label={`${DRILL_TO_DETAIL_BY} ${t('all')}`}
|
||||
css={css`
|
||||
max-width: 200px;
|
||||
`}
|
||||
>
|
||||
{`${DRILL_TO_DETAIL_BY} `}
|
||||
<StyledFilter stripHTML={false}>
|
||||
{t('all')}
|
||||
</StyledFilter>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
...props,
|
||||
};
|
||||
|
||||
const menuItems: ItemType[] = [drillToDetailMenuItem];
|
||||
if (drillToDetailByMenuItem) {
|
||||
menuItems.push(drillToDetailByMenuItem);
|
||||
}
|
||||
return {
|
||||
drillToDetailMenuItem,
|
||||
};
|
||||
|
||||
return menuItems;
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import setupPlugins from 'src/setup/setupPlugins';
|
||||
@@ -29,15 +30,13 @@ import { getMockStoreWithNativeFilters } from 'spec/fixtures/mockStore';
|
||||
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
|
||||
import { BinaryQueryObjectFilterClause, VizType } from '@superset-ui/core';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import DrillDetailMenuItems, {
|
||||
DrillDetailMenuItemsProps,
|
||||
} from './DrillDetailMenuItems';
|
||||
import DrillDetailModal from './DrillDetailModal';
|
||||
import DrillDetailModal from '../DrillDetail/DrillDetailModal';
|
||||
import { useDrillDetailMenuItems, DrillDetailMenuItemsProps } from './index';
|
||||
|
||||
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
|
||||
|
||||
jest.mock(
|
||||
'./DrillDetailPane',
|
||||
'../DrillDetail/DrillDetailPane',
|
||||
() =>
|
||||
({
|
||||
initialFilters,
|
||||
@@ -87,17 +86,17 @@ const MockRenderChart = ({
|
||||
BinaryQueryObjectFilterClause[] | undefined
|
||||
>(filters);
|
||||
|
||||
const menuItems = useDrillDetailMenuItems({
|
||||
setFilters,
|
||||
formData: formData ?? defaultFormData,
|
||||
filters: modalFilters,
|
||||
isContextMenu,
|
||||
setShowModal: setShowMenu,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu forceSubMenuRender>
|
||||
<DrillDetailMenuItems
|
||||
setFilters={setFilters}
|
||||
formData={formData ?? defaultFormData}
|
||||
filters={modalFilters}
|
||||
isContextMenu={isContextMenu}
|
||||
setShowModal={setShowMenu}
|
||||
/>
|
||||
</Menu>
|
||||
<Menu forceSubMenuRender items={menuItems} />
|
||||
|
||||
<DrillDetailModal
|
||||
chartId={chartId ?? defaultChartId}
|
||||
@@ -130,8 +129,10 @@ const renderMenu = ({
|
||||
);
|
||||
};
|
||||
|
||||
const setupMenu = (filters: BinaryQueryObjectFilterClause[]) => {
|
||||
const setupMenu = async (filters: BinaryQueryObjectFilterClause[]) => {
|
||||
cleanup();
|
||||
// Small delay to ensure DOM cleanup is complete
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
renderMenu({
|
||||
chartId: defaultChartId,
|
||||
formData: defaultFormData,
|
||||
@@ -235,11 +236,11 @@ const expectDrillToDetailByEnabled = async () => {
|
||||
.getAllByRole('menuitem')
|
||||
.find(menuItem => within(menuItem).queryByText('Drill to detail by'));
|
||||
await expectMenuItemEnabled(drillToDetailBy!);
|
||||
|
||||
userEvent.hover(drillToDetailBy!);
|
||||
|
||||
const submenus = await screen.findAllByTestId('drill-to-detail-by-submenu');
|
||||
|
||||
expect(submenus.length).toEqual(2);
|
||||
const submenu = await screen.findByRole('menu', {});
|
||||
expect(submenu).toBeInTheDocument();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -258,17 +259,37 @@ const expectDrillToDetailByDisabled = async (tooltipContent?: string) => {
|
||||
const expectDrillToDetailByDimension = async (
|
||||
filter: BinaryQueryObjectFilterClause,
|
||||
) => {
|
||||
userEvent.hover(screen.getByRole('menuitem', { name: 'Drill to detail by' }));
|
||||
const drillToDetailBySubMenus = await screen.findAllByTestId(
|
||||
'drill-to-detail-by-submenu',
|
||||
);
|
||||
const formattedVal = filter.formattedVal as string;
|
||||
const drillByMenuItem = screen.getByRole('menuitem', {
|
||||
name: 'Drill to detail by',
|
||||
});
|
||||
|
||||
userEvent.hover(drillByMenuItem);
|
||||
|
||||
const submenuPopup = (await waitFor(() =>
|
||||
screen
|
||||
.getAllByRole('menu', { hidden: true })
|
||||
.find(menu =>
|
||||
(menu.textContent ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.includes(formattedVal),
|
||||
),
|
||||
)) as HTMLElement;
|
||||
|
||||
const drillToDetailBySubmenuItem = (await waitFor(() => {
|
||||
const items = within(submenuPopup).getAllByRole('menuitem');
|
||||
return items.find(item =>
|
||||
(item.textContent ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.includes(`Drill to detail by ${filter.formattedVal}`),
|
||||
);
|
||||
})) as HTMLElement;
|
||||
|
||||
const menuItemName = `Drill to detail by ${filter.formattedVal}`;
|
||||
const drillToDetailBySubmenuItems = await within(
|
||||
drillToDetailBySubMenus[1],
|
||||
).findAllByRole('menuitem');
|
||||
|
||||
await expectMenuItemEnabled(drillToDetailBySubmenuItems[0]);
|
||||
await expectMenuItemEnabled(drillToDetailBySubmenuItem);
|
||||
await expectDrillToDetailModal(menuItemName, [filter]);
|
||||
};
|
||||
|
||||
@@ -278,14 +299,15 @@ const expectDrillToDetailByDimension = async (
|
||||
const expectDrillToDetailByAll = async (
|
||||
filters: BinaryQueryObjectFilterClause[],
|
||||
) => {
|
||||
userEvent.hover(screen.getByRole('menuitem', { name: 'Drill to detail by' }));
|
||||
const drillToDetailBySubMenus = await screen.findAllByTestId(
|
||||
'drill-to-detail-by-submenu',
|
||||
);
|
||||
const drillByMenuItem = screen.getByRole('menuitem', {
|
||||
name: 'Drill to detail by',
|
||||
});
|
||||
|
||||
const drillToDetailBySubmenuItem = await within(
|
||||
drillToDetailBySubMenus[1],
|
||||
).findByText(/all/i);
|
||||
userEvent.hover(drillByMenuItem);
|
||||
|
||||
await screen.findByRole('menu');
|
||||
|
||||
const drillToDetailBySubmenuItem = await screen.findByText(/all/i, {});
|
||||
|
||||
await expectMenuItemEnabled(drillToDetailBySubmenuItem);
|
||||
|
||||
@@ -386,20 +408,20 @@ test('context menu for supported chart, dimensions, 1 filter', async () => {
|
||||
|
||||
test('context menu for supported chart, dimensions, filter A', async () => {
|
||||
const filters = [filterA, filterB];
|
||||
setupMenu(filters);
|
||||
await setupMenu(filters);
|
||||
await expectDrillToDetailByEnabled();
|
||||
await expectDrillToDetailByDimension(filterA);
|
||||
});
|
||||
|
||||
test('context menu for supported chart, dimensions, filter B', async () => {
|
||||
const filters = [filterA, filterB];
|
||||
setupMenu(filters);
|
||||
await setupMenu(filters);
|
||||
await expectDrillToDetailByEnabled();
|
||||
await expectDrillToDetailByDimension(filterB);
|
||||
});
|
||||
|
||||
test.skip('context menu for supported chart, dimensions, all filters', async () => {
|
||||
const filters = [filterA, filterB];
|
||||
setupMenu(filters);
|
||||
await setupMenu(filters);
|
||||
await expectDrillToDetailByAll(filters);
|
||||
});
|
||||
@@ -86,16 +86,6 @@ const StyledFilterCount = styled.div`
|
||||
const StyledBadge = styled(Badge)`
|
||||
${({ theme }) => `
|
||||
margin-left: ${theme.sizeUnit * 2}px;
|
||||
&>sup.ant-badge-count {
|
||||
padding: 0 ${theme.sizeUnit}px;
|
||||
min-width: ${theme.sizeUnit * 4}px;
|
||||
height: ${theme.sizeUnit * 4}px;
|
||||
line-height: 1.5;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
font-size: ${theme.fontSizeSM - 1}px;
|
||||
box-shadow: none;
|
||||
padding: 0 ${theme.sizeUnit}px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -314,6 +304,7 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => {
|
||||
data-test="applied-filter-count"
|
||||
className="applied-count"
|
||||
count={filterCount}
|
||||
size="small"
|
||||
showZero
|
||||
/>
|
||||
</StyledFilterCount>
|
||||
|
||||
@@ -85,7 +85,7 @@ const ChartHeaderStyles = styled.div`
|
||||
& > .header-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
max-width: calc(100% - ${theme.sizeUnit * 4}px);
|
||||
flex-grow: 1;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
|
||||
@@ -54,7 +54,7 @@ import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
|
||||
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
|
||||
import { useDrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
|
||||
import { useDrillDetailMenuItems } from 'src/components/Chart/useDrillDetailMenuItems';
|
||||
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
|
||||
import { MenuKeys, RootState } from 'src/dashboard/types';
|
||||
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
|
||||
@@ -455,14 +455,13 @@ const SliceHeaderControls = (
|
||||
});
|
||||
}
|
||||
|
||||
const { drillToDetailMenuItem, drillToDetailByMenuItem } =
|
||||
useDrillDetailMenuItems({
|
||||
formData: props.formData,
|
||||
filters: modalFilters,
|
||||
setFilters,
|
||||
setShowModal: setDrillModalIsOpen,
|
||||
key: MenuKeys.DrillToDetail,
|
||||
});
|
||||
const drillDetailMenuItems = useDrillDetailMenuItems({
|
||||
formData: props.formData,
|
||||
filters: modalFilters,
|
||||
setFilters,
|
||||
setShowModal: setDrillModalIsOpen,
|
||||
key: MenuKeys.DrillToDetail,
|
||||
});
|
||||
|
||||
const shareMenuItems = useShareMenuItems({
|
||||
dashboardId,
|
||||
@@ -477,10 +476,7 @@ const SliceHeaderControls = (
|
||||
});
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail) {
|
||||
newMenuItems.push(drillToDetailMenuItem);
|
||||
if (drillToDetailByMenuItem) {
|
||||
newMenuItems.push(drillToDetailByMenuItem);
|
||||
}
|
||||
newMenuItems.push(...drillDetailMenuItems);
|
||||
}
|
||||
|
||||
if (slice.description || canExplore) {
|
||||
|
||||
@@ -58,7 +58,7 @@ const HoverStyleOverrides = styled.div`
|
||||
top: ${({ theme }) => theme.sizeUnit * -6}px;
|
||||
left: 50%;
|
||||
transform: translate(-50%);
|
||||
padding: 0 ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
|
||||
@@ -33,7 +33,7 @@ const useFilterFocusHighlightStyles = (chartId: number) => {
|
||||
() => ({
|
||||
borderColor: theme.colorPrimaryBorder,
|
||||
opacity: 1,
|
||||
boxShadow: `0px 0px ${theme.sizeUnit * 2}px ${theme.colorPrimary}`,
|
||||
boxShadow: `0px 0px ${theme.sizeUnit * 3}px ${theme.colorPrimary}`,
|
||||
pointerEvents: 'auto',
|
||||
}),
|
||||
[theme],
|
||||
|
||||
@@ -25,11 +25,7 @@ import {
|
||||
setItem,
|
||||
LocalStorageKeys,
|
||||
} from 'src/utils/localStorageHelpers';
|
||||
import {
|
||||
SamplesPane,
|
||||
TableControlsWrapper,
|
||||
useResultsPane,
|
||||
} from './components';
|
||||
import { SamplesPane, useResultsPane } from './components';
|
||||
import { DataTablesPaneProps, ResultTypes } from './types';
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
@@ -162,7 +158,7 @@ export const DataTablesPane = ({
|
||||
<Icons.DownOutlined aria-label={t('Expand data panel')} />
|
||||
);
|
||||
return (
|
||||
<TableControlsWrapper>
|
||||
<div>
|
||||
{panelOpen ? (
|
||||
<span
|
||||
role="button"
|
||||
@@ -180,7 +176,7 @@ export const DataTablesPane = ({
|
||||
{caretIcon}
|
||||
</span>
|
||||
)}
|
||||
</TableControlsWrapper>
|
||||
</div>
|
||||
);
|
||||
}, [handleCollapseChange, panelOpen]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user