mirror of https://github.com/apache/superset.git
fix(explore): drag and drop indicator UX (#27558)
This commit is contained in:
parent
a48f770195
commit
7369754192
|
@ -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);
|
||||
});
|
|
@ -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;
|
|
@ -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};
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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<{
|
||||
|
|
|
@ -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(() =>
|
||||
|
|
Loading…
Reference in New Issue