This is an automated email from the ASF dual-hosted git repository.

michaelsmolina pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 08e44c0850 feat: Improves the Drill By feature (#29242)
08e44c0850 is described below

commit 08e44c085014ca258fe0c22886067dc716a910c6
Author: Michael S. Molina <[email protected]>
AuthorDate: Mon Jun 17 09:25:20 2024 -0300

    feat: Improves the Drill By feature (#29242)
    
    Co-authored-by: JUST.in DO IT <[email protected]>
---
 .../superset-ui-core/src/ui-overrides/types.ts     |  11 ++
 .../Chart/ChartContextMenu/ChartContextMenu.tsx    |  12 +-
 .../Chart/DrillBy/DrillByMenuItems.test.tsx        |   9 +-
 .../components/Chart/DrillBy/DrillByMenuItems.tsx  | 194 ++++++++++++++-------
 .../components/Chart/MenuItemWithTruncation.tsx    |   3 +-
 5 files changed, 160 insertions(+), 69 deletions(-)

diff --git 
a/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts 
b/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts
index bd2fa9047c..775e2c129a 100644
--- a/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts
+++ b/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts
@@ -23,6 +23,8 @@ import {
   ComponentType,
 } from 'react';
 import type { Editor } from 'brace';
+import { BaseFormData } from '../query';
+import { JsonResponse } from '../connection';
 
 /**
  * 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;
 
+/**
+ * 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.
  * 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<{
   'alertsreports.header.icon': ComponentType;
+  'load.drillby.options': LoadDrillByOptions;
   'embedded.documentation.configuration_details': 
ComponentType<ConfigDetailsProps>;
   'embedded.documentation.description': ReturningDisplayable;
   'embedded.documentation.url': string;
diff --git 
a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx 
b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx
index b7da3a7382..bf1ea84ff1 100644
--- 
a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx
+++ 
b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx
@@ -18,6 +18,7 @@
  */
 import {
   forwardRef,
+  Key,
   ReactNode,
   RefObject,
   useCallback,
@@ -104,6 +105,7 @@ const ChartContextMenu = (
   const crossFiltersEnabled = useSelector<RootState, boolean>(
     ({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled,
   );
+  const [openKeys, setOpenKeys] = useState<Key[]>([]);
 
   const isDisplayed = (item: ContextMenuItem) =>
     displayedItems === ContextMenuItem.All ||
@@ -254,6 +256,8 @@ const ChartContextMenu = (
         formData={formData}
         contextMenuY={clientY}
         submenuIndex={submenuIndex}
+        open={openKeys.includes('drill-by-submenu')}
+        key="drill-by-submenu"
         {...(additionalConfig?.drillBy || {})}
       />,
     );
@@ -288,7 +292,13 @@ const ChartContextMenu = (
   return ReactDOM.createPortal(
     <Dropdown
       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
           ) : (
diff --git 
a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx 
b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx
index 0f2dff8bce..8b65d21409 100644
--- a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx
+++ b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.test.tsx
@@ -31,11 +31,17 @@ import { DrillByMenuItems, DrillByMenuItemsProps } from 
'./DrillByMenuItems';
 
 /* 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 FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data';
 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 = [
   { column_name: 'col1', groupby: true },
   { column_name: 'col2', groupby: true },
@@ -68,6 +74,7 @@ const renderMenu = ({
       <DrillByMenuItems
         formData={formData ?? defaultFormData}
         drillByConfig={drillByConfig}
+        open
         {...rest}
       />
     </Menu>,
diff --git 
a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx 
b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx
index c7b4ec8e45..6df180cb78 100644
--- a/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx
+++ b/superset-frontend/src/components/Chart/DrillBy/DrillByMenuItems.tsx
@@ -18,11 +18,12 @@
  */
 
 import {
-  ChangeEvent,
+  CSSProperties,
   ReactNode,
   useCallback,
   useEffect,
   useMemo,
+  useRef,
   useState,
 } from 'react';
 import { Menu } from 'src/components/Menu';
@@ -31,12 +32,20 @@ import {
   Behavior,
   Column,
   ContextMenuFilters,
+  FAST_DEBOUNCE,
+  JsonResponse,
   css,
   ensureIsArray,
   getChartMetadataRegistry,
+  getExtensionsRegistry,
+  logging,
   t,
   useTheme,
 } 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 { Input } from 'src/components/Input';
 import { useToasts } from 'src/components/MessageToasts/withToasts';
@@ -52,7 +61,7 @@ import { getSubmenuYOffset } from '../utils';
 import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
 import { Dataset } from '../types';
 
-const MAX_SUBMENU_HEIGHT = 200;
+const SUBMENU_HEIGHT = 200;
 const SHOW_COLUMNS_SEARCH_THRESHOLD = 10;
 const SEARCH_INPUT_HEIGHT = 48;
 
@@ -65,8 +74,28 @@ export interface DrillByMenuItemsProps {
   onClick?: (event: MouseEvent) => void;
   openNewModal?: boolean;
   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 = ({
   drillByConfig,
   formData,
@@ -76,6 +105,7 @@ export const DrillByMenuItems = ({
   onClick = () => {},
   excludedColumns,
   openNewModal = true,
+  open,
   ...rest
 }: DrillByMenuItemsProps) => {
   const theme = useTheme();
@@ -86,6 +116,9 @@ export const DrillByMenuItems = ({
   const [columns, setColumns] = useState<Column[]>([]);
   const [showModal, setShowModal] = useState(false);
   const [currentColumn, setCurrentColumn] = useState();
+  const ref = useRef<AntdInput>(null);
+  const showSearch =
+    loadDrillByOptions || columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
   const handleSelection = useCallback(
     (event, column) => {
       onClick(event);
@@ -102,10 +135,14 @@ export const DrillByMenuItems = ({
   }, []);
 
   useEffect(() => {
-    // Input is displayed only when columns.length > 
SHOW_COLUMNS_SEARCH_THRESHOLD
-    // Reset search input in case Input gets removed
-    setSearchInput('');
-  }, [columns.length]);
+    if (open) {
+      ref.current?.input.focus();
+    } else {
+      // Reset search input when menu is closed
+      ref.current?.setValue('');
+      setSearchInput('');
+    }
+  }, [open]);
 
   const hasDrillBy = drillByConfig?.groupbyFieldName;
 
@@ -119,51 +156,59 @@ export const DrillByMenuItems = ({
   const verboseMap = useVerboseMap(dataset);
 
   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) {
-      const datasetId = formData.datasource.split('__')[0];
-      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);
-        });
+      loadOptions();
     }
   }, [
     addDangerToast,
+    drillByConfig?.groupbyFieldName,
     excludedColumns,
     formData,
-    drillByConfig?.groupbyFieldName,
     handlesDimensionContextMenu,
     hasDrillBy,
   ]);
 
-  const handleInput = useCallback((e: ChangeEvent<HTMLInputElement>) => {
-    e.stopPropagation();
-    const input = e?.target?.value;
-    setSearchInput(input);
-  }, []);
+  const handleInput = debounce(
+    (value: string) => setSearchInput(value),
+    FAST_DEBOUNCE,
+  );
 
   const filteredColumns = useMemo(
     () =>
@@ -181,12 +226,10 @@ export const DrillByMenuItems = ({
         contextMenuY,
         filteredColumns.length || 1,
         submenuIndex,
-        MAX_SUBMENU_HEIGHT,
-        columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD
-          ? SEARCH_INPUT_HEIGHT
-          : 0,
+        SUBMENU_HEIGHT,
+        showSearch ? SEARCH_INPUT_HEIGHT : 0,
       ),
-    [contextMenuY, filteredColumns.length, submenuIndex, columns.length],
+    [contextMenuY, filteredColumns.length, submenuIndex, showSearch],
   );
 
   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 (
     <>
       <Menu.SubMenu
         title={t('Drill by')}
-        key="drill-by-submenu"
         popupClassName="chart-context-submenu"
         popupOffset={[0, submenuYOffset]}
         {...rest}
       >
         <div data-test="drill-by-submenu">
-          {columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD && (
+          {showSearch && (
             <Input
+              ref={ref}
               prefix={
                 <Icons.Search
                   iconSize="l"
                   iconColor={theme.colors.grayscale.light1}
                 />
               }
-              onChange={handleInput}
+              onChange={e => {
+                e.stopPropagation();
+                handleInput(e.target.value);
+              }}
               placeholder={t('Search columns')}
-              value={searchInput}
               onClick={e => {
                 // prevent closing menu when clicking on input
                 e.nativeEvent.stopImmediatePropagation();
@@ -251,23 +320,16 @@ export const DrillByMenuItems = ({
               <Loading position="inline-centered" />
             </div>
           ) : filteredColumns.length ? (
-            <div
-              css={css`
-                max-height: ${MAX_SUBMENU_HEIGHT}px;
-                overflow: auto;
-              `}
+            <List
+              width="100%"
+              height={SUBMENU_HEIGHT}
+              itemSize={35}
+              itemCount={filteredColumns.length}
+              itemData={{ columns: filteredColumns, ...rest }}
+              overscanCount={20}
             >
-              {filteredColumns.map(column => (
-                <MenuItemWithTruncation
-                  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>
+              {Row}
+            </List>
           ) : (
             <Menu.Item disabled key="no-drill-by-columns-found" {...rest}>
               {t('No columns found')}
diff --git a/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx 
b/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx
index fd371fb30a..1ab3daf485 100644
--- a/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx
+++ b/superset-frontend/src/components/Chart/MenuItemWithTruncation.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { ReactNode } from 'react';
+import { ReactNode, CSSProperties } from 'react';
 import { css, truncationCSS, useCSSTextTruncation } from '@superset-ui/core';
 import { Menu } from 'src/components/Menu';
 import { Tooltip } from 'src/components/Tooltip';
@@ -27,6 +27,7 @@ export type MenuItemWithTruncationProps = {
   tooltipText: ReactNode;
   children: ReactNode;
   onClick?: MenuProps['onClick'];
+  style?: CSSProperties;
 };
 
 export const MenuItemWithTruncation = ({

Reply via email to