From 10ed12e589a38e83cb40026b6e8f09305ff88364 Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Wed, 17 Apr 2024 18:20:02 +0530 Subject: [PATCH] [WEB-1005] chore: pragmatic drag n drop implementation for labels (#4223) * pragmatic drag n drop implementation for labels * minor code quality improvements --- packages/types/src/pragmatic.d.ts | 31 ++- packages/ui/src/drop-indicator.tsx | 8 +- .../labels/label-block/drag-handle.tsx | 15 +- .../labels/label-block/label-item-block.tsx | 9 +- .../labels/label-drag-n-drop-HOC.tsx | 161 ++++++++++++++ web/components/labels/label-utils.ts | 67 ++++++ .../labels/project-setting-label-group.tsx | 209 ++++++++---------- .../labels/project-setting-label-item.tsx | 75 ++++--- .../labels/project-setting-label-list.tsx | 165 ++++---------- web/helpers/array.helper.ts | 4 +- web/package.json | 3 +- .../projects/[projectId]/settings/labels.tsx | 21 +- web/store/label.store.ts | 75 ++++--- yarn.lock | 8 + 14 files changed, 517 insertions(+), 334 deletions(-) create mode 100644 web/components/labels/label-drag-n-drop-HOC.tsx create mode 100644 web/components/labels/label-utils.ts diff --git a/packages/types/src/pragmatic.d.ts b/packages/types/src/pragmatic.d.ts index ca47e2d37..439e2b54f 100644 --- a/packages/types/src/pragmatic.d.ts +++ b/packages/types/src/pragmatic.d.ts @@ -8,18 +8,27 @@ export type TDropTargetMiscellaneousData = { isActiveDueToStickiness: boolean; }; -export interface IPragmaticDropPayload { - location: { - initial: { - dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[]; - }; - current: { - dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[]; - }; - previous: { - dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[]; - }; +export interface IPragmaticPayloadLocation { + initial: { + dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[]; }; + current: { + dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[]; + }; + previous: { + dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[]; + }; +} + +export interface IPragmaticDropPayload { + location: IPragmaticPayloadLocation; source: TDropTarget; self: TDropTarget & TDropTargetMiscellaneousData; } + +export type InstructionType = + | "reparent" + | "reorder-above" + | "reorder-below" + | "make-child" + | "instruction-blocked"; \ No newline at end of file diff --git a/packages/ui/src/drop-indicator.tsx b/packages/ui/src/drop-indicator.tsx index 228c1a330..7ffc83a4b 100644 --- a/packages/ui/src/drop-indicator.tsx +++ b/packages/ui/src/drop-indicator.tsx @@ -3,9 +3,12 @@ import { cn } from "../helpers"; type Props = { isVisible: boolean; + classNames?: string; }; export const DropIndicator = (props: Props) => { + const { isVisible, classNames = "" } = props; + return (
{ before:left-0 before:relative before:block before:top-[-2px] before:h-[6px] before:w-[6px] before:rounded after:left-[calc(100%-6px)] after:relative after:block after:top-[-8px] after:h-[6px] after:w-[6px] after:rounded`, { - "bg-custom-primary-100 before:bg-custom-primary-100 after:bg-custom-primary-100": props.isVisible, - } + "bg-custom-primary-100 before:bg-custom-primary-100 after:bg-custom-primary-100": isVisible, + }, + classNames )} /> ); diff --git a/web/components/labels/label-block/drag-handle.tsx b/web/components/labels/label-block/drag-handle.tsx index a71637bb9..64aaa075f 100644 --- a/web/components/labels/label-block/drag-handle.tsx +++ b/web/components/labels/label-block/drag-handle.tsx @@ -1,24 +1,25 @@ -import { DraggableProvidedDragHandleProps } from "@hello-pangea/dnd"; +import { forwardRef } from "react"; import { MoreVertical } from "lucide-react"; interface IDragHandle { isDragging: boolean; - dragHandleProps: DraggableProvidedDragHandleProps; } -export const DragHandle = (props: IDragHandle) => { - const { isDragging, dragHandleProps } = props; +export const DragHandle = forwardRef((props, ref) => { + const { isDragging } = props; return ( ); -}; +}); + +DragHandle.displayName = "DragHandle"; diff --git a/web/components/labels/label-block/label-item-block.tsx b/web/components/labels/label-block/label-item-block.tsx index d15406059..e3236c91d 100644 --- a/web/components/labels/label-block/label-item-block.tsx +++ b/web/components/labels/label-block/label-item-block.tsx @@ -1,5 +1,4 @@ -import { useRef, useState } from "react"; -import { DraggableProvidedDragHandleProps } from "@hello-pangea/dnd"; +import { MutableRefObject, useRef, useState } from "react"; import { LucideIcon, X } from "lucide-react"; import { IIssueLabel } from "@plane/types"; //ui @@ -24,13 +23,13 @@ interface ILabelItemBlock { label: IIssueLabel; isDragging: boolean; customMenuItems: ICustomMenuItem[]; - dragHandleProps: DraggableProvidedDragHandleProps; handleLabelDelete: (label: IIssueLabel) => void; isLabelGroup?: boolean; + dragHandleRef: MutableRefObject; } export const LabelItemBlock = (props: ILabelItemBlock) => { - const { label, isDragging, customMenuItems, dragHandleProps, handleLabelDelete, isLabelGroup } = props; + const { label, isDragging, customMenuItems, handleLabelDelete, isLabelGroup, dragHandleRef } = props; // states const [isMenuActive, setIsMenuActive] = useState(false); // refs @@ -41,7 +40,7 @@ export const LabelItemBlock = (props: ILabelItemBlock) => { return (
- +
diff --git a/web/components/labels/label-drag-n-drop-HOC.tsx b/web/components/labels/label-drag-n-drop-HOC.tsx new file mode 100644 index 000000000..3898d6b7a --- /dev/null +++ b/web/components/labels/label-drag-n-drop-HOC.tsx @@ -0,0 +1,161 @@ +import { MutableRefObject, useEffect, useRef, useState } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview"; +import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview"; +import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; +import { observer } from "mobx-react"; +import { createRoot } from "react-dom/client"; +// types +import { IIssueLabel, InstructionType } from "@plane/types"; +// ui +import { DropIndicator } from "@plane/ui"; +// components +import { LabelName } from "./label-block/label-name"; +import { TargetData, getCanDrop, getInstructionFromPayload } from "./label-utils"; + +type LabelDragPreviewProps = { + label: IIssueLabel; + isGroup: boolean; +}; + +export const LabelDragPreview = (props: LabelDragPreviewProps) => { + const { label, isGroup } = props; + + return ( +
+ +
+ ); +}; + +type Props = { + label: IIssueLabel; + isGroup: boolean; + isChild: boolean; + isLastChild: boolean; + children: ( + isDragging: boolean, + isDroppingInLabel: boolean, + dragHandleRef: MutableRefObject + ) => JSX.Element; + onDrop: ( + draggingLabelId: string, + droppedParentId: string | null, + droppedLabelId: string | undefined, + dropAtEndOfList: boolean + ) => void; +}; + +export const LabelDndHOC = observer((props: Props) => { + const { label, isGroup, isChild, isLastChild, children, onDrop } = props; + + const [isDragging, setIsDragging] = useState(false); + const [instruction, setInstruction] = useState(undefined); + // refs + const labelRef = useRef(null); + const dragHandleRef = useRef(null); + + useEffect(() => { + const element = labelRef.current; + const dragHandleElement = dragHandleRef.current; + + if (!element) return; + + return combine( + draggable({ + element, + dragHandle: dragHandleElement ?? undefined, + getInitialData: () => ({ id: label?.id, parentId: label?.parent, isGroup, isChild }), + onDragStart: () => { + setIsDragging(true); + }, + onDrop: () => { + setIsDragging(false); + }, + onGenerateDragPreview: ({ nativeSetDragImage }) => { + setCustomNativeDragPreview({ + getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }), + render: ({ container }) => { + const root = createRoot(container); + root.render(); + return () => root.unmount(); + }, + nativeSetDragImage, + }); + }, + }), + dropTargetForElements({ + element, + canDrop: ({ source }) => getCanDrop(source, label, isChild), + getData: ({ input, element }) => { + const data = { id: label?.id, parentId: label?.parent, isGroup, isChild }; + + const blockedStates: InstructionType[] = []; + + // if is currently a child then block make-child instruction + if (isChild) blockedStates.push("make-child"); + // if is currently is not a last child then block reorder-below instruction + if (!isLastChild) blockedStates.push("reorder-below"); + + return attachInstruction(data, { + input, + element, + currentLevel: isChild ? 1 : 0, + indentPerLevel: 0, + mode: isLastChild ? "last-in-group" : "standard", + block: blockedStates, + }); + }, + onDrag: ({ self, source, location }) => { + const instruction = getInstructionFromPayload(self, source, location); + setInstruction(instruction); + }, + onDragLeave: () => { + setInstruction(undefined); + }, + onDrop: ({ source, location }) => { + setInstruction(undefined); + + const dropTargets = location?.current?.dropTargets ?? []; + + if (isChild || !dropTargets || dropTargets.length <= 0) return; + + // if the label is dropped on both a child and it's parent at the same time then get only the child's drop target + const dropTarget = + dropTargets.length > 1 ? dropTargets.find((target) => target?.data?.isChild) : dropTargets[0]; + + let parentId: string | null = null, + dropAtEndOfList = false; + + const dropTargetData = dropTarget?.data as TargetData; + + if (!dropTarget || !dropTargetData) return; + + // get possible instructions for the dropTarget + const instruction = getInstructionFromPayload(dropTarget, source, location); + + // if instruction is make child the set parentId as current dropTarget Id or else set it as dropTarget's parentId + parentId = instruction === "make-child" ? dropTargetData.id : dropTargetData.parentId; + // if instruction is any other than make-child, i.e., reorder-above and reorder-below then set the droppedId as dropTarget's id + const droppedLabelId = instruction !== "make-child" ? dropTargetData.id : undefined; + // if instruction is to reorder-below that is enabled only for end of the last items in the list then dropAtEndOfList as true + if (instruction === "reorder-below") dropAtEndOfList = true; + + const sourceData = source.data as TargetData; + if (sourceData.id) onDrop(sourceData.id as string, parentId, droppedLabelId, dropAtEndOfList); + }, + }) + ); + }, [labelRef?.current, dragHandleRef?.current, label, isChild, isGroup, isLastChild, onDrop]); + + const isMakeChild = instruction == "make-child"; + + return ( +
+ + {children(isDragging, isMakeChild, dragHandleRef)} + {isLastChild && } +
+ ); +}); diff --git a/web/components/labels/label-utils.ts b/web/components/labels/label-utils.ts new file mode 100644 index 000000000..1919e53f3 --- /dev/null +++ b/web/components/labels/label-utils.ts @@ -0,0 +1,67 @@ +import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; +import { IIssueLabel, IPragmaticPayloadLocation, InstructionType, TDropTarget } from "@plane/types"; + +export type TargetData = { + id: string; + parentId: string | null; + isGroup: boolean; + isChild: boolean; +}; + +/** + * extracts the Payload and translates the instruction for the current dropTarget based on drag and drop payload + * @param dropTarget dropTarget for which the instruction is required + * @param source the dragging label data that is being dragged on the dropTarget + * @param location location includes the data of all the dropTargets the source is being dragged on + * @returns Instruction for dropTarget + */ +export const getInstructionFromPayload = ( + dropTarget: TDropTarget, + source: TDropTarget, + location: IPragmaticPayloadLocation +): InstructionType | undefined => { + const dropTargetData = dropTarget?.data as TargetData; + const sourceData = source?.data as TargetData; + + const allDropTargets = location?.current?.dropTargets; + + // if all the dropTargets are greater than 1 meaning the source is being dragged on a group and its child at the same time + // and also if the dropTarget in question is also a group then, it should be a child of the current Droptarget + if (allDropTargets?.length > 1 && dropTargetData?.isGroup) return "make-child"; + + if (!dropTargetData || !sourceData) return undefined; + + let instruction = extractInstruction(dropTargetData)?.type; + + // If the instruction is blocked then set an instruction based on if dropTarget it is a child or not + if (instruction === "instruction-blocked") { + instruction = dropTargetData.isChild ? "reorder-above" : "make-child"; + } + + // if source that is being dragged is a group. A group cannon be a child of any other label, + // hence if current instruction is to be a child of dropTarget then reorder-above instead + if (instruction === "make-child" && sourceData.isGroup) instruction = "reorder-above"; + + return instruction; +}; + +/** + * This provides a boolean to indicate if the label can be dropped onto the droptarget + * @param source + * @param label + * @param isCurrentChild if the dropTarget is a child + * @returns + */ +export const getCanDrop = (source: TDropTarget, label: IIssueLabel | undefined, isCurrentChild: boolean) => { + const sourceData = source?.data; + + if (!sourceData) return false; + + // a label cannot be dropped on to itself and it's parent cannon be dropped on the child + if (sourceData.id === label?.id || sourceData.id === label?.parent) return false; + + // if current dropTarget is a child and the label being dropped is a group then don't enable drop + if (isCurrentChild && sourceData.isGroup) return false; + + return true; +}; diff --git a/web/components/labels/project-setting-label-group.tsx b/web/components/labels/project-setting-label-group.tsx index 0a3efc9df..3302cb6bf 100644 --- a/web/components/labels/project-setting-label-group.tsx +++ b/web/components/labels/project-setting-label-group.tsx @@ -1,51 +1,38 @@ import React, { Dispatch, SetStateAction, useState } from "react"; -import { - Draggable, - DraggableProvided, - DraggableProvidedDragHandleProps, - DraggableStateSnapshot, - Droppable, -} from "@hello-pangea/dnd"; import { observer } from "mobx-react-lite"; import { ChevronDown, Pencil, Trash2 } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; - // store // icons -import { IIssueLabel } from "@plane/types"; // types -import useDraggableInPortal from "@/hooks/use-draggable-portal"; +import { IIssueLabel } from "@plane/types"; +// components import { CreateUpdateLabelInline } from "./create-update-label-inline"; import { ICustomMenuItem, LabelItemBlock } from "./label-block/label-item-block"; +import { LabelDndHOC } from "./label-drag-n-drop-HOC"; import { ProjectSettingLabelItem } from "./project-setting-label-item"; type Props = { label: IIssueLabel; labelChildren: IIssueLabel[]; handleLabelDelete: (label: IIssueLabel) => void; - dragHandleProps: DraggableProvidedDragHandleProps; - draggableSnapshot: DraggableStateSnapshot; isUpdating: boolean; setIsUpdating: Dispatch>; - isDropDisabled: boolean; + isLastChild: boolean; + onDrop: ( + draggingLabelId: string, + droppedParentId: string | null, + droppedLabelId: string | undefined, + dropAtEndOfList: boolean + ) => void; }; export const ProjectSettingLabelGroup: React.FC = observer((props) => { - const { - label, - labelChildren, - handleLabelDelete, - draggableSnapshot: groupDragSnapshot, - dragHandleProps, - isUpdating, - setIsUpdating, - isDropDisabled, - } = props; + const { label, labelChildren, handleLabelDelete, isUpdating, setIsUpdating, isLastChild, onDrop } = props; + // states const [isEditLabelForm, setEditLabelForm] = useState(false); - const renderDraggable = useDraggableInPortal(); - const customMenuItems: ICustomMenuItem[] = [ { CustomIcon: Pencil, @@ -67,101 +54,89 @@ export const ProjectSettingLabelGroup: React.FC = observer((props) => { ]; return ( - - {({ open }) => ( - <> - + {(isDragging, isDroppingInLabel, dragHandleRef) => ( +
+ - {(droppableProvided) => ( -
- <> -
- {isEditLabelForm ? ( - { - setEditLabelForm(false); - setIsUpdating(false); - }} - /> - ) : ( - - )} - - - - ( + <> +
+ <> +
+ {isEditLabelForm ? ( + { + setEditLabelForm(false); + setIsUpdating(false); + }} /> - - -
- - -
- {labelChildren.map((child, index) => ( -
- - {renderDraggable((provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( -
- handleLabelDelete(child)} - draggableSnapshot={snapshot} - dragHandleProps={provided.dragHandleProps!} - setIsUpdating={setIsUpdating} - isChild - /> -
- ))} -
-
- ))} -
-
-
- {droppableProvided.placeholder} - -
+ ) : ( + + )} + + + + + + +
+ + +
+ {labelChildren.map((child, index) => ( +
+
+ handleLabelDelete(child)} + setIsUpdating={setIsUpdating} + isParentDragging={isDragging} + isChild + isLastChild={index === labelChildren.length - 1} + onDrop={onDrop} + /> +
+
+ ))} +
+
+
+ +
+ )} - - +
+
)} -
+ ); }); diff --git a/web/components/labels/project-setting-label-item.tsx b/web/components/labels/project-setting-label-item.tsx index d8bbee719..0a596fe41 100644 --- a/web/components/labels/project-setting-label-item.tsx +++ b/web/components/labels/project-setting-label-item.tsx @@ -1,27 +1,32 @@ import React, { Dispatch, SetStateAction, useState } from "react"; -import { DraggableProvidedDragHandleProps, DraggableStateSnapshot } from "@hello-pangea/dnd"; import { useRouter } from "next/router"; import { X, Pencil } from "lucide-react"; +// types import { IIssueLabel } from "@plane/types"; // hooks import { useLabel } from "@/hooks/store"; -// types // components import { CreateUpdateLabelInline } from "./create-update-label-inline"; import { ICustomMenuItem, LabelItemBlock } from "./label-block/label-item-block"; +import { LabelDndHOC } from "./label-drag-n-drop-HOC"; type Props = { label: IIssueLabel; handleLabelDelete: (label: IIssueLabel) => void; - draggableSnapshot: DraggableStateSnapshot; - dragHandleProps: DraggableProvidedDragHandleProps; setIsUpdating: Dispatch>; + isParentDragging?: boolean; isChild: boolean; + isLastChild: boolean; + onDrop: ( + draggingLabelId: string, + droppedParentId: string | null, + droppedLabelId: string | undefined, + dropAtEndOfList: boolean + ) => void; }; export const ProjectSettingLabelItem: React.FC = (props) => { - const { label, setIsUpdating, handleLabelDelete, draggableSnapshot, dragHandleProps, isChild } = props; - const { combineTargetFor, isDragging } = draggableSnapshot; + const { label, setIsUpdating, handleLabelDelete, isChild, isLastChild, isParentDragging = false, onDrop } = props; // states const [isEditLabelForm, setEditLabelForm] = useState(false); // router @@ -59,31 +64,39 @@ export const ProjectSettingLabelItem: React.FC = (props) => { ]; return ( -
- {isEditLabelForm ? ( - { - setEditLabelForm(false); - setIsUpdating(false); - }} - /> - ) : ( - + + {(isDragging, isDroppingInLabel, dragHandleRef) => ( +
+
+ {isEditLabelForm ? ( + { + setEditLabelForm(false); + setIsUpdating(false); + }} + /> + ) : ( + + )} +
+
)} -
+ ); }; diff --git a/web/components/labels/project-setting-label-list.tsx b/web/components/labels/project-setting-label-list.tsx index bbc4023d1..8de57e8b2 100644 --- a/web/components/labels/project-setting-label-list.tsx +++ b/web/components/labels/project-setting-label-list.tsx @@ -1,12 +1,4 @@ import React, { useState, useRef } from "react"; -import { - DragDropContext, - Draggable, - DraggableProvided, - DraggableStateSnapshot, - DropResult, - Droppable, -} from "@hello-pangea/dnd"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; import { IIssueLabel } from "@plane/types"; @@ -21,20 +13,16 @@ import { } from "@/components/labels"; import { EmptyStateType } from "@/constants/empty-state"; import { useLabel } from "@/hooks/store"; -import useDraggableInPortal from "@/hooks/use-draggable-portal"; // components // ui // types // constants -const LABELS_ROOT = "labels.root"; - export const ProjectSettingsLabelList: React.FC = observer(() => { // states const [showLabelForm, setLabelForm] = useState(false); const [isUpdating, setIsUpdating] = useState(false); const [selectDeleteLabel, setSelectDeleteLabel] = useState(null); - const [isDraggingGroup, setIsDraggingGroup] = useState(false); // refs const scrollToRef = useRef(null); // router @@ -42,44 +30,28 @@ export const ProjectSettingsLabelList: React.FC = observer(() => { const { workspaceSlug, projectId } = router.query; // store hooks const { projectLabels, updateLabelPosition, projectLabelsTree } = useLabel(); - // portal - const renderDraggable = useDraggableInPortal(); const newLabel = () => { setIsUpdating(false); setLabelForm(true); }; - const onDragEnd = (result: DropResult) => { - const { combine, draggableId, destination, source } = result; - - // return if dropped outside the DragDropContext - if (!combine && !destination) return; - - const childLabel = draggableId.split(".")[2]; - let parentLabel: string | undefined | null = destination?.droppableId?.split(".")[3]; - const index = destination?.index || 0; - - const prevParentLabel: string | undefined | null = source?.droppableId?.split(".")[3]; - const prevIndex = source?.index; - - if (combine && combine.draggableId) parentLabel = combine?.draggableId?.split(".")[2]; - - if (destination?.droppableId === LABELS_ROOT) parentLabel = null; - - if (result.reason == "DROP" && childLabel != parentLabel) { - if (workspaceSlug && projectId) { - updateLabelPosition( - workspaceSlug?.toString(), - projectId?.toString(), - childLabel, - parentLabel, - index, - prevParentLabel == parentLabel, - prevIndex - ); - return; - } + const onDrop = ( + draggingLabelId: string, + droppedParentId: string | null, + droppedLabelId: string | undefined, + dropAtEndOfList: boolean + ) => { + if (workspaceSlug && projectId) { + updateLabelPosition( + workspaceSlug?.toString(), + projectId?.toString(), + draggingLabelId, + droppedParentId, + droppedLabelId, + dropAtEndOfList + ); + return; } }; @@ -118,82 +90,35 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
) : ( projectLabelsTree && ( - - - {(droppableProvided, droppableSnapshot) => ( -
- {projectLabelsTree.map((label, index) => { - if (label.children && label.children.length) { - return ( - - {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => { - const isGroup = droppableSnapshot.draggingFromThisWith?.split(".")[3] === "group"; - setIsDraggingGroup(isGroup); - - return ( -
- setSelectDeleteLabel(label)} - draggableSnapshot={snapshot} - isUpdating={isUpdating} - setIsUpdating={setIsUpdating} - /> -
- ); - }} -
- ); - } - return ( - - {renderDraggable((provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( -
- setSelectDeleteLabel(label)} - isChild={false} - /> -
- ))} -
- ); - })} - {droppableProvided.placeholder} -
- )} -
-
+
+ {projectLabelsTree.map((label, index) => { + if (label.children && label.children.length) { + return ( + setSelectDeleteLabel(label)} + isUpdating={isUpdating} + setIsUpdating={setIsUpdating} + isLastChild={index === projectLabelsTree.length - 1} + onDrop={onDrop} + /> + ); + } + return ( + setSelectDeleteLabel(label)} + isChild={false} + isLastChild={index === projectLabelsTree.length - 1} + onDrop={onDrop} + /> + ); + })} +
) ) ) : ( diff --git a/web/helpers/array.helper.ts b/web/helpers/array.helper.ts index 45b0b5739..c2799bfa2 100644 --- a/web/helpers/array.helper.ts +++ b/web/helpers/array.helper.ts @@ -1,4 +1,4 @@ -import { IIssueLabelTree } from "@plane/types"; +import { IIssueLabel, IIssueLabelTree } from "@plane/types"; export const groupBy = (array: any[], key: string) => { const innerKey = key.split("."); // split the key by dot @@ -77,7 +77,7 @@ export const orderGroupedDataByField = (groupedData: GroupedItems, orderBy return groupedData; }; -export const buildTree = (array: any[], parent = null) => { +export const buildTree = (array: IIssueLabel[], parent = null) => { const tree: IIssueLabelTree[] = []; array.forEach((item: any) => { diff --git a/web/package.json b/web/package.json index fbdb9b07a..468664f45 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,7 @@ "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.1.3", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.0.3", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", "@blueprintjs/popover2": "^1.13.3", "@headlessui/react": "^1.7.3", "@hello-pangea/dnd": "^16.3.0", @@ -63,7 +64,6 @@ "uuid": "^9.0.0" }, "devDependencies": { - "prettier": "^3.2.5", "@types/dompurify": "^3.0.5", "@types/js-cookie": "^3.0.2", "@types/lodash": "^4.14.202", @@ -74,6 +74,7 @@ "@types/react-dom": "^18.2.17", "@types/uuid": "^8.3.4", "eslint-config-custom": "*", + "prettier": "^3.2.5", "tailwind-config-custom": "*", "tsconfig": "*", "typescript": "4.7.4" diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/labels.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/labels.tsx index 9f4361c96..cf13b6d25 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/labels.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/labels.tsx @@ -1,4 +1,6 @@ -import { ReactElement } from "react"; +import { ReactElement, useEffect, useRef } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; import { observer } from "mobx-react"; // layouts import { PageHead } from "@/components/core"; @@ -16,10 +18,25 @@ const LabelsSettingsPage: NextPageWithLayout = observer(() => { const { currentProjectDetails } = useProject(); const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Labels` : undefined; + const scrollableContainerRef = useRef(null); + + // Enable Auto Scroll for Labels list + useEffect(() => { + const element = scrollableContainerRef.current; + + if (!element) return; + + return combine( + autoScrollForElements({ + element, + }) + ); + }, [scrollableContainerRef?.current]); + return ( <> -
+
diff --git a/web/store/label.store.ts b/web/store/label.store.ts index b91b9db15..3f343f9a4 100644 --- a/web/store/label.store.ts +++ b/web/store/label.store.ts @@ -36,12 +36,11 @@ export interface ILabelStore { updateLabelPosition: ( workspaceSlug: string, projectId: string, - labelId: string, - parentId: string | null | undefined, - index: number, - isSameParent: boolean, - prevIndex: number | undefined - ) => Promise; + draggingLabelId: string, + droppedParentId: string | null, + droppedLabelId: string | undefined, + dropAtEndOfList: boolean + ) => Promise; deleteLabel: (workspaceSlug: string, projectId: string, labelId: string) => Promise; } @@ -81,8 +80,8 @@ export class LabelStore implements ILabelStore { */ get workspaceLabels() { const currentWorkspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; - const worksapceSlug = this.rootStore.app.router.workspaceSlug || ""; - if (!currentWorkspaceDetails || !this.fetchedMap[worksapceSlug]) return; + const workspaceSlug = this.rootStore.app.router.workspaceSlug || ""; + if (!currentWorkspaceDetails || !this.fetchedMap[workspaceSlug]) return; return sortBy( Object.values(this.labelMap).filter((label) => label.workspace_id === currentWorkspaceDetails.id), "sort_order" @@ -94,8 +93,8 @@ export class LabelStore implements ILabelStore { */ get projectLabels() { const projectId = this.rootStore.app.router.projectId; - const worksapceSlug = this.rootStore.app.router.workspaceSlug || ""; - if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[worksapceSlug])) return; + const workspaceSlug = this.rootStore.app.router.workspaceSlug || ""; + if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return; return sortBy( Object.values(this.labelMap).filter((label) => label.project_id === projectId), "sort_order" @@ -111,8 +110,8 @@ export class LabelStore implements ILabelStore { } getProjectLabels = computedFn((projectId: string | null) => { - const worksapceSlug = this.rootStore.app.router.workspaceSlug || ""; - if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[worksapceSlug])) return; + const workspaceSlug = this.rootStore.app.router.workspaceSlug || ""; + if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return; return sortBy( Object.values(this.labelMap).filter((label) => label.project_id === projectId), "sort_order" @@ -186,7 +185,7 @@ export class LabelStore implements ILabelStore { const originalLabel = this.labelMap[labelId]; try { runInAction(() => { - set(this.labelMap, [labelId], { ...this.labelMap[labelId], ...data }); + set(this.labelMap, [labelId], { ...originalLabel, ...data }); }); const response = await this.issueLabelService.patchIssueLabel(workspaceSlug, projectId, labelId, data); return response; @@ -213,50 +212,54 @@ export class LabelStore implements ILabelStore { updateLabelPosition = async ( workspaceSlug: string, projectId: string, - labelId: string, - parentId: string | null | undefined, - index: number, - isSameParent: boolean, - prevIndex: number | undefined + draggingLabelId: string, + droppedParentId: string | null, + droppedLabelId: string | undefined, + dropAtEndOfList: boolean ) => { - const currLabel = this.labelMap?.[labelId]; + const currLabel = this.labelMap?.[draggingLabelId]; const labelTree = this.projectLabelsTree; let currentArray: IIssueLabel[]; if (!currLabel || !labelTree) return; - const data: Partial = { parent: parentId }; - //find array in which the label is to be added - if (!parentId) currentArray = labelTree; - else currentArray = labelTree?.find((label) => label.id === parentId)?.children || []; + //If its is dropped in the same parent then, there is not specific label on which it is mentioned then keep it's original position + if (currLabel.parent === droppedParentId && !droppedLabelId) return; - //Add the array at the destination - if (isSameParent && prevIndex !== undefined) currentArray.splice(prevIndex, 1); - currentArray.splice(index, 0, currLabel); + const data: Partial = { parent: droppedParentId }; + + // find array in which the label is to be added + if (!droppedParentId) currentArray = labelTree; + else currentArray = labelTree?.find((label) => label.id === droppedParentId)?.children || []; + + let droppedLabelIndex = currentArray.findIndex((label) => label.id === droppedLabelId); + //if the position of droppedLabelId cannot be determined then drop it at the end of the list + if (dropAtEndOfList || droppedLabelIndex === -1) droppedLabelIndex = currentArray.length; //if currently adding to a new array, then let backend assign a sort order - if (currentArray.length > 1) { + if (currentArray.length > 0) { let prevSortOrder: number | undefined, nextSortOrder: number | undefined; - if (typeof currentArray[index - 1] !== "undefined") { - prevSortOrder = currentArray[index - 1].sort_order; + if (typeof currentArray[droppedLabelIndex - 1] !== "undefined") { + prevSortOrder = currentArray[droppedLabelIndex - 1].sort_order; } - if (typeof currentArray[index + 1] !== "undefined") { - nextSortOrder = currentArray[index + 1].sort_order; + if (typeof currentArray[droppedLabelIndex] !== "undefined") { + nextSortOrder = currentArray[droppedLabelIndex].sort_order; } - let sortOrder: number; + let sortOrder: number = 65535; //based on the next and previous labelMap calculate current sort order if (prevSortOrder && nextSortOrder) { sortOrder = (prevSortOrder + nextSortOrder) / 2; } else if (nextSortOrder) { - sortOrder = nextSortOrder + 10000; - } else { - sortOrder = prevSortOrder! / 2; + sortOrder = nextSortOrder / 2; + } else if (prevSortOrder) { + sortOrder = prevSortOrder + 10000; } data.sort_order = sortOrder; } - return this.updateLabel(workspaceSlug, projectId, labelId, data); + + return this.updateLabel(workspaceSlug, projectId, draggingLabelId, data); }; /** diff --git a/yarn.lock b/yarn.lock index 9a3d22cf3..a33c24f88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -37,6 +37,14 @@ "@atlaskit/pragmatic-drag-and-drop" "^1.1.0" "@babel/runtime" "^7.0.0" +"@atlaskit/pragmatic-drag-and-drop-hitbox@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-1.0.3.tgz#fcce42f5e2c5a26007f4422397acad29608d3784" + integrity sha512-/Sbu/HqN2VGLYBhnsG7SbRNg98XKkbF6L7XDdBi+izRybfaK1FeMfodPpm/xnBHPJzwYMdkE0qtLyv6afhgMUA== + dependencies: + "@atlaskit/pragmatic-drag-and-drop" "^1.1.0" + "@babel/runtime" "^7.0.0" + "@atlaskit/pragmatic-drag-and-drop@^1.1.0", "@atlaskit/pragmatic-drag-and-drop@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.1.3.tgz#ecbfa4dcd2f9bf9b87f3d1565cedb2661d1fae0a"