feat: Improves the Drill By feature (#29242)

Co-authored-by: JUST.in DO IT <justin.park@airbnb.com>
This commit is contained in:
Michael S. Molina 2024-06-17 09:25:20 -03:00 committed by GitHub
parent ddc9f06786
commit 08e44c0850
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 160 additions and 69 deletions

View File

@ -23,6 +23,8 @@ import {
ComponentType, ComponentType,
} from 'react'; } from 'react';
import type { Editor } from 'brace'; import type { Editor } from 'brace';
import { BaseFormData } from '../query';
import { JsonResponse } from '../connection';
/** /**
* A function which returns text (or marked-up text) * A function which returns text (or marked-up text)
@ -30,6 +32,14 @@ import type { Editor } from 'brace';
*/ */
type ReturningDisplayable<P = void> = (props: P) => string | ReactElement; type ReturningDisplayable<P = void> = (props: P) => string | ReactElement;
/**
* A function which returns the drill by options for a given dataset and chart's formData.
*/
export type LoadDrillByOptions = (
datasetId: number,
formData: BaseFormData,
) => Promise<JsonResponse>;
/** /**
* This type defines all available extensions of Superset's default UI. * This type defines all available extensions of Superset's default UI.
* Namespace the keys here to follow the form of 'some_domain.functionality.item'. * Namespace the keys here to follow the form of 'some_domain.functionality.item'.
@ -193,6 +203,7 @@ export interface CustomAutocomplete extends AutocompleteItem {
export type Extensions = Partial<{ export type Extensions = Partial<{
'alertsreports.header.icon': ComponentType; 'alertsreports.header.icon': ComponentType;
'load.drillby.options': LoadDrillByOptions;
'embedded.documentation.configuration_details': ComponentType<ConfigDetailsProps>; 'embedded.documentation.configuration_details': ComponentType<ConfigDetailsProps>;
'embedded.documentation.description': ReturningDisplayable; 'embedded.documentation.description': ReturningDisplayable;
'embedded.documentation.url': string; 'embedded.documentation.url': string;

View File

@ -18,6 +18,7 @@
*/ */
import { import {
forwardRef, forwardRef,
Key,
ReactNode, ReactNode,
RefObject, RefObject,
useCallback, useCallback,
@ -104,6 +105,7 @@ const ChartContextMenu = (
const crossFiltersEnabled = useSelector<RootState, boolean>( const crossFiltersEnabled = useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled, ({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled,
); );
const [openKeys, setOpenKeys] = useState<Key[]>([]);
const isDisplayed = (item: ContextMenuItem) => const isDisplayed = (item: ContextMenuItem) =>
displayedItems === ContextMenuItem.All || displayedItems === ContextMenuItem.All ||
@ -254,6 +256,8 @@ const ChartContextMenu = (
formData={formData} formData={formData}
contextMenuY={clientY} contextMenuY={clientY}
submenuIndex={submenuIndex} submenuIndex={submenuIndex}
open={openKeys.includes('drill-by-submenu')}
key="drill-by-submenu"
{...(additionalConfig?.drillBy || {})} {...(additionalConfig?.drillBy || {})}
/>, />,
); );
@ -288,7 +292,13 @@ const ChartContextMenu = (
return ReactDOM.createPortal( return ReactDOM.createPortal(
<Dropdown <Dropdown
overlay={ overlay={
<Menu className="chart-context-menu" data-test="chart-context-menu"> <Menu
className="chart-context-menu"
data-test="chart-context-menu"
onOpenChange={openKeys => {
setOpenKeys(openKeys);
}}
>
{menuItems.length ? ( {menuItems.length ? (
menuItems menuItems
) : ( ) : (

View File

@ -31,11 +31,17 @@ import { DrillByMenuItems, DrillByMenuItemsProps } from './DrillByMenuItems';
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */ /* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
const DATASET_ENDPOINT = 'glob:*/api/v1/dataset/7'; const DATASET_ENDPOINT = 'glob:*/api/v1/dataset/7*';
const CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*'; const CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*';
const FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data'; const FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data';
const { form_data: defaultFormData } = chartQueries[sliceId]; const { form_data: defaultFormData } = chartQueries[sliceId];
jest.mock('lodash/debounce', () => (fn: Function & { debounce: Function }) => {
// eslint-disable-next-line no-param-reassign
fn.debounce = jest.fn();
return fn;
});
const defaultColumns = [ const defaultColumns = [
{ column_name: 'col1', groupby: true }, { column_name: 'col1', groupby: true },
{ column_name: 'col2', groupby: true }, { column_name: 'col2', groupby: true },
@ -68,6 +74,7 @@ const renderMenu = ({
<DrillByMenuItems <DrillByMenuItems
formData={formData ?? defaultFormData} formData={formData ?? defaultFormData}
drillByConfig={drillByConfig} drillByConfig={drillByConfig}
open
{...rest} {...rest}
/> />
</Menu>, </Menu>,

View File

@ -18,11 +18,12 @@
*/ */
import { import {
ChangeEvent, CSSProperties,
ReactNode, ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from 'react'; } from 'react';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
@ -31,12 +32,20 @@ import {
Behavior, Behavior,
Column, Column,
ContextMenuFilters, ContextMenuFilters,
FAST_DEBOUNCE,
JsonResponse,
css, css,
ensureIsArray, ensureIsArray,
getChartMetadataRegistry, getChartMetadataRegistry,
getExtensionsRegistry,
logging,
t, t,
useTheme, useTheme,
} from '@superset-ui/core'; } from '@superset-ui/core';
import rison from 'rison';
import { debounce } from 'lodash';
import { FixedSizeList as List } from 'react-window';
import { AntdInput } from 'src/components';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { Input } from 'src/components/Input'; import { Input } from 'src/components/Input';
import { useToasts } from 'src/components/MessageToasts/withToasts'; import { useToasts } from 'src/components/MessageToasts/withToasts';
@ -52,7 +61,7 @@ import { getSubmenuYOffset } from '../utils';
import { MenuItemWithTruncation } from '../MenuItemWithTruncation'; import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
import { Dataset } from '../types'; import { Dataset } from '../types';
const MAX_SUBMENU_HEIGHT = 200; const SUBMENU_HEIGHT = 200;
const SHOW_COLUMNS_SEARCH_THRESHOLD = 10; const SHOW_COLUMNS_SEARCH_THRESHOLD = 10;
const SEARCH_INPUT_HEIGHT = 48; const SEARCH_INPUT_HEIGHT = 48;
@ -65,8 +74,28 @@ export interface DrillByMenuItemsProps {
onClick?: (event: MouseEvent) => void; onClick?: (event: MouseEvent) => void;
openNewModal?: boolean; openNewModal?: boolean;
excludedColumns?: Column[]; excludedColumns?: Column[];
open: boolean;
} }
const loadDrillByOptions = getExtensionsRegistry().get('load.drillby.options');
const queryString = rison.encode({
columns: [
'table_name',
'owners.first_name',
'owners.last_name',
'created_by.first_name',
'created_by.last_name',
'created_on_humanized',
'changed_by.first_name',
'changed_by.last_name',
'changed_on_humanized',
'columns.column_name',
'columns.verbose_name',
'columns.groupby',
],
});
export const DrillByMenuItems = ({ export const DrillByMenuItems = ({
drillByConfig, drillByConfig,
formData, formData,
@ -76,6 +105,7 @@ export const DrillByMenuItems = ({
onClick = () => {}, onClick = () => {},
excludedColumns, excludedColumns,
openNewModal = true, openNewModal = true,
open,
...rest ...rest
}: DrillByMenuItemsProps) => { }: DrillByMenuItemsProps) => {
const theme = useTheme(); const theme = useTheme();
@ -86,6 +116,9 @@ export const DrillByMenuItems = ({
const [columns, setColumns] = useState<Column[]>([]); const [columns, setColumns] = useState<Column[]>([]);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [currentColumn, setCurrentColumn] = useState(); const [currentColumn, setCurrentColumn] = useState();
const ref = useRef<AntdInput>(null);
const showSearch =
loadDrillByOptions || columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
const handleSelection = useCallback( const handleSelection = useCallback(
(event, column) => { (event, column) => {
onClick(event); onClick(event);
@ -102,10 +135,14 @@ export const DrillByMenuItems = ({
}, []); }, []);
useEffect(() => { useEffect(() => {
// Input is displayed only when columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD if (open) {
// Reset search input in case Input gets removed ref.current?.input.focus();
setSearchInput(''); } else {
}, [columns.length]); // Reset search input when menu is closed
ref.current?.setValue('');
setSearchInput('');
}
}, [open]);
const hasDrillBy = drillByConfig?.groupbyFieldName; const hasDrillBy = drillByConfig?.groupbyFieldName;
@ -119,51 +156,59 @@ export const DrillByMenuItems = ({
const verboseMap = useVerboseMap(dataset); const verboseMap = useVerboseMap(dataset);
useEffect(() => { useEffect(() => {
async function loadOptions() {
const datasetId = Number(formData.datasource.split('__')[0]);
try {
setIsLoadingColumns(true);
let response: JsonResponse;
if (loadDrillByOptions) {
response = await loadDrillByOptions(datasetId, formData);
} else {
response = await cachedSupersetGet({
endpoint: `/api/v1/dataset/${datasetId}?q=${queryString}`,
});
}
const { json } = response;
const { result } = json;
setDataset(result);
setColumns(
ensureIsArray(result.columns)
.filter(column => column.groupby)
.filter(
column =>
!ensureIsArray(
formData[drillByConfig?.groupbyFieldName ?? ''],
).includes(column.column_name) &&
column.column_name !== formData.x_axis &&
ensureIsArray(excludedColumns)?.every(
excludedCol => excludedCol.column_name !== column.column_name,
),
),
);
} catch (error) {
logging.error(error);
supersetGetCache.delete(`/api/v1/dataset/${datasetId}`);
addDangerToast(t('Failed to load dimensions for drill by'));
} finally {
setIsLoadingColumns(false);
}
}
if (handlesDimensionContextMenu && hasDrillBy) { if (handlesDimensionContextMenu && hasDrillBy) {
const datasetId = formData.datasource.split('__')[0]; loadOptions();
cachedSupersetGet({
endpoint: `/api/v1/dataset/${datasetId}`,
})
.then(({ json: { result } }) => {
setDataset(result);
setColumns(
ensureIsArray(result.columns)
.filter(column => column.groupby)
.filter(
column =>
!ensureIsArray(
formData[drillByConfig.groupbyFieldName ?? ''],
).includes(column.column_name) &&
column.column_name !== formData.x_axis &&
ensureIsArray(excludedColumns)?.every(
excludedCol =>
excludedCol.column_name !== column.column_name,
),
),
);
})
.catch(() => {
supersetGetCache.delete(`/api/v1/dataset/${datasetId}`);
addDangerToast(t('Failed to load dimensions for drill by'));
})
.finally(() => {
setIsLoadingColumns(false);
});
} }
}, [ }, [
addDangerToast, addDangerToast,
drillByConfig?.groupbyFieldName,
excludedColumns, excludedColumns,
formData, formData,
drillByConfig?.groupbyFieldName,
handlesDimensionContextMenu, handlesDimensionContextMenu,
hasDrillBy, hasDrillBy,
]); ]);
const handleInput = useCallback((e: ChangeEvent<HTMLInputElement>) => { const handleInput = debounce(
e.stopPropagation(); (value: string) => setSearchInput(value),
const input = e?.target?.value; FAST_DEBOUNCE,
setSearchInput(input); );
}, []);
const filteredColumns = useMemo( const filteredColumns = useMemo(
() => () =>
@ -181,12 +226,10 @@ export const DrillByMenuItems = ({
contextMenuY, contextMenuY,
filteredColumns.length || 1, filteredColumns.length || 1,
submenuIndex, submenuIndex,
MAX_SUBMENU_HEIGHT, SUBMENU_HEIGHT,
columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD showSearch ? SEARCH_INPUT_HEIGHT : 0,
? SEARCH_INPUT_HEIGHT
: 0,
), ),
[contextMenuY, filteredColumns.length, submenuIndex, columns.length], [contextMenuY, filteredColumns.length, submenuIndex, showSearch],
); );
let tooltip: ReactNode; let tooltip: ReactNode;
@ -208,27 +251,53 @@ export const DrillByMenuItems = ({
); );
} }
const Row = ({
index,
data,
style,
}: {
index: number;
data: { columns: Column[] };
style: CSSProperties;
}) => {
const { columns, ...rest } = data;
const column = columns[index];
return (
<MenuItemWithTruncation
key={`drill-by-item-${column.column_name}`}
tooltipText={column.verbose_name || column.column_name}
{...rest}
onClick={e => handleSelection(e, column)}
style={style}
>
{column.verbose_name || column.column_name}
</MenuItemWithTruncation>
);
};
return ( return (
<> <>
<Menu.SubMenu <Menu.SubMenu
title={t('Drill by')} title={t('Drill by')}
key="drill-by-submenu"
popupClassName="chart-context-submenu" popupClassName="chart-context-submenu"
popupOffset={[0, submenuYOffset]} popupOffset={[0, submenuYOffset]}
{...rest} {...rest}
> >
<div data-test="drill-by-submenu"> <div data-test="drill-by-submenu">
{columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD && ( {showSearch && (
<Input <Input
ref={ref}
prefix={ prefix={
<Icons.Search <Icons.Search
iconSize="l" iconSize="l"
iconColor={theme.colors.grayscale.light1} iconColor={theme.colors.grayscale.light1}
/> />
} }
onChange={handleInput} onChange={e => {
e.stopPropagation();
handleInput(e.target.value);
}}
placeholder={t('Search columns')} placeholder={t('Search columns')}
value={searchInput}
onClick={e => { onClick={e => {
// prevent closing menu when clicking on input // prevent closing menu when clicking on input
e.nativeEvent.stopImmediatePropagation(); e.nativeEvent.stopImmediatePropagation();
@ -251,23 +320,16 @@ export const DrillByMenuItems = ({
<Loading position="inline-centered" /> <Loading position="inline-centered" />
</div> </div>
) : filteredColumns.length ? ( ) : filteredColumns.length ? (
<div <List
css={css` width="100%"
max-height: ${MAX_SUBMENU_HEIGHT}px; height={SUBMENU_HEIGHT}
overflow: auto; itemSize={35}
`} itemCount={filteredColumns.length}
itemData={{ columns: filteredColumns, ...rest }}
overscanCount={20}
> >
{filteredColumns.map(column => ( {Row}
<MenuItemWithTruncation </List>
key={`drill-by-item-${column.column_name}`}
tooltipText={column.verbose_name || column.column_name}
{...rest}
onClick={e => handleSelection(e, column)}
>
{column.verbose_name || column.column_name}
</MenuItemWithTruncation>
))}
</div>
) : ( ) : (
<Menu.Item disabled key="no-drill-by-columns-found" {...rest}> <Menu.Item disabled key="no-drill-by-columns-found" {...rest}>
{t('No columns found')} {t('No columns found')}

View File

@ -17,7 +17,7 @@
* under the License. * under the License.
*/ */
import { ReactNode } from 'react'; import { ReactNode, CSSProperties } from 'react';
import { css, truncationCSS, useCSSTextTruncation } from '@superset-ui/core'; import { css, truncationCSS, useCSSTextTruncation } from '@superset-ui/core';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
@ -27,6 +27,7 @@ export type MenuItemWithTruncationProps = {
tooltipText: ReactNode; tooltipText: ReactNode;
children: ReactNode; children: ReactNode;
onClick?: MenuProps['onClick']; onClick?: MenuProps['onClick'];
style?: CSSProperties;
}; };
export const MenuItemWithTruncation = ({ export const MenuItemWithTruncation = ({