mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-04 13:48:56 +02:00
Fix(1-3462)/janky drag n drop (#9599)
Fixes janky drag and drop behavior and updates the styling of the drag handle focus. The solution uses the same method to prevent oscillation as we do for strategies. To get access to the same context, I've added some extra parameters to the OnMoveItem function and passed along the extra data from the `useDragItem` hook. No new information, just making more of it available, and turning it into an object so that you can declare the properties you need (and get rid of potential wrong ordering of drag/drop indices). For the drag and drop behavior: If the dragged element is the same size or smaller than the element you're dragging over, they will swap places as soon as you enter that space. If the target element is larger, however, they won't swap until you reach the drag/drop handle, even if they could theoretically switch somewhere in the middle. This appears to be a limitation of how the drag/drop event system works. New drag events are only fired when you "dragenter" a new element, so it never fires anywhere in the middle. Technically, we could insert more empty spans inside the drag handle to trigger more events, but I wanna hold off on that because it doesn't sound great. When dragging, only the handle is visible; the rest of the card stays in place. For strategies, we show a "ghost" version of the config you're dragging. However, if you apply the drag handle to the card itself, all of it becomes draggable, but you can no longer select the text inside it, which is unfortunate. Strategies do solev this, though, but I haven't been able to figure out why. If you know, please share! Before:  After: 
This commit is contained in:
parent
d8c7e31b18
commit
3d1a97f745
@ -39,7 +39,7 @@ export const EnvironmentTable = () => {
|
|||||||
const isFeatureEnabled = useUiFlag('EEA');
|
const isFeatureEnabled = useUiFlag('EEA');
|
||||||
|
|
||||||
const onMoveItem: OnMoveItem = useCallback(
|
const onMoveItem: OnMoveItem = useCallback(
|
||||||
async (dragIndex: number, dropIndex: number, save = false) => {
|
async ({ dragIndex, dropIndex, save }) => {
|
||||||
const oldEnvironments = environments || [];
|
const oldEnvironments = environments || [];
|
||||||
const newEnvironments = [...oldEnvironments];
|
const newEnvironments = [...oldEnvironments];
|
||||||
const movedEnvironment = newEnvironments.splice(dragIndex, 1)[0];
|
const movedEnvironment = newEnvironments.splice(dragIndex, 1)[0];
|
||||||
|
@ -17,7 +17,6 @@ import { UPDATE_FEATURE_STRATEGY } from '@server/types/permissions';
|
|||||||
import { StrategyDraggableItem } from './StrategyDraggableItem';
|
import { StrategyDraggableItem } from './StrategyDraggableItem';
|
||||||
|
|
||||||
type ProjectEnvironmentStrategyDraggableItemProps = {
|
type ProjectEnvironmentStrategyDraggableItemProps = {
|
||||||
className?: string;
|
|
||||||
strategy: IFeatureStrategy;
|
strategy: IFeatureStrategy;
|
||||||
environmentName: string;
|
environmentName: string;
|
||||||
index: number;
|
index: number;
|
||||||
@ -35,7 +34,6 @@ type ProjectEnvironmentStrategyDraggableItemProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ProjectEnvironmentStrategyDraggableItem = ({
|
export const ProjectEnvironmentStrategyDraggableItem = ({
|
||||||
className,
|
|
||||||
strategy,
|
strategy,
|
||||||
index,
|
index,
|
||||||
environmentName,
|
environmentName,
|
||||||
|
@ -10,13 +10,12 @@ import {
|
|||||||
FormHelperText,
|
FormHelperText,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import type { IReleasePlanMilestoneStrategy } from 'interfaces/releasePlans';
|
import type { IReleasePlanMilestoneStrategy } from 'interfaces/releasePlans';
|
||||||
import { type DragEventHandler, type RefObject, useRef, useState } from 'react';
|
import { type DragEventHandler, type RefObject, useState } from 'react';
|
||||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||||
import { MilestoneCardName } from './MilestoneCardName';
|
import { MilestoneCardName } from './MilestoneCardName';
|
||||||
import { MilestoneStrategyMenuCards } from './MilestoneStrategyMenu/MilestoneStrategyMenuCards';
|
import { MilestoneStrategyMenuCards } from './MilestoneStrategyMenu/MilestoneStrategyMenuCards';
|
||||||
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||||
import { ReleasePlanTemplateAddStrategyForm } from '../../MilestoneStrategy/ReleasePlanTemplateAddStrategyForm';
|
import { ReleasePlanTemplateAddStrategyForm } from '../../MilestoneStrategy/ReleasePlanTemplateAddStrategyForm';
|
||||||
import DragIndicator from '@mui/icons-material/DragIndicator';
|
|
||||||
import { type OnMoveItem, useDragItem } from 'hooks/useDragItem';
|
import { type OnMoveItem, useDragItem } from 'hooks/useDragItem';
|
||||||
import type { IExtendedMilestonePayload } from 'component/releases/hooks/useTemplateForm';
|
import type { IExtendedMilestonePayload } from 'component/releases/hooks/useTemplateForm';
|
||||||
|
|
||||||
@ -24,9 +23,9 @@ import { StrategySeparator } from 'component/common/StrategySeparator/StrategySe
|
|||||||
import Edit from '@mui/icons-material/Edit';
|
import Edit from '@mui/icons-material/Edit';
|
||||||
import Delete from '@mui/icons-material/DeleteOutlined';
|
import Delete from '@mui/icons-material/DeleteOutlined';
|
||||||
import { StrategyDraggableItem } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyDraggableItem';
|
import { StrategyDraggableItem } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyDraggableItem';
|
||||||
import { ScreenReaderOnly } from 'component/common/ScreenReaderOnly/ScreenReaderOnly';
|
|
||||||
import { StrategyList } from 'component/common/StrategyList/StrategyList';
|
import { StrategyList } from 'component/common/StrategyList/StrategyList';
|
||||||
import { StrategyListItem } from 'component/common/StrategyList/StrategyListItem';
|
import { StrategyListItem } from 'component/common/StrategyList/StrategyListItem';
|
||||||
|
import { MilestoneCardDragHandle } from './MilestoneCardDragHandle';
|
||||||
|
|
||||||
const leftPadding = 3;
|
const leftPadding = 3;
|
||||||
|
|
||||||
@ -122,27 +121,6 @@ const StyledIconButton = styled(IconButton)(({ theme }) => ({
|
|||||||
color: theme.palette.primary.main,
|
color: theme.palette.primary.main,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const DragButton = styled('button')(({ theme }) => ({
|
|
||||||
padding: 0,
|
|
||||||
cursor: 'grab',
|
|
||||||
transition: 'background-color 0.2s ease-in-out',
|
|
||||||
backgroundColor: 'inherit',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: theme.shape.borderRadiusMedium,
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
'&:hover, &:focus-visible': {
|
|
||||||
background: theme.palette.table.headerHover,
|
|
||||||
outline: 'none',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const DraggableContent = styled('span')(({ theme }) => ({
|
|
||||||
paddingTop: theme.spacing(2.75),
|
|
||||||
display: 'block',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
}));
|
|
||||||
|
|
||||||
export interface IMilestoneCardProps {
|
export interface IMilestoneCardProps {
|
||||||
milestone: IExtendedMilestonePayload;
|
milestone: IExtendedMilestonePayload;
|
||||||
milestoneChanged: (milestone: IExtendedMilestonePayload) => void;
|
milestoneChanged: (milestone: IExtendedMilestonePayload) => void;
|
||||||
@ -178,22 +156,7 @@ export const MilestoneCard = ({
|
|||||||
? 'MilestoneStrategyMenuPopover'
|
? 'MilestoneStrategyMenuPopover'
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const dragHandleRef = useRef(null);
|
const dragItemRef = useDragItem<HTMLSpanElement>(index, onMoveItem);
|
||||||
|
|
||||||
const dragItemRef = useDragItem<HTMLTableRowElement>(
|
|
||||||
index,
|
|
||||||
onMoveItem,
|
|
||||||
dragHandleRef,
|
|
||||||
);
|
|
||||||
|
|
||||||
const dragHandle = (
|
|
||||||
<DragButton type='button'>
|
|
||||||
<DraggableContent ref={dragItemRef}>
|
|
||||||
<DragIndicator aria-hidden />
|
|
||||||
<ScreenReaderOnly>Drag to reorder</ScreenReaderOnly>
|
|
||||||
</DraggableContent>
|
|
||||||
</DragButton>
|
|
||||||
);
|
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
setAnchor(undefined);
|
setAnchor(undefined);
|
||||||
@ -363,7 +326,7 @@ export const MilestoneCard = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DraggableCardContainer>
|
<DraggableCardContainer>
|
||||||
{dragHandle}
|
<MilestoneCardDragHandle dragItemRef={dragItemRef} />
|
||||||
<StyledMilestoneCard
|
<StyledMilestoneCard
|
||||||
hasError={
|
hasError={
|
||||||
Boolean(errors?.[milestone.id]) ||
|
Boolean(errors?.[milestone.id]) ||
|
||||||
@ -445,8 +408,8 @@ export const MilestoneCard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DraggableCardContainer ref={dragItemRef}>
|
<DraggableCardContainer>
|
||||||
{dragHandle}
|
<MilestoneCardDragHandle dragItemRef={dragItemRef} />
|
||||||
<StyledAccordion
|
<StyledAccordion
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onChange={(e, change) => setExpanded(change)}
|
onChange={(e, change) => setExpanded(change)}
|
||||||
|
@ -0,0 +1,54 @@
|
|||||||
|
import DragIndicator from '@mui/icons-material/DragIndicator';
|
||||||
|
import { styled } from '@mui/material';
|
||||||
|
import { ScreenReaderOnly } from 'component/common/ScreenReaderOnly/ScreenReaderOnly';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
const DragButton = styled('button')(({ theme }) => ({
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'grab',
|
||||||
|
transition: 'background-color 0.2s ease-in-out',
|
||||||
|
backgroundColor: 'inherit',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: theme.shape.borderRadiusMedium,
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
':hover, :focus-visible': {
|
||||||
|
outline: 'none',
|
||||||
|
'.draggable-hover-indicator': {
|
||||||
|
background: theme.palette.table.headerHover,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DraggableContent = styled('span')(({ theme }) => ({
|
||||||
|
paddingTop: theme.spacing(2),
|
||||||
|
paddingInline: theme.spacing(0.5),
|
||||||
|
display: 'block',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DraggableHoverIndicator = styled('span')(({ theme }) => ({
|
||||||
|
display: 'block',
|
||||||
|
paddingBlock: theme.spacing(0.75),
|
||||||
|
borderRadius: theme.shape.borderRadiusMedium,
|
||||||
|
'> svg': {
|
||||||
|
verticalAlign: 'bottom',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
dragItemRef: React.RefObject<HTMLElement>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MilestoneCardDragHandle: FC<Props> = ({ dragItemRef }) => {
|
||||||
|
return (
|
||||||
|
<DragButton tabIndex={-1} type='button'>
|
||||||
|
<DraggableContent ref={dragItemRef}>
|
||||||
|
<DraggableHoverIndicator className='draggable-hover-indicator'>
|
||||||
|
<DragIndicator aria-hidden />
|
||||||
|
</DraggableHoverIndicator>
|
||||||
|
<ScreenReaderOnly>Drag to reorder</ScreenReaderOnly>
|
||||||
|
</DraggableContent>
|
||||||
|
</DragButton>
|
||||||
|
);
|
||||||
|
};
|
@ -32,38 +32,44 @@ export const MilestoneList = ({
|
|||||||
}: IMilestoneListProps) => {
|
}: IMilestoneListProps) => {
|
||||||
const useNewMilestoneCard = useUiFlag('flagOverviewRedesign');
|
const useNewMilestoneCard = useUiFlag('flagOverviewRedesign');
|
||||||
const onMoveItem: OnMoveItem = useCallback(
|
const onMoveItem: OnMoveItem = useCallback(
|
||||||
async (dragIndex: number, dropIndex: number, save?: boolean) => {
|
async ({ dragIndex, dropIndex, event, draggedElement }) => {
|
||||||
if (useNewMilestoneCard && save) {
|
if (useNewMilestoneCard && event.type === 'drop') {
|
||||||
return; // the user has let go, we should leave the current sort order as it is currently visually displayed
|
return; // the user has let go, we should leave the current sort order as it is currently visually displayed
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dragIndex !== dropIndex) {
|
if (event.type === 'dragenter' && dragIndex !== dropIndex) {
|
||||||
// todo! See if there's a way to make this snippet to stabilize dragging before removing flag `flagOverviewRedesign`
|
const target = event.target as HTMLElement;
|
||||||
// We don't have a reference to `ref` or `event` here, but maybe we can make it work? Somehow?
|
|
||||||
|
|
||||||
// const { top, bottom } = ref.current.getBoundingClientRect();
|
const draggedElementHeight =
|
||||||
// const overTargetTop = event.clientY - top < dragItem.height;
|
draggedElement.getBoundingClientRect().height;
|
||||||
// const overTargetBottom =
|
|
||||||
// bottom - event.clientY < dragItem.height;
|
|
||||||
// const draggingUp = dragItem.index > targetIndex;
|
|
||||||
|
|
||||||
// // prevent oscillating by only reordering if there is sufficient space
|
const { top, bottom } = target.getBoundingClientRect();
|
||||||
// if (
|
const overTargetTop =
|
||||||
// (overTargetTop && draggingUp) ||
|
event.clientY - top < draggedElementHeight;
|
||||||
// (overTargetBottom && !draggingUp)
|
const overTargetBottom =
|
||||||
// ) {
|
bottom - event.clientY < draggedElementHeight;
|
||||||
// // reorder here
|
const draggingUp = dragIndex > dropIndex;
|
||||||
// }
|
|
||||||
const oldMilestones = milestones || [];
|
|
||||||
const newMilestones = [...oldMilestones];
|
|
||||||
const movedMilestone = newMilestones.splice(dragIndex, 1)[0];
|
|
||||||
newMilestones.splice(dropIndex, 0, movedMilestone);
|
|
||||||
|
|
||||||
newMilestones.forEach((milestone, index) => {
|
// prevent oscillating by only reordering if there is sufficient space
|
||||||
milestone.sortOrder = index;
|
const shouldReorder = draggingUp
|
||||||
});
|
? overTargetTop
|
||||||
|
: overTargetBottom;
|
||||||
|
|
||||||
setMilestones(newMilestones);
|
if (shouldReorder) {
|
||||||
|
const oldMilestones = milestones || [];
|
||||||
|
const newMilestones = [...oldMilestones];
|
||||||
|
const movedMilestone = newMilestones.splice(
|
||||||
|
dragIndex,
|
||||||
|
1,
|
||||||
|
)[0];
|
||||||
|
newMilestones.splice(dropIndex, 0, movedMilestone);
|
||||||
|
|
||||||
|
newMilestones.forEach((milestone, index) => {
|
||||||
|
milestone.sortOrder = index;
|
||||||
|
});
|
||||||
|
|
||||||
|
setMilestones(newMilestones);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[milestones],
|
[milestones],
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
import { useRef, useEffect, type RefObject } from 'react';
|
import { useRef, useEffect, type RefObject } from 'react';
|
||||||
|
|
||||||
export type OnMoveItem = (
|
type OnMoveItemParams = {
|
||||||
dragIndex: number,
|
dragIndex: number;
|
||||||
dropIndex: number,
|
dropIndex: number;
|
||||||
save?: boolean,
|
save: boolean;
|
||||||
) => void;
|
event: DragEvent;
|
||||||
|
draggedElement: HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnMoveItem = (args: OnMoveItemParams) => void;
|
||||||
|
|
||||||
// The element being dragged in the browser.
|
// The element being dragged in the browser.
|
||||||
let globalDraggedElement: HTMLElement | null;
|
let globalDraggedElement: HTMLElement | null;
|
||||||
@ -37,11 +41,17 @@ const addEventListeners = (
|
|||||||
): (() => void) => {
|
): (() => void) => {
|
||||||
const handleEl = handle ?? el;
|
const handleEl = handle ?? el;
|
||||||
|
|
||||||
const moveDraggedElement = (save: boolean) => {
|
const moveDraggedElement = (save: boolean, event: DragEvent) => {
|
||||||
if (globalDraggedElement) {
|
if (globalDraggedElement) {
|
||||||
const fromIndex = Number(globalDraggedElement.dataset.index);
|
const dragIndex = Number(globalDraggedElement.dataset.index);
|
||||||
const toIndex = Number(el.dataset.index);
|
const dropIndex = Number(el.dataset.index);
|
||||||
onMoveItem(fromIndex, toIndex, save);
|
onMoveItem({
|
||||||
|
dragIndex,
|
||||||
|
dropIndex,
|
||||||
|
save,
|
||||||
|
event,
|
||||||
|
draggedElement: globalDraggedElement,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -60,16 +70,16 @@ const addEventListeners = (
|
|||||||
globalDraggedElement = el;
|
globalDraggedElement = el;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragEnter = () => {
|
const onDragEnter = (event: DragEvent) => {
|
||||||
moveDraggedElement(false);
|
moveDraggedElement(false, event);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragOver = (event: DragEvent) => {
|
const onDragOver = (event: DragEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDrop = () => {
|
const onDrop = (event: DragEvent) => {
|
||||||
moveDraggedElement(true);
|
moveDraggedElement(true, event);
|
||||||
globalDraggedElement = null;
|
globalDraggedElement = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user