1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +02: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 type { Row } from 'react-table';
import { styled, TableRow } from '@mui/material'; import { styled, TableRow } from '@mui/material';
import { TableCell } from 'component/common/Table'; import { TableCell } from 'component/common/Table';
@ -18,10 +18,10 @@ const StyledTableRow = styled(TableRow)(() => ({
interface IEnvironmentRowProps { interface IEnvironmentRowProps {
row: Row; row: Row;
moveListItem: MoveListItem; onMoveItem: OnMoveItem;
} }
export const EnvironmentRow = ({ row, moveListItem }: IEnvironmentRowProps) => { export const EnvironmentRow = ({ row, onMoveItem }: IEnvironmentRowProps) => {
const { hasAccess } = useContext(AccessContext); const { hasAccess } = useContext(AccessContext);
const dragHandleRef = useRef(null); const dragHandleRef = useRef(null);
const { searchQuery } = useSearchHighlightContext(); const { searchQuery } = useSearchHighlightContext();
@ -29,15 +29,17 @@ export const EnvironmentRow = ({ row, moveListItem }: IEnvironmentRowProps) => {
const dragItemRef = useDragItem<HTMLTableRowElement>( const dragItemRef = useDragItem<HTMLTableRowElement>(
row.index, row.index,
moveListItem, onMoveItem,
dragHandleRef, dragHandleRef,
); );
const renderCell = (cell: any, ref: ForwardedRef<HTMLElement>) => { const renderCell = (cell: any, ref: ForwardedRef<HTMLElement>) => {
const { key, ...cellProps } = cell.getCellProps();
if (draggable && cell.column.isDragHandle) { if (draggable && cell.column.isDragHandle) {
return ( return (
<TableCell <TableCell
{...cell.getCellProps()} key={key}
{...cellProps}
ref={ref} ref={ref}
className='drag-handle' className='drag-handle'
> >
@ -46,7 +48,7 @@ export const EnvironmentRow = ({ row, moveListItem }: IEnvironmentRowProps) => {
); );
} else { } else {
return ( return (
<TableCell {...cell.getCellProps()}> <TableCell key={key} {...cellProps}>
{cell.render('Cell')} {cell.render('Cell')}
</TableCell> </TableCell>
); );

View File

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

View File

@ -9,19 +9,22 @@ import {
AccordionSummary, AccordionSummary,
AccordionDetails, AccordionDetails,
IconButton, IconButton,
FormHelperText,
} from '@mui/material'; } from '@mui/material';
import Delete from '@mui/icons-material/DeleteOutlined'; import Delete from '@mui/icons-material/DeleteOutlined';
import type { import type {
IReleasePlanMilestonePayload, IReleasePlanMilestonePayload,
IReleasePlanMilestoneStrategy, IReleasePlanMilestoneStrategy,
} from 'interfaces/releasePlans'; } 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 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 { MilestoneStrategyDraggableItem } from './MilestoneStrategyDraggableItem'; import { MilestoneStrategyDraggableItem } from './MilestoneStrategyDraggableItem';
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';
const StyledMilestoneCard = styled(Card, { const StyledMilestoneCard = styled(Card, {
shouldForwardProp: (prop) => prop !== 'hasError', shouldForwardProp: (prop) => prop !== 'hasError',
@ -110,13 +113,25 @@ const StyledIconButton = styled(IconButton)(({ theme }) => ({
color: theme.palette.primary.main, 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; milestone: IReleasePlanMilestonePayload;
milestoneChanged: (milestone: IReleasePlanMilestonePayload) => void; milestoneChanged: (milestone: IReleasePlanMilestonePayload) => void;
errors: { [key: string]: string }; errors: { [key: string]: string };
clearErrors: () => void; clearErrors: () => void;
removable: boolean; removable: boolean;
onDeleteMilestone: () => void; onDeleteMilestone: () => void;
index: number;
onMoveItem: OnMoveItem;
} }
export const MilestoneCard = ({ export const MilestoneCard = ({
@ -126,6 +141,8 @@ export const MilestoneCard = ({
clearErrors, clearErrors,
removable, removable,
onDeleteMilestone, onDeleteMilestone,
index,
onMoveItem,
}: IMilestoneCardProps) => { }: IMilestoneCardProps) => {
const [anchor, setAnchor] = useState<Element>(); const [anchor, setAnchor] = useState<Element>();
const [dragItem, setDragItem] = useState<{ const [dragItem, setDragItem] = useState<{
@ -141,6 +158,20 @@ export const MilestoneCard = ({
? 'MilestoneStrategyMenuPopover' ? 'MilestoneStrategyMenuPopover'
: undefined; : 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 = () => { const onClose = () => {
setAnchor(undefined); setAnchor(undefined);
}; };
@ -217,7 +248,7 @@ export const MilestoneCard = ({
setAddUpdateStrategyOpen(true); setAddUpdateStrategyOpen(true);
}; };
const onDragOver = const onStrategyDragOver =
(targetId: string) => (targetId: string) =>
( (
ref: RefObject<HTMLDivElement>, ref: RefObject<HTMLDivElement>,
@ -253,7 +284,7 @@ export const MilestoneCard = ({
} }
}; };
const onDragStartRef = const onStrategyDragStartRef =
( (
ref: RefObject<HTMLDivElement>, ref: RefObject<HTMLDivElement>,
index: number, index: number,
@ -275,7 +306,7 @@ export const MilestoneCard = ({
event.dataTransfer.setDragImage(ref.current, 20, 20); event.dataTransfer.setDragImage(ref.current, 20, 20);
} }
}; };
const onDragEnd = () => { const onStrategyDragEnd = () => {
setDragItem(null); setDragItem(null);
onReOrderStrategies(); onReOrderStrategies();
}; };
@ -313,10 +344,12 @@ export const MilestoneCard = ({
Boolean(errors?.[milestone.id]) || Boolean(errors?.[milestone.id]) ||
Boolean(errors?.[`${milestone.id}_name`]) Boolean(errors?.[`${milestone.id}_name`])
} }
ref={dragItemRef}
> >
<StyledMilestoneCardBody> <StyledMilestoneCardBody>
<Grid container> <Grid container>
<StyledGridItem item xs={6} md={6}> <StyledGridItem item xs={6} md={6}>
{dragHandle}
<MilestoneCardName <MilestoneCardName
milestone={milestone} milestone={milestone}
errors={errors} errors={errors}
@ -368,6 +401,10 @@ export const MilestoneCard = ({
</StyledMilestoneCardBody> </StyledMilestoneCardBody>
</StyledMilestoneCard> </StyledMilestoneCard>
<FormHelperText error={Boolean(errors?.[milestone.id])}>
{errors?.[milestone.id]}
</FormHelperText>
<SidebarModal <SidebarModal
label='Add strategy to template milestone' label='Add strategy to template milestone'
onClose={() => { onClose={() => {
@ -398,7 +435,9 @@ export const MilestoneCard = ({
> >
<StyledAccordionSummary <StyledAccordionSummary
expandIcon={<ExpandMore titleAccess='Toggle' />} expandIcon={<ExpandMore titleAccess='Toggle' />}
ref={dragItemRef}
> >
{dragHandle}
<MilestoneCardName <MilestoneCardName
milestone={milestone} milestone={milestone}
errors={errors} errors={errors}
@ -411,9 +450,9 @@ export const MilestoneCard = ({
<div key={strg.id}> <div key={strg.id}>
<MilestoneStrategyDraggableItem <MilestoneStrategyDraggableItem
index={index} index={index}
onDragEnd={onDragEnd} onDragEnd={onStrategyDragEnd}
onDragStartRef={onDragStartRef} onDragStartRef={onStrategyDragStartRef}
onDragOver={onDragOver(strg.id)} onDragOver={onStrategyDragOver(strg.id)}
onDeleteClick={() => onDeleteClick={() =>
milestoneStrategyDeleted(strg.id) milestoneStrategyDeleted(strg.id)
} }
@ -463,6 +502,10 @@ export const MilestoneCard = ({
</StyledAccordionDetails> </StyledAccordionDetails>
</StyledAccordion> </StyledAccordion>
<FormHelperText error={Boolean(errors?.[milestone.id])}>
{errors?.[milestone.id]}
</FormHelperText>
<SidebarModal <SidebarModal
label='Add strategy to template milestone' label='Add strategy to template milestone'
onClose={() => { onClose={() => {

View File

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

View File

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