You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2024/03/28 12:28:08 UTC

(superset) 01/03: fix(explore): drag and drop indicator UX (#27558)

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

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

commit a024b4ac1b6ae6d30337447f3557063c3adb5a51
Author: JUST.in DO IT <ju...@airbnb.com>
AuthorDate: Wed Mar 27 11:22:27 2024 -0700

    fix(explore): drag and drop indicator UX (#27558)
    
    (cherry picked from commit 736975419297898af59714363b5094ccee8ed0d1)
---
 .../ExploreContainer/ExploreContainer.test.tsx     | 85 ++++++++++++++++++++
 .../explore/components/ExploreContainer/index.tsx  | 60 ++++++++++++++
 .../components/ExploreViewContainer/index.jsx      |  8 +-
 .../DndColumnSelectControl/DndSelectLabel.tsx      | 10 ++-
 .../components/controls/OptionControls/index.tsx   | 92 +++++++++++++++++++---
 superset-frontend/src/pages/Chart/Chart.test.tsx   |  3 +
 6 files changed, 237 insertions(+), 21 deletions(-)

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..3c64373948
--- /dev/null
+++ b/superset-frontend/src/explore/components/ExploreContainer/index.tsx
@@ -0,0 +1,60 @@
+/**
+ * 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 } from 'react';
+import { styled } from '@superset-ui/core';
+import { useDragDropManager } from 'react-dnd';
+
+export const DraggingContext = React.createContext(false);
+const StyledDiv = styled.div`
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
+`;
+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]);
+
+  return (
+    <DraggingContext.Provider value={dragging}>
+      <StyledDiv>{children}</StyledDiv>
+    </DraggingContext.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..f4da2d1729 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, 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 } from '../../ExploreContainer';
 
 export type DndSelectLabelProps = {
   name: string;
@@ -43,18 +44,20 @@ export type DndSelectLabelProps = {
   valuesRenderer: () => ReactNode;
   displayGhostButton?: boolean;
   onClickGhostButton: () => void;
+  isLoading?: boolean;
 };
 
 export default function DndSelectLabel({
   displayGhostButton = true,
   accept,
   valuesRenderer,
+  isLoading,
   ...props
 }: DndSelectLabelProps) {
   const theme = useTheme();
 
   const [{ isOver, canDrop }, datasourcePanelDrop] = useDrop({
-    accept,
+    accept: isLoading ? [] : accept,
 
     drop: (item: DatasourcePanelDndItem) => {
       props.onDrop(item);
@@ -70,6 +73,7 @@ export default function DndSelectLabel({
       type: monitor.getItemType(),
     }),
   });
+  const isDragging = useContext(DraggingContext);
 
   const values = useMemo(() => valuesRenderer(), [valuesRenderer]);
 
@@ -94,6 +98,8 @@ export default function DndSelectLabel({
         data-test="dnd-labels-container"
         canDrop={canDrop}
         isOver={isOver}
+        isDragging={isDragging}
+        isLoading={isLoading}
       >
         {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..2e2bf1f5d7 100644
--- a/superset-frontend/src/explore/components/controls/OptionControls/index.tsx
+++ b/superset-frontend/src/explore/components/controls/OptionControls/index.tsx
@@ -18,7 +18,7 @@
  */
 import React, { useRef } from 'react';
 import { useDrag, useDrop, DropTargetMonitor } from 'react-dnd';
-import { styled, t, useTheme } from '@superset-ui/core';
+import { styled, t, useTheme, keyframes, css } from '@superset-ui/core';
 import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls';
 import { Tooltip } from 'src/components/Tooltip';
 import Icons from 'src/components/Icons';
@@ -103,21 +103,89 @@ export const LabelsContainer = styled.div`
   border-radius: ${({ theme }) => theme.gridUnit}px;
 `;
 
+const borderPulse = keyframes`
+  0% {
+    right: 100%;
+  }
+  50% {
+    left: 4px;
+  }
+  90% {
+    right: 4px;
+  }
+  100% {
+    left: 100%;
+  }
+`;
+
 export const DndLabelsContainer = styled.div<{
   canDrop?: boolean;
   isOver?: boolean;
+  isDragging?: boolean;
+  isLoading?: boolean;
 }>`
-  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}`;
-    }
-    return `solid 1px ${theme.colors.grayscale.light2}`;
-  }};
-  border-radius: ${({ theme }) => theme.gridUnit}px;
+  ${({ theme, isLoading, canDrop, isDragging, isOver }) => `
+  position: relative;
+  padding: ${theme.gridUnit}px;
+  border: ${
+    !isLoading && isDragging
+      ? `dashed 1px ${
+          canDrop ? theme.colors.info.dark1 : theme.colors.error.dark1
+        }`
+      : `solid 1px ${
+          isLoading && isDragging
+            ? theme.colors.warning.light1
+            : theme.colors.grayscale.light2
+        }`
+  };
+  border-radius: ${theme.gridUnit}px;
+  &:before,
+  &:after {
+    content: ' ';
+    position: absolute;
+    border-radius: ${theme.gridUnit}px;
+  }
+  &:before {
+    display: ${isDragging || isLoading ? 'block' : 'none'};
+    background-color: ${
+      canDrop ? theme.colors.primary.base : theme.colors.error.light1
+    };
+    z-index: ${theme.zIndex.aboveDashboardCharts};
+    opacity: ${theme.opacity.light};
+    top: 1px;
+    right: 1px;
+    bottom: 1px;
+    left: 1px;
+  }
+  &:after {
+    display: ${isLoading || (canDrop && isOver) ? 'block' : 'none'};
+    background-color: ${
+      isLoading ? theme.colors.grayscale.light3 : theme.colors.primary.base
+    };
+    z-index: ${theme.zIndex.dropdown};
+    opacity: ${theme.opacity.mediumLight};
+    top: ${-theme.gridUnit}px;
+    right: ${-theme.gridUnit}px;
+    bottom: ${-theme.gridUnit}px;
+    left: ${-theme.gridUnit}px;
+    cursor: ${isLoading ? 'wait' : 'auto'};
+  }
+  `}
+
+  &:before {
+    ${({ theme, isLoading }) =>
+      isLoading &&
+      css`
+        animation: ${borderPulse} 2s ease-in infinite;
+        background: linear-gradient(currentColor 0 0) 0 100%/0% 3px no-repeat;
+        background-size: 100% ${theme.gridUnit / 2}px;
+        top: auto;
+        right: ${theme.gridUnit}px;
+        left: ${theme.gridUnit}px;
+        bottom: -${theme.gridUnit / 2}px;
+        height: ${theme.gridUnit / 2}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(() =>