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:
parent
ec014c0fdf
commit
25e8f80f21
@ -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>
|
||||
);
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
@ -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={() => {
|
||||
|
@ -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'
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user