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(() =>