1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-09 00:18:00 +01:00

chore: drag to reorder release plan template milestones (#9176)

https://linear.app/unleash/issue/2-2821/drag-to-reorder-template-milestones

This PR introduces reordering release plan template milestones by
dragging and dropping them.

Was a bit undecided on the approach, but it seems like using an old
`useDragItem` hook we have is pretty elegant and behaves as expected.

I suggest reviewers try it out themselves.

Includes a slight refactor to `useDragItem`, which so far is only used
here and in environments. I manually tested, but I suggest trying that
one out as well just in case.


![image](https://github.com/user-attachments/assets/3e433f70-53f8-4860-a704-60361f3b0ed7)
This commit is contained in:
Nuno Góis 2025-01-31 09:12:27 +00:00 committed by GitHub
parent ec014c0fdf
commit 25e8f80f21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 124 additions and 56 deletions

View File

@ -1,4 +1,4 @@
import { type MoveListItem, useDragItem } from 'hooks/useDragItem';
import { type OnMoveItem, useDragItem } from 'hooks/useDragItem';
import type { Row } from 'react-table';
import { styled, TableRow } from '@mui/material';
import { TableCell } from 'component/common/Table';
@ -18,10 +18,10 @@ const StyledTableRow = styled(TableRow)(() => ({
interface IEnvironmentRowProps {
row: Row;
moveListItem: MoveListItem;
onMoveItem: OnMoveItem;
}
export const EnvironmentRow = ({ row, moveListItem }: IEnvironmentRowProps) => {
export const EnvironmentRow = ({ row, onMoveItem }: IEnvironmentRowProps) => {
const { hasAccess } = useContext(AccessContext);
const dragHandleRef = useRef(null);
const { searchQuery } = useSearchHighlightContext();
@ -29,15 +29,17 @@ export const EnvironmentRow = ({ row, moveListItem }: IEnvironmentRowProps) => {
const dragItemRef = useDragItem<HTMLTableRowElement>(
row.index,
moveListItem,
onMoveItem,
dragHandleRef,
);
const renderCell = (cell: any, ref: ForwardedRef<HTMLElement>) => {
const { key, ...cellProps } = cell.getCellProps();
if (draggable && cell.column.isDragHandle) {
return (
<TableCell
{...cell.getCellProps()}
key={key}
{...cellProps}
ref={ref}
className='drag-handle'
>
@ -46,7 +48,7 @@ export const EnvironmentRow = ({ row, moveListItem }: IEnvironmentRowProps) => {
);
} else {
return (
<TableCell {...cell.getCellProps()}>
<TableCell key={key} {...cellProps}>
{cell.render('Cell')}
</TableCell>
);

View File

@ -11,7 +11,7 @@ import {
import { useCallback, useMemo } from 'react';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { Alert, styled, TableBody } from '@mui/material';
import type { MoveListItem } from 'hooks/useDragItem';
import type { OnMoveItem } from 'hooks/useDragItem';
import useToast from 'hooks/useToast';
import useEnvironmentApi, {
createSortOrderPayload,
@ -38,17 +38,20 @@ export const EnvironmentTable = () => {
const { environments, mutateEnvironments } = useEnvironments();
const isFeatureEnabled = useUiFlag('EEA');
const moveListItem: MoveListItem = useCallback(
const onMoveItem: OnMoveItem = useCallback(
async (dragIndex: number, dropIndex: number, save = false) => {
const copy = [...environments];
const tmp = copy[dragIndex];
copy.splice(dragIndex, 1);
copy.splice(dropIndex, 0, tmp);
await mutateEnvironments(copy);
const oldEnvironments = environments || [];
const newEnvironments = [...oldEnvironments];
const movedEnvironment = newEnvironments.splice(dragIndex, 1)[0];
newEnvironments.splice(dropIndex, 0, movedEnvironment);
await mutateEnvironments(newEnvironments);
if (save) {
try {
await changeSortOrder(createSortOrderPayload(copy));
await changeSortOrder(
createSortOrderPayload(newEnvironments),
);
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
@ -136,7 +139,7 @@ export const EnvironmentTable = () => {
return (
<EnvironmentRow
row={row as any}
moveListItem={moveListItem}
onMoveItem={onMoveItem}
key={row.original.name}
/>
);

View File

@ -9,19 +9,22 @@ import {
AccordionSummary,
AccordionDetails,
IconButton,
FormHelperText,
} from '@mui/material';
import Delete from '@mui/icons-material/DeleteOutlined';
import type {
IReleasePlanMilestonePayload,
IReleasePlanMilestoneStrategy,
} from 'interfaces/releasePlans';
import { type DragEventHandler, type RefObject, useState } from 'react';
import { type DragEventHandler, type RefObject, useRef, useState } from 'react';
import ExpandMore from '@mui/icons-material/ExpandMore';
import { MilestoneCardName } from './MilestoneCardName';
import { MilestoneStrategyMenuCards } from './MilestoneStrategyMenu/MilestoneStrategyMenuCards';
import { MilestoneStrategyDraggableItem } from './MilestoneStrategyDraggableItem';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { ReleasePlanTemplateAddStrategyForm } from '../../MilestoneStrategy/ReleasePlanTemplateAddStrategyForm';
import DragIndicator from '@mui/icons-material/DragIndicator';
import { type OnMoveItem, useDragItem } from 'hooks/useDragItem';
const StyledMilestoneCard = styled(Card, {
shouldForwardProp: (prop) => prop !== 'hasError',
@ -110,13 +113,25 @@ const StyledIconButton = styled(IconButton)(({ theme }) => ({
color: theme.palette.primary.main,
}));
interface IMilestoneCardProps {
const StyledDragIcon = styled(IconButton)(({ theme }) => ({
padding: 0,
cursor: 'grab',
transition: 'color 0.2s ease-in-out',
marginRight: theme.spacing(1),
'& > svg': {
color: 'action.active',
},
}));
export interface IMilestoneCardProps {
milestone: IReleasePlanMilestonePayload;
milestoneChanged: (milestone: IReleasePlanMilestonePayload) => void;
errors: { [key: string]: string };
clearErrors: () => void;
removable: boolean;
onDeleteMilestone: () => void;
index: number;
onMoveItem: OnMoveItem;
}
export const MilestoneCard = ({
@ -126,6 +141,8 @@ export const MilestoneCard = ({
clearErrors,
removable,
onDeleteMilestone,
index,
onMoveItem,
}: IMilestoneCardProps) => {
const [anchor, setAnchor] = useState<Element>();
const [dragItem, setDragItem] = useState<{
@ -141,6 +158,20 @@ export const MilestoneCard = ({
? 'MilestoneStrategyMenuPopover'
: undefined;
const dragHandleRef = useRef(null);
const dragItemRef = useDragItem<HTMLTableRowElement>(
index,
onMoveItem,
dragHandleRef,
);
const dragHandle = (
<StyledDragIcon ref={dragHandleRef} disableRipple size='small'>
<DragIndicator titleAccess='Drag to reorder' />
</StyledDragIcon>
);
const onClose = () => {
setAnchor(undefined);
};
@ -217,7 +248,7 @@ export const MilestoneCard = ({
setAddUpdateStrategyOpen(true);
};
const onDragOver =
const onStrategyDragOver =
(targetId: string) =>
(
ref: RefObject<HTMLDivElement>,
@ -253,7 +284,7 @@ export const MilestoneCard = ({
}
};
const onDragStartRef =
const onStrategyDragStartRef =
(
ref: RefObject<HTMLDivElement>,
index: number,
@ -275,7 +306,7 @@ export const MilestoneCard = ({
event.dataTransfer.setDragImage(ref.current, 20, 20);
}
};
const onDragEnd = () => {
const onStrategyDragEnd = () => {
setDragItem(null);
onReOrderStrategies();
};
@ -313,10 +344,12 @@ export const MilestoneCard = ({
Boolean(errors?.[milestone.id]) ||
Boolean(errors?.[`${milestone.id}_name`])
}
ref={dragItemRef}
>
<StyledMilestoneCardBody>
<Grid container>
<StyledGridItem item xs={6} md={6}>
{dragHandle}
<MilestoneCardName
milestone={milestone}
errors={errors}
@ -368,6 +401,10 @@ export const MilestoneCard = ({
</StyledMilestoneCardBody>
</StyledMilestoneCard>
<FormHelperText error={Boolean(errors?.[milestone.id])}>
{errors?.[milestone.id]}
</FormHelperText>
<SidebarModal
label='Add strategy to template milestone'
onClose={() => {
@ -398,7 +435,9 @@ export const MilestoneCard = ({
>
<StyledAccordionSummary
expandIcon={<ExpandMore titleAccess='Toggle' />}
ref={dragItemRef}
>
{dragHandle}
<MilestoneCardName
milestone={milestone}
errors={errors}
@ -411,9 +450,9 @@ export const MilestoneCard = ({
<div key={strg.id}>
<MilestoneStrategyDraggableItem
index={index}
onDragEnd={onDragEnd}
onDragStartRef={onDragStartRef}
onDragOver={onDragOver(strg.id)}
onDragEnd={onStrategyDragEnd}
onDragStartRef={onStrategyDragStartRef}
onDragOver={onStrategyDragOver(strg.id)}
onDeleteClick={() =>
milestoneStrategyDeleted(strg.id)
}
@ -463,6 +502,10 @@ export const MilestoneCard = ({
</StyledAccordionDetails>
</StyledAccordion>
<FormHelperText error={Boolean(errors?.[milestone.id])}>
{errors?.[milestone.id]}
</FormHelperText>
<SidebarModal
label='Add strategy to template milestone'
onClose={() => {

View File

@ -1,8 +1,10 @@
import type { IReleasePlanMilestonePayload } from 'interfaces/releasePlans';
import { MilestoneCard } from './MilestoneCard/MilestoneCard';
import { styled, Button, FormHelperText } from '@mui/material';
import { styled, Button } from '@mui/material';
import Add from '@mui/icons-material/Add';
import { v4 as uuidv4 } from 'uuid';
import { useCallback } from 'react';
import type { OnMoveItem } from 'hooks/useDragItem';
import { MilestoneCard } from './MilestoneCard/MilestoneCard';
interface IMilestoneListProps {
milestones: IReleasePlanMilestonePayload[];
@ -26,6 +28,24 @@ export const MilestoneList = ({
clearErrors,
milestoneChanged,
}: IMilestoneListProps) => {
const onMoveItem: OnMoveItem = useCallback(
async (dragIndex: number, dropIndex: number) => {
if (dragIndex !== dropIndex) {
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],
);
const onDeleteMilestone = (milestoneId: string) => () => {
setMilestones((prev) =>
prev
@ -36,10 +56,11 @@ export const MilestoneList = ({
return (
<>
{milestones.map((milestone) => (
<>
{milestones.map((milestone, index) => (
<MilestoneCard
key={milestone.id}
index={index}
onMoveItem={onMoveItem}
milestone={milestone}
milestoneChanged={milestoneChanged}
errors={errors}
@ -47,11 +68,6 @@ export const MilestoneList = ({
removable={milestones.length > 1}
onDeleteMilestone={onDeleteMilestone(milestone.id)}
/>
<FormHelperText error={Boolean(errors?.[milestone.id])}>
{errors?.[milestone.id]}
</FormHelperText>
</>
))}
<StyledAddMilestoneButton
variant='text'

View File

@ -1,14 +1,17 @@
import { useRef, useEffect, type RefObject } from 'react';
export type MoveListItem = (
export type OnMoveItem = (
dragIndex: number,
dropIndex: number,
save?: boolean,
) => void;
// The element being dragged in the browser.
let globalDraggedElement: HTMLElement | null;
export const useDragItem = <T extends HTMLElement>(
listItemIndex: number,
moveListItem: MoveListItem,
onMoveItem: OnMoveItem,
handle?: RefObject<HTMLElement>,
): RefObject<T> => {
const ref = useRef<T>(null);
@ -18,32 +21,30 @@ export const useDragItem = <T extends HTMLElement>(
ref.current.dataset.index = String(listItemIndex);
return addEventListeners(
ref.current,
moveListItem,
onMoveItem,
handle?.current ?? undefined,
);
}
}, [listItemIndex, moveListItem]);
}, [listItemIndex, onMoveItem]);
return ref;
};
const addEventListeners = (
el: HTMLElement,
moveListItem: MoveListItem,
onMoveItem: OnMoveItem,
handle?: HTMLElement,
): (() => void) => {
const handleEl = handle ?? el;
const moveDraggedElement = (save: boolean) => {
if (globalDraggedElement) {
moveListItem(
Number(globalDraggedElement.dataset.index),
Number(el.dataset.index),
save,
);
const fromIndex = Number(globalDraggedElement.dataset.index);
const toIndex = Number(el.dataset.index);
onMoveItem(fromIndex, toIndex, save);
}
};
const handleEl = handle ?? el;
const onMouseEnter = (e: MouseEvent) => {
if (e.target === handleEl) {
el.draggable = true;
@ -72,6 +73,10 @@ const addEventListeners = (
globalDraggedElement = null;
};
const onDragEnd = () => {
globalDraggedElement = null;
};
handleEl.addEventListener('mouseenter', onMouseEnter);
handleEl.addEventListener('mouseleave', onMouseLeave);
if (handle) {
@ -81,6 +86,7 @@ const addEventListeners = (
el.addEventListener('dragenter', onDragEnter);
el.addEventListener('dragover', onDragOver);
el.addEventListener('drop', onDrop);
el.addEventListener('dragend', onDragEnd);
return () => {
handleEl.removeEventListener('mouseenter', onMouseEnter);
@ -92,8 +98,6 @@ const addEventListeners = (
el.removeEventListener('dragenter', onDragEnter);
el.removeEventListener('dragover', onDragOver);
el.removeEventListener('drop', onDrop);
el.removeEventListener('dragend', onDragEnd);
};
};
// The element being dragged in the browser.
let globalDraggedElement: HTMLElement | null;