fix(explore): drag and drop indicator UX (#27558)

This commit is contained in:
JUST.in DO IT 2024-03-27 11:22:27 -07:00 committed by GitHub
parent a48f770195
commit 7369754192
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 237 additions and 21 deletions

View File

@ -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);
});

View File

@ -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;

View File

@ -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};

View File

@ -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()}

View File

@ -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<{

View File

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