You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ju...@apache.org on 2024/03/19 21:32:52 UTC

(superset) branch justin--4.0 updated: DO NOT MERGE: test show allowlist only (#27578)

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

justinpark pushed a commit to branch justin--4.0
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/justin--4.0 by this push:
     new 8536cf0c60 DO NOT MERGE: test show allowlist only (#27578)
8536cf0c60 is described below

commit 8536cf0c60cf2dd1af2dd216402c24a5f4a4b7c6
Author: JUST.in DO IT <ju...@airbnb.com>
AuthorDate: Tue Mar 19 14:32:46 2024 -0700

    DO NOT MERGE: test show allowlist only (#27578)
---
 .../explore/components/DatasourcePanel/index.tsx   | 217 ++++++++++++---------
 .../ExploreContainer/ExploreContainer.test.tsx     |  85 ++++++++
 .../explore/components/ExploreContainer/index.tsx  |  88 +++++++++
 .../components/ExploreViewContainer/index.jsx      |   8 +-
 .../DndColumnSelectControl/DndSelectLabel.tsx      |  15 +-
 .../components/controls/OptionControls/index.tsx   |  40 +++-
 superset-frontend/src/pages/Chart/Chart.test.tsx   |   3 +
 7 files changed, 352 insertions(+), 104 deletions(-)

diff --git a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx
index 99f6b48b89..b084801181 100644
--- a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx
+++ b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx
@@ -16,19 +16,20 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import React, { useEffect, useMemo, useRef, useState } from 'react';
+import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
 import {
   css,
   DatasourceType,
   Metric,
   QueryFormData,
+  SLOW_DEBOUNCE,
   styled,
   t,
 } from '@superset-ui/core';
 
 import { ControlConfig } from '@superset-ui/chart-controls';
-
-import { debounce, isArray } from 'lodash';
+import { isArray } from 'lodash';
+import { Checkbox } from 'antd';
 import { matchSorter, rankings } from 'match-sorter';
 import Collapse from 'src/components/Collapse';
 import Alert from 'src/components/Alert';
@@ -38,9 +39,11 @@ import { Input } from 'src/components/Input';
 import { FAST_DEBOUNCE } from 'src/constants';
 import { ExploreActions } from 'src/explore/actions/exploreActions';
 import Control from 'src/explore/components/Control';
+import { useDebounceValue } from 'src/hooks/useDebounceValue';
 import DatasourcePanelDragOption from './DatasourcePanelDragOption';
 import { DndItemType } from '../DndItemType';
 import { DndItemValue } from './types';
+import { DropzoneContext } from '../ExploreContainer';
 
 interface DatasourceControl extends ControlConfig {
   datasource?: IDatasource;
@@ -202,6 +205,9 @@ const LabelContainer = (props: {
   );
 };
 
+const sortCertifiedFirst = (slice: DataSourcePanelColumn[]) =>
+  slice.sort((a, b) => (b?.is_certified ?? 0) - (a?.is_certified ?? 0));
+
 export default function DataSourcePanel({
   datasource,
   formData,
@@ -209,11 +215,30 @@ export default function DataSourcePanel({
   actions,
   shouldForceUpdate,
 }: Props) {
+  const [dropzones] = useContext(DropzoneContext);
+  const [allowlistOnly, setAllowlistOnly] = useState(false);
+
   const { columns: _columns, metrics } = datasource;
+
+  const allowedColumns = useMemo(() => {
+    const validators = Object.values(dropzones);
+    if (!isArray(_columns)) return [];
+    return allowlistOnly
+      ? _columns.filter(column =>
+          validators.some(validator =>
+            validator({
+              value: column as DndItemValue,
+              type: DndItemType.Column,
+            }),
+          ),
+        )
+      : _columns;
+  }, [dropzones, _columns, allowlistOnly]);
+
   // display temporal column first
   const columns = useMemo(
     () =>
-      [...(isArray(_columns) ? _columns : [])].sort((col1, col2) => {
+      [...allowedColumns].sort((col1, col2) => {
         if (col1?.is_dttm && !col2?.is_dttm) {
           return -1;
         }
@@ -222,107 +247,105 @@ export default function DataSourcePanel({
         }
         return 0;
       }),
-    [_columns],
+    [allowedColumns],
   );
 
+  const allowedMetrics = useMemo(() => {
+    const validators = Object.values(dropzones);
+    return allowlistOnly
+      ? metrics.filter(metric =>
+          validators.some(validator =>
+            validator({ value: metric, type: DndItemType.Metric }),
+          ),
+        )
+      : metrics;
+  }, [dropzones, metrics, allowlistOnly]);
+
+  console.log('allowedMetrics', allowedMetrics);
+
   const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
   const [inputValue, setInputValue] = useState('');
-  const [lists, setList] = useState({
-    columns,
-    metrics,
-  });
   const [showAllMetrics, setShowAllMetrics] = useState(false);
   const [showAllColumns, setShowAllColumns] = useState(false);
+  const searchKeyword = useDebounceValue(inputValue, FAST_DEBOUNCE);
 
   const DEFAULT_MAX_COLUMNS_LENGTH = 50;
   const DEFAULT_MAX_METRICS_LENGTH = 50;
 
-  const search = useMemo(
-    () =>
-      debounce((value: string) => {
-        if (value === '') {
-          setList({ columns, metrics });
-          return;
-        }
-        setList({
-          columns: matchSorter(columns, value, {
-            keys: [
-              {
-                key: 'verbose_name',
-                threshold: rankings.CONTAINS,
-              },
-              {
-                key: 'column_name',
-                threshold: rankings.CONTAINS,
-              },
-              {
-                key: item =>
-                  [item?.description ?? '', item?.expression ?? ''].map(
-                    x => x?.replace(/[_\n\s]+/g, ' ') || '',
-                  ),
-                threshold: rankings.CONTAINS,
-                maxRanking: rankings.CONTAINS,
-              },
-            ],
-            keepDiacritics: true,
-          }),
-          metrics: matchSorter(metrics, value, {
-            keys: [
-              {
-                key: 'verbose_name',
-                threshold: rankings.CONTAINS,
-              },
-              {
-                key: 'metric_name',
-                threshold: rankings.CONTAINS,
-              },
-              {
-                key: item =>
-                  [item?.description ?? '', item?.expression ?? ''].map(
-                    x => x?.replace(/[_\n\s]+/g, ' ') || '',
-                  ),
-                threshold: rankings.CONTAINS,
-                maxRanking: rankings.CONTAINS,
-              },
-            ],
-            keepDiacritics: true,
-            baseSort: (a, b) =>
-              Number(b?.item?.is_certified ?? 0) -
-                Number(a?.item?.is_certified ?? 0) ||
-              String(a?.rankedValue ?? '').localeCompare(b?.rankedValue ?? ''),
-          }),
-        });
-      }, FAST_DEBOUNCE),
-    [columns, metrics],
-  );
-
-  useEffect(() => {
-    setList({
-      columns,
-      metrics,
+  const filteredColumns = useMemo(() => {
+    if (!searchKeyword) {
+      return columns ?? [];
+    }
+    return matchSorter(columns, searchKeyword, {
+      keys: [
+        {
+          key: 'verbose_name',
+          threshold: rankings.CONTAINS,
+        },
+        {
+          key: 'column_name',
+          threshold: rankings.CONTAINS,
+        },
+        {
+          key: item =>
+            [item?.description ?? '', item?.expression ?? ''].map(
+              x => x?.replace(/[_\n\s]+/g, ' ') || '',
+            ),
+          threshold: rankings.CONTAINS,
+          maxRanking: rankings.CONTAINS,
+        },
+      ],
+      keepDiacritics: true,
     });
-    setInputValue('');
-  }, [columns, datasource, metrics]);
+  }, [columns, searchKeyword]);
 
-  const sortCertifiedFirst = (slice: DataSourcePanelColumn[]) =>
-    slice.sort((a, b) => (b?.is_certified ?? 0) - (a?.is_certified ?? 0));
+  const filteredMetrics = useMemo(() => {
+    if (!searchKeyword) {
+      return allowedMetrics ?? [];
+    }
+    return matchSorter(allowedMetrics, searchKeyword, {
+      keys: [
+        {
+          key: 'verbose_name',
+          threshold: rankings.CONTAINS,
+        },
+        {
+          key: 'metric_name',
+          threshold: rankings.CONTAINS,
+        },
+        {
+          key: item =>
+            [item?.description ?? '', item?.expression ?? ''].map(
+              x => x?.replace(/[_\n\s]+/g, ' ') || '',
+            ),
+          threshold: rankings.CONTAINS,
+          maxRanking: rankings.CONTAINS,
+        },
+      ],
+      keepDiacritics: true,
+      baseSort: (a, b) =>
+        Number(b?.item?.is_certified ?? 0) -
+          Number(a?.item?.is_certified ?? 0) ||
+        String(a?.rankedValue ?? '').localeCompare(b?.rankedValue ?? ''),
+    });
+  }, [allowedMetrics, searchKeyword]);
 
   const metricSlice = useMemo(
     () =>
       showAllMetrics
-        ? lists?.metrics
-        : lists?.metrics?.slice?.(0, DEFAULT_MAX_METRICS_LENGTH),
-    [lists?.metrics, showAllMetrics],
+        ? filteredMetrics
+        : filteredMetrics?.slice?.(0, DEFAULT_MAX_METRICS_LENGTH),
+    [filteredMetrics, showAllMetrics],
   );
 
   const columnSlice = useMemo(
     () =>
       showAllColumns
-        ? sortCertifiedFirst(lists?.columns)
+        ? sortCertifiedFirst(filteredColumns)
         : sortCertifiedFirst(
-            lists?.columns?.slice?.(0, DEFAULT_MAX_COLUMNS_LENGTH),
+            filteredColumns?.slice?.(0, DEFAULT_MAX_COLUMNS_LENGTH),
           ),
-    [lists.columns, showAllColumns],
+    [filteredColumns, showAllColumns],
   );
 
   const showInfoboxCheck = () => {
@@ -349,12 +372,26 @@ export default function DataSourcePanel({
           allowClear
           onChange={evt => {
             setInputValue(evt.target.value);
-            search(evt.target.value);
           }}
           value={inputValue}
           className="form-control input-md"
           placeholder={t('Search Metrics & Columns')}
         />
+        <div
+          css={css`
+            display: flex;
+            margin: 0 16px;
+            column-gap: 4px;
+          `}
+        >
+          <Checkbox
+            id="allowlistOnly"
+            onChange={({ target }) => setAllowlistOnly(Boolean(target.checked))}
+            checked={allowlistOnly}
+          >
+            {t('Show only allowlist')}
+          </Checkbox>
+        </div>
         <div className="field-selections">
           {datasourceIsSaveable && showInfoboxCheck() && (
             <StyledInfoboxWrapper>
@@ -399,7 +436,7 @@ export default function DataSourcePanel({
                   {t(
                     `Showing %s of %s`,
                     metricSlice?.length,
-                    lists?.metrics.length,
+                    filteredMetrics.length,
                   )}
                 </div>
                 {metricSlice?.map?.((m: Metric) => (
@@ -413,7 +450,7 @@ export default function DataSourcePanel({
                     />
                   </LabelContainer>
                 ))}
-                {lists?.metrics?.length > DEFAULT_MAX_METRICS_LENGTH ? (
+                {filteredMetrics.length > DEFAULT_MAX_METRICS_LENGTH ? (
                   <ButtonContainer>
                     <Button onClick={() => setShowAllMetrics(!showAllMetrics)}>
                       {showAllMetrics ? t('Show less...') : t('Show all...')}
@@ -432,7 +469,7 @@ export default function DataSourcePanel({
                 {t(
                   `Showing %s of %s`,
                   columnSlice.length,
-                  lists.columns.length,
+                  filteredColumns.length,
                 )}
               </div>
               {columnSlice.map(col => (
@@ -446,7 +483,7 @@ export default function DataSourcePanel({
                   />
                 </LabelContainer>
               ))}
-              {lists.columns.length > DEFAULT_MAX_COLUMNS_LENGTH ? (
+              {filteredColumns.length > DEFAULT_MAX_COLUMNS_LENGTH ? (
                 <ButtonContainer>
                   <Button onClick={() => setShowAllColumns(!showAllColumns)}>
                     {showAllColumns ? t('Show Less...') : t('Show all...')}
@@ -464,14 +501,14 @@ export default function DataSourcePanel({
     [
       columnSlice,
       inputValue,
-      lists.columns.length,
-      lists?.metrics?.length,
+      filteredColumns.length,
+      filteredMetrics.length,
       metricSlice,
-      search,
       showAllColumns,
       showAllMetrics,
       datasourceIsSaveable,
       shouldForceUpdate,
+      allowlistOnly,
     ],
   );
 
diff --git a/superset-frontend/src/explore/components/ExploreContainer/ExploreContainer.test.tsx b/superset-frontend/src/explore/components/ExploreContainer/ExploreContainer.test.tsx
new file mode 100644
index 0000000000..50922256ea
--- /dev/null
+++ b/superset-frontend/src/explore/components/ExploreContainer/ExploreContainer.test.tsx
@@ -0,0 +1,85 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React from 'react';
+import { fireEvent, render } from 'spec/helpers/testing-library';
+import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
+
+import ExploreContainer, { DraggingContext } from '.';
+import OptionWrapper from '../controls/DndColumnSelectControl/OptionWrapper';
+
+const MockChildren = () => {
+  const dragging = React.useContext(DraggingContext);
+  return (
+    <div data-test="mock-children" className={dragging ? 'dragging' : ''}>
+      {dragging ? 'dragging' : 'not dragging'}
+    </div>
+  );
+};
+
+test('should render children', () => {
+  const { getByTestId, getByText } = render(
+    <ExploreContainer>
+      <MockChildren />
+    </ExploreContainer>,
+    { useRedux: true, useDnd: true },
+  );
+  expect(getByTestId('mock-children')).toBeInTheDocument();
+  expect(getByText('not dragging')).toBeInTheDocument();
+});
+
+test('should update the style on dragging state', () => {
+  const defaultProps = {
+    label: <span>Test label</span>,
+    tooltipTitle: 'This is a tooltip title',
+    onRemove: jest.fn(),
+    onMoveLabel: jest.fn(),
+    onDropLabel: jest.fn(),
+    type: 'test',
+    index: 0,
+  };
+  const { container, getByText } = render(
+    <ExploreContainer>
+      <OptionControlLabel
+        {...defaultProps}
+        index={1}
+        label={<span>Label 1</span>}
+      />
+      <OptionWrapper
+        {...defaultProps}
+        index={2}
+        label="Label 2"
+        clickClose={() => {}}
+        onShiftOptions={() => {}}
+      />
+      <MockChildren />
+    </ExploreContainer>,
+    {
+      useRedux: true,
+      useDnd: true,
+    },
+  );
+  expect(container.getElementsByClassName('dragging')).toHaveLength(0);
+  fireEvent.dragStart(getByText('Label 1'));
+  expect(container.getElementsByClassName('dragging')).toHaveLength(1);
+  fireEvent.dragEnd(getByText('Label 1'));
+  expect(container.getElementsByClassName('dragging')).toHaveLength(0);
+  // don't show dragging state for the sorting item
+  fireEvent.dragStart(getByText('Label 2'));
+  expect(container.getElementsByClassName('dragging')).toHaveLength(0);
+});
diff --git a/superset-frontend/src/explore/components/ExploreContainer/index.tsx b/superset-frontend/src/explore/components/ExploreContainer/index.tsx
new file mode 100644
index 0000000000..6f4bb7a370
--- /dev/null
+++ b/superset-frontend/src/explore/components/ExploreContainer/index.tsx
@@ -0,0 +1,88 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React, { useEffect, Dispatch, useReducer } from 'react';
+import { styled } from '@superset-ui/core';
+import { useDragDropManager } from 'react-dnd';
+import { DatasourcePanelDndItem } from '../DatasourcePanel/types';
+
+type CanDropValidator = (item: DatasourcePanelDndItem) => boolean;
+type DropzoneSet = Record<string, CanDropValidator>;
+type Action = { key: string; canDrop?: CanDropValidator };
+
+export const DraggingContext = React.createContext(false);
+export const DropzoneContext = React.createContext<
+  [DropzoneSet, Dispatch<Action>]
+>([{}, () => {}]);
+const StyledDiv = styled.div`
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
+`;
+
+const reducer = (state: DropzoneSet = {}, action: Action) => {
+  if (action.canDrop) {
+    return {
+      ...state,
+      [action.key]: action.canDrop,
+    };
+  }
+  if (action.key) {
+    const newState = { ...state };
+    delete newState[action.key];
+    return newState;
+  }
+  return state;
+};
+
+const ExploreContainer: React.FC<{}> = ({ children }) => {
+  const dragDropManager = useDragDropManager();
+  const [dragging, setDragging] = React.useState(
+    dragDropManager.getMonitor().isDragging(),
+  );
+
+  useEffect(() => {
+    const monitor = dragDropManager.getMonitor();
+    const unsub = monitor.subscribeToStateChange(() => {
+      const item = monitor.getItem() || {};
+      // don't show dragging state for the sorting item
+      if ('dragIndex' in item) {
+        return;
+      }
+      const isDragging = monitor.isDragging();
+      setDragging(isDragging);
+    });
+
+    return () => {
+      unsub();
+    };
+  }, [dragDropManager]);
+
+  const dropzoneValue = useReducer(reducer, {});
+
+  return (
+    <DropzoneContext.Provider value={dropzoneValue}>
+      <DraggingContext.Provider value={dragging}>
+        <StyledDiv>{children}</StyledDiv>
+      </DraggingContext.Provider>
+    </DropzoneContext.Provider>
+  );
+};
+
+export default ExploreContainer;
diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx
index d3a32a2bd6..0da43ebdc0 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx
@@ -68,6 +68,7 @@ import ConnectedControlPanelsContainer from '../ControlPanelsContainer';
 import SaveModal from '../SaveModal';
 import DataSourcePanel from '../DatasourcePanel';
 import ConnectedExploreChartHeader from '../ExploreChartHeader';
+import ExploreContainer from '../ExploreContainer';
 
 const propTypes = {
   ...ExploreChartPanel.propTypes,
@@ -90,13 +91,6 @@ const propTypes = {
   isSaveModalVisible: PropTypes.bool,
 };
 
-const ExploreContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  height: 100%;
-  min-height: 0;
-`;
-
 const ExplorePanelContainer = styled.div`
   ${({ theme }) => css`
     background: ${theme.colors.grayscale.light5};
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx
index 397d6f54e0..3eb6daa69b 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import React, { ReactNode, useMemo } from 'react';
+import React, { ReactNode, useContext, useEffect, useMemo } from 'react';
 import { useDrop } from 'react-dnd';
 import { t, useTheme } from '@superset-ui/core';
 import ControlHeader from 'src/explore/components/ControlHeader';
@@ -31,6 +31,7 @@ import {
 } from 'src/explore/components/DatasourcePanel/types';
 import Icons from 'src/components/Icons';
 import { DndItemType } from '../../DndItemType';
+import { DraggingContext, DropzoneContext } from '../../ExploreContainer';
 
 export type DndSelectLabelProps = {
   name: string;
@@ -71,6 +72,17 @@ export default function DndSelectLabel({
     }),
   });
 
+  const dispatch = useContext(DropzoneContext)[1];
+  console.log('name', props.name);
+  useEffect(() => {
+    dispatch({ key: props.name, canDrop: props.canDrop });
+    return () => {
+      dispatch({ key: props.name });
+    };
+  }, [dispatch, props.name, props.canDrop]);
+
+  const isDragging = useContext(DraggingContext);
+
   const values = useMemo(() => valuesRenderer(), [valuesRenderer]);
 
   function renderGhostButton() {
@@ -94,6 +106,7 @@ export default function DndSelectLabel({
         data-test="dnd-labels-container"
         canDrop={canDrop}
         isOver={isOver}
+        isDragging={isDragging}
       >
         {values}
         {displayGhostButton && renderGhostButton()}
diff --git a/superset-frontend/src/explore/components/controls/OptionControls/index.tsx b/superset-frontend/src/explore/components/controls/OptionControls/index.tsx
index 90ae73c637..dd484f3296 100644
--- a/superset-frontend/src/explore/components/controls/OptionControls/index.tsx
+++ b/superset-frontend/src/explore/components/controls/OptionControls/index.tsx
@@ -106,18 +106,46 @@ export const LabelsContainer = styled.div`
 export const DndLabelsContainer = styled.div<{
   canDrop?: boolean;
   isOver?: boolean;
+  isDragging?: boolean;
 }>`
+  position: relative;
   padding: ${({ theme }) => theme.gridUnit}px;
-  border: ${({ canDrop, isOver, theme }) => {
-    if (canDrop) {
-      return `dashed 1px ${theme.colors.info.dark1}`;
-    }
-    if (isOver && !canDrop) {
-      return `dashed 1px ${theme.colors.error.dark1}`;
+  border: ${({ canDrop, isDragging, theme }) => {
+    if (isDragging) {
+      return `dashed 1px ${
+        canDrop ? theme.colors.info.dark1 : theme.colors.error.dark1
+      }`;
     }
     return `solid 1px ${theme.colors.grayscale.light2}`;
   }};
   border-radius: ${({ theme }) => theme.gridUnit}px;
+  &:before,
+  &:after {
+    content: ' ';
+    position: absolute;
+    border-radius: ${({ theme }) => theme.gridUnit}px;
+  }
+  &:before {
+    display: ${({ isDragging }) => (isDragging ? 'block' : 'none')};
+    background-color: ${({ theme, canDrop }) =>
+      canDrop ? theme.colors.primary.base : theme.colors.error.light1};
+    z-index: ${({ theme }) => theme.zIndex.aboveDashboardCharts};
+    opacity: ${({ theme }) => theme.opacity.light};
+    top: 1px;
+    right: 1px;
+    bottom: 1px;
+    left: 1px;
+  }
+  &:after {
+    display: ${({ isOver, canDrop }) => (canDrop && isOver ? 'block' : 'none')};
+    background-color: ${({ theme }) => theme.colors.primary.base};
+    z-index: ${({ theme }) => theme.zIndex.dropdown};
+    opacity: ${({ theme }) => theme.opacity.mediumLight};
+    top: ${({ theme }) => -theme.gridUnit}px;
+    right: ${({ theme }) => -theme.gridUnit}px;
+    bottom: ${({ theme }) => -theme.gridUnit}px;
+    left: ${({ theme }) => -theme.gridUnit}px;
+  }
 `;
 
 export const AddControlLabel = styled.div<{
diff --git a/superset-frontend/src/pages/Chart/Chart.test.tsx b/superset-frontend/src/pages/Chart/Chart.test.tsx
index 674a33d44d..f8fb9fdcbb 100644
--- a/superset-frontend/src/pages/Chart/Chart.test.tsx
+++ b/superset-frontend/src/pages/Chart/Chart.test.tsx
@@ -65,6 +65,7 @@ describe('ChartPage', () => {
     const { getByTestId } = render(<ChartPage />, {
       useRouter: true,
       useRedux: true,
+      useDnd: true,
     });
     await waitFor(() =>
       expect(fetchMock.calls(exploreApiRoute).length).toBe(1),
@@ -110,6 +111,7 @@ describe('ChartPage', () => {
       const { getByTestId } = render(<ChartPage />, {
         useRouter: true,
         useRedux: true,
+        useDnd: true,
       });
       await waitFor(() =>
         expect(fetchMock.calls(exploreApiRoute).length).toBe(1),
@@ -156,6 +158,7 @@ describe('ChartPage', () => {
         {
           useRouter: true,
           useRedux: true,
+          useDnd: true,
         },
       );
       await waitFor(() =>