[WEB-1005] chore: pragmatic drag n drop implementation for labels (#4223)

* pragmatic drag n drop implementation for labels

* minor code quality improvements
This commit is contained in:
rahulramesha 2024-04-17 18:20:02 +05:30 committed by GitHub
parent 68c870b791
commit 10ed12e589
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 517 additions and 334 deletions

View File

@ -8,8 +8,7 @@ export type TDropTargetMiscellaneousData = {
isActiveDueToStickiness: boolean; isActiveDueToStickiness: boolean;
}; };
export interface IPragmaticDropPayload { export interface IPragmaticPayloadLocation {
location: {
initial: { initial: {
dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[]; dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[];
}; };
@ -19,7 +18,17 @@ export interface IPragmaticDropPayload {
previous: { previous: {
dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[]; dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[];
}; };
}; }
export interface IPragmaticDropPayload {
location: IPragmaticPayloadLocation;
source: TDropTarget; source: TDropTarget;
self: TDropTarget & TDropTargetMiscellaneousData; self: TDropTarget & TDropTargetMiscellaneousData;
} }
export type InstructionType =
| "reparent"
| "reorder-above"
| "reorder-below"
| "make-child"
| "instruction-blocked";

View File

@ -3,9 +3,12 @@ import { cn } from "../helpers";
type Props = { type Props = {
isVisible: boolean; isVisible: boolean;
classNames?: string;
}; };
export const DropIndicator = (props: Props) => { export const DropIndicator = (props: Props) => {
const { isVisible, classNames = "" } = props;
return ( return (
<div <div
className={cn( className={cn(
@ -13,8 +16,9 @@ export const DropIndicator = (props: Props) => {
before:left-0 before:relative before:block before:top-[-2px] before:h-[6px] before:w-[6px] before:rounded 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`, 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
)} )}
/> />
); );

View File

@ -1,24 +1,25 @@
import { DraggableProvidedDragHandleProps } from "@hello-pangea/dnd"; import { forwardRef } from "react";
import { MoreVertical } from "lucide-react"; import { MoreVertical } from "lucide-react";
interface IDragHandle { interface IDragHandle {
isDragging: boolean; isDragging: boolean;
dragHandleProps: DraggableProvidedDragHandleProps;
} }
export const DragHandle = (props: IDragHandle) => { export const DragHandle = forwardRef<HTMLButtonElement | null, IDragHandle>((props, ref) => {
const { isDragging, dragHandleProps } = props; const { isDragging } = props;
return ( return (
<button <button
type="button" type="button"
className={`mr-1 flex flex-shrink-0 rounded text-custom-sidebar-text-200 group-hover:opacity-100 ${ className={`mr-1 flex flex-shrink-0 rounded text-custom-sidebar-text-200 group-hover:opacity-100 cursor-grab ${
isDragging ? "opacity-100" : "opacity-0" isDragging ? "opacity-100" : "opacity-0"
}`} }`}
{...dragHandleProps} ref={ref}
> >
<MoreVertical className="h-3.5 w-3.5 stroke-custom-text-400" /> <MoreVertical className="h-3.5 w-3.5 stroke-custom-text-400" />
<MoreVertical className="-ml-5 h-3.5 w-3.5 stroke-custom-text-400" /> <MoreVertical className="-ml-5 h-3.5 w-3.5 stroke-custom-text-400" />
</button> </button>
); );
}; });
DragHandle.displayName = "DragHandle";

View File

@ -1,5 +1,4 @@
import { useRef, useState } from "react"; import { MutableRefObject, useRef, useState } from "react";
import { DraggableProvidedDragHandleProps } from "@hello-pangea/dnd";
import { LucideIcon, X } from "lucide-react"; import { LucideIcon, X } from "lucide-react";
import { IIssueLabel } from "@plane/types"; import { IIssueLabel } from "@plane/types";
//ui //ui
@ -24,13 +23,13 @@ interface ILabelItemBlock {
label: IIssueLabel; label: IIssueLabel;
isDragging: boolean; isDragging: boolean;
customMenuItems: ICustomMenuItem[]; customMenuItems: ICustomMenuItem[];
dragHandleProps: DraggableProvidedDragHandleProps;
handleLabelDelete: (label: IIssueLabel) => void; handleLabelDelete: (label: IIssueLabel) => void;
isLabelGroup?: boolean; isLabelGroup?: boolean;
dragHandleRef: MutableRefObject<HTMLButtonElement | null>;
} }
export const LabelItemBlock = (props: ILabelItemBlock) => { export const LabelItemBlock = (props: ILabelItemBlock) => {
const { label, isDragging, customMenuItems, dragHandleProps, handleLabelDelete, isLabelGroup } = props; const { label, isDragging, customMenuItems, handleLabelDelete, isLabelGroup, dragHandleRef } = props;
// states // states
const [isMenuActive, setIsMenuActive] = useState(false); const [isMenuActive, setIsMenuActive] = useState(false);
// refs // refs
@ -41,7 +40,7 @@ export const LabelItemBlock = (props: ILabelItemBlock) => {
return ( return (
<div className="group flex items-center"> <div className="group flex items-center">
<div className="flex items-center"> <div className="flex items-center">
<DragHandle isDragging={isDragging} dragHandleProps={dragHandleProps} /> <DragHandle isDragging={isDragging} ref={dragHandleRef} />
<LabelName color={label.color} name={label.name} isGroup={isLabelGroup ?? false} /> <LabelName color={label.color} name={label.name} isGroup={isLabelGroup ?? false} />
</div> </div>

View File

@ -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 (
<div className="py-3 pl-2 pr-4 border-[1px] border-custom-border-200 bg-custom-background-100">
<LabelName name={label.name} color={label.color} isGroup={isGroup} />
</div>
);
};
type Props = {
label: IIssueLabel;
isGroup: boolean;
isChild: boolean;
isLastChild: boolean;
children: (
isDragging: boolean,
isDroppingInLabel: boolean,
dragHandleRef: MutableRefObject<HTMLButtonElement | null>
) => 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<InstructionType | undefined>(undefined);
// refs
const labelRef = useRef<HTMLDivElement | null>(null);
const dragHandleRef = useRef<HTMLButtonElement | null>(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(<LabelDragPreview label={label} isGroup={isGroup} />);
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 (
<div ref={labelRef}>
<DropIndicator classNames="my-1" isVisible={instruction === "reorder-above"} />
{children(isDragging, isMakeChild, dragHandleRef)}
{isLastChild && <DropIndicator classNames="my-1" isVisible={instruction === "reorder-below"} />}
</div>
);
});

View File

@ -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;
};

View File

@ -1,51 +1,38 @@
import React, { Dispatch, SetStateAction, useState } from "react"; import React, { Dispatch, SetStateAction, useState } from "react";
import {
Draggable,
DraggableProvided,
DraggableProvidedDragHandleProps,
DraggableStateSnapshot,
Droppable,
} from "@hello-pangea/dnd";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ChevronDown, Pencil, Trash2 } from "lucide-react"; import { ChevronDown, Pencil, Trash2 } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
// store // store
// icons // icons
import { IIssueLabel } from "@plane/types";
// types // types
import useDraggableInPortal from "@/hooks/use-draggable-portal"; import { IIssueLabel } from "@plane/types";
// components
import { CreateUpdateLabelInline } from "./create-update-label-inline"; import { CreateUpdateLabelInline } from "./create-update-label-inline";
import { ICustomMenuItem, LabelItemBlock } from "./label-block/label-item-block"; import { ICustomMenuItem, LabelItemBlock } from "./label-block/label-item-block";
import { LabelDndHOC } from "./label-drag-n-drop-HOC";
import { ProjectSettingLabelItem } from "./project-setting-label-item"; import { ProjectSettingLabelItem } from "./project-setting-label-item";
type Props = { type Props = {
label: IIssueLabel; label: IIssueLabel;
labelChildren: IIssueLabel[]; labelChildren: IIssueLabel[];
handleLabelDelete: (label: IIssueLabel) => void; handleLabelDelete: (label: IIssueLabel) => void;
dragHandleProps: DraggableProvidedDragHandleProps;
draggableSnapshot: DraggableStateSnapshot;
isUpdating: boolean; isUpdating: boolean;
setIsUpdating: Dispatch<SetStateAction<boolean>>; setIsUpdating: Dispatch<SetStateAction<boolean>>;
isDropDisabled: boolean; isLastChild: boolean;
onDrop: (
draggingLabelId: string,
droppedParentId: string | null,
droppedLabelId: string | undefined,
dropAtEndOfList: boolean
) => void;
}; };
export const ProjectSettingLabelGroup: React.FC<Props> = observer((props) => { export const ProjectSettingLabelGroup: React.FC<Props> = observer((props) => {
const { const { label, labelChildren, handleLabelDelete, isUpdating, setIsUpdating, isLastChild, onDrop } = props;
label,
labelChildren,
handleLabelDelete,
draggableSnapshot: groupDragSnapshot,
dragHandleProps,
isUpdating,
setIsUpdating,
isDropDisabled,
} = props;
// states
const [isEditLabelForm, setEditLabelForm] = useState(false); const [isEditLabelForm, setEditLabelForm] = useState(false);
const renderDraggable = useDraggableInPortal();
const customMenuItems: ICustomMenuItem[] = [ const customMenuItems: ICustomMenuItem[] = [
{ {
CustomIcon: Pencil, CustomIcon: Pencil,
@ -67,26 +54,21 @@ export const ProjectSettingLabelGroup: React.FC<Props> = observer((props) => {
]; ];
return ( return (
<LabelDndHOC label={label} isGroup isChild={false} isLastChild={isLastChild} onDrop={onDrop}>
{(isDragging, isDroppingInLabel, dragHandleRef) => (
<div
className={`rounded ${isDroppingInLabel ? "border-[2px] border-custom-primary-100" : "border-[1.5px] border-transparent"}`}
>
<Disclosure <Disclosure
as="div" as="div"
className={`rounded border-[0.5px] border-custom-border-200 text-custom-text-100 ${ className={`rounded text-custom-text-100 ${
groupDragSnapshot.combineTargetFor ? "bg-custom-background-80" : "bg-custom-background-100" !isDroppingInLabel ? "border-[0.5px] border-custom-border-200" : ""
}`} } ${isDragging ? "bg-custom-background-80" : "bg-custom-background-100"}`}
defaultOpen defaultOpen
> >
{({ open }) => ( {({ open }) => (
<> <>
<Droppable <div className={`py-3 pl-1 pr-3 ${!isUpdating && "max-h-full overflow-y-hidden"}`}>
key={`label.group.droppable.${label.id}`}
droppableId={`label.group.droppable.${label.id}`}
isDropDisabled={groupDragSnapshot.isDragging || isUpdating || isDropDisabled}
>
{(droppableProvided) => (
<div
className={`py-3 pl-1 pr-3 ${!isUpdating && "max-h-full overflow-y-hidden"}`}
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
>
<> <>
<div className="relative flex cursor-pointer items-center justify-between gap-2"> <div className="relative flex cursor-pointer items-center justify-between gap-2">
{isEditLabelForm ? ( {isEditLabelForm ? (
@ -103,11 +85,11 @@ export const ProjectSettingLabelGroup: React.FC<Props> = observer((props) => {
) : ( ) : (
<LabelItemBlock <LabelItemBlock
label={label} label={label}
isDragging={groupDragSnapshot.isDragging} isDragging={isDragging}
customMenuItems={customMenuItems} customMenuItems={customMenuItems}
dragHandleProps={dragHandleProps}
handleLabelDelete={handleLabelDelete} handleLabelDelete={handleLabelDelete}
isLabelGroup isLabelGroup
dragHandleRef={dragHandleRef}
/> />
)} )}
@ -129,39 +111,32 @@ export const ProjectSettingLabelGroup: React.FC<Props> = observer((props) => {
leaveTo="transform opacity-0" leaveTo="transform opacity-0"
> >
<Disclosure.Panel> <Disclosure.Panel>
<div className="ml-6 mt-2.5"> <div className="ml-6">
{labelChildren.map((child, index) => ( {labelChildren.map((child, index) => (
<div key={child.id} className={`group flex w-full items-center text-sm`}> <div key={child.id} className={`group flex w-full items-center text-sm`}>
<Draggable <div className="w-full">
draggableId={`label.draggable.${child.id}`}
index={index}
isDragDisabled={groupDragSnapshot.isDragging || isUpdating}
>
{renderDraggable((provided: DraggableProvided, snapshot: DraggableStateSnapshot) => (
<div className="w-full py-1" ref={provided.innerRef} {...provided.draggableProps}>
<ProjectSettingLabelItem <ProjectSettingLabelItem
label={child} label={child}
handleLabelDelete={() => handleLabelDelete(child)} handleLabelDelete={() => handleLabelDelete(child)}
draggableSnapshot={snapshot}
dragHandleProps={provided.dragHandleProps!}
setIsUpdating={setIsUpdating} setIsUpdating={setIsUpdating}
isParentDragging={isDragging}
isChild isChild
isLastChild={index === labelChildren.length - 1}
onDrop={onDrop}
/> />
</div> </div>
))}
</Draggable>
</div> </div>
))} ))}
</div> </div>
</Disclosure.Panel> </Disclosure.Panel>
</Transition> </Transition>
{droppableProvided.placeholder}
</> </>
</div> </div>
)}
</Droppable>
</> </>
)} )}
</Disclosure> </Disclosure>
</div>
)}
</LabelDndHOC>
); );
}); });

View File

@ -1,27 +1,32 @@
import React, { Dispatch, SetStateAction, useState } from "react"; import React, { Dispatch, SetStateAction, useState } from "react";
import { DraggableProvidedDragHandleProps, DraggableStateSnapshot } from "@hello-pangea/dnd";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { X, Pencil } from "lucide-react"; import { X, Pencil } from "lucide-react";
// types
import { IIssueLabel } from "@plane/types"; import { IIssueLabel } from "@plane/types";
// hooks // hooks
import { useLabel } from "@/hooks/store"; import { useLabel } from "@/hooks/store";
// types
// components // components
import { CreateUpdateLabelInline } from "./create-update-label-inline"; import { CreateUpdateLabelInline } from "./create-update-label-inline";
import { ICustomMenuItem, LabelItemBlock } from "./label-block/label-item-block"; import { ICustomMenuItem, LabelItemBlock } from "./label-block/label-item-block";
import { LabelDndHOC } from "./label-drag-n-drop-HOC";
type Props = { type Props = {
label: IIssueLabel; label: IIssueLabel;
handleLabelDelete: (label: IIssueLabel) => void; handleLabelDelete: (label: IIssueLabel) => void;
draggableSnapshot: DraggableStateSnapshot;
dragHandleProps: DraggableProvidedDragHandleProps;
setIsUpdating: Dispatch<SetStateAction<boolean>>; setIsUpdating: Dispatch<SetStateAction<boolean>>;
isParentDragging?: boolean;
isChild: boolean; isChild: boolean;
isLastChild: boolean;
onDrop: (
draggingLabelId: string,
droppedParentId: string | null,
droppedLabelId: string | undefined,
dropAtEndOfList: boolean
) => void;
}; };
export const ProjectSettingLabelItem: React.FC<Props> = (props) => { export const ProjectSettingLabelItem: React.FC<Props> = (props) => {
const { label, setIsUpdating, handleLabelDelete, draggableSnapshot, dragHandleProps, isChild } = props; const { label, setIsUpdating, handleLabelDelete, isChild, isLastChild, isParentDragging = false, onDrop } = props;
const { combineTargetFor, isDragging } = draggableSnapshot;
// states // states
const [isEditLabelForm, setEditLabelForm] = useState(false); const [isEditLabelForm, setEditLabelForm] = useState(false);
// router // router
@ -59,10 +64,15 @@ export const ProjectSettingLabelItem: React.FC<Props> = (props) => {
]; ];
return ( return (
<LabelDndHOC label={label} isGroup={false} isChild={isChild} isLastChild={isLastChild} onDrop={onDrop}>
{(isDragging, isDroppingInLabel, dragHandleRef) => (
<div <div
className={`group relative flex items-center justify-between gap-2 space-y-3 rounded border-[0.5px] border-custom-border-200 ${ className={`rounded ${isDroppingInLabel ? "border-[2px] border-custom-primary-100" : "border-[1.5px] border-transparent"}`}
!isChild && combineTargetFor ? "bg-custom-background-80" : "" >
} ${isDragging ? "bg-custom-background-80 shadow-custom-shadow-xs" : ""} bg-custom-background-100 px-1 py-2.5`} <div
className={`py-3 px-1 group relative flex items-center justify-between gap-2 space-y-3 rounded ${
isDroppingInLabel ? "" : "border-[0.5px] border-custom-border-200"
} ${isDragging || isParentDragging ? "bg-custom-background-80" : "bg-custom-background-100"}`}
> >
{isEditLabelForm ? ( {isEditLabelForm ? (
<CreateUpdateLabelInline <CreateUpdateLabelInline
@ -80,10 +90,13 @@ export const ProjectSettingLabelItem: React.FC<Props> = (props) => {
label={label} label={label}
isDragging={isDragging} isDragging={isDragging}
customMenuItems={customMenuItems} customMenuItems={customMenuItems}
dragHandleProps={dragHandleProps}
handleLabelDelete={handleLabelDelete} handleLabelDelete={handleLabelDelete}
dragHandleRef={dragHandleRef}
/> />
)} )}
</div> </div>
</div>
)}
</LabelDndHOC>
); );
}; };

View File

@ -1,12 +1,4 @@
import React, { useState, useRef } from "react"; import React, { useState, useRef } from "react";
import {
DragDropContext,
Draggable,
DraggableProvided,
DraggableStateSnapshot,
DropResult,
Droppable,
} from "@hello-pangea/dnd";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { IIssueLabel } from "@plane/types"; import { IIssueLabel } from "@plane/types";
@ -21,20 +13,16 @@ import {
} from "@/components/labels"; } from "@/components/labels";
import { EmptyStateType } from "@/constants/empty-state"; import { EmptyStateType } from "@/constants/empty-state";
import { useLabel } from "@/hooks/store"; import { useLabel } from "@/hooks/store";
import useDraggableInPortal from "@/hooks/use-draggable-portal";
// components // components
// ui // ui
// types // types
// constants // constants
const LABELS_ROOT = "labels.root";
export const ProjectSettingsLabelList: React.FC = observer(() => { export const ProjectSettingsLabelList: React.FC = observer(() => {
// states // states
const [showLabelForm, setLabelForm] = useState(false); const [showLabelForm, setLabelForm] = useState(false);
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [selectDeleteLabel, setSelectDeleteLabel] = useState<IIssueLabel | null>(null); const [selectDeleteLabel, setSelectDeleteLabel] = useState<IIssueLabel | null>(null);
const [isDraggingGroup, setIsDraggingGroup] = useState(false);
// refs // refs
const scrollToRef = useRef<HTMLFormElement>(null); const scrollToRef = useRef<HTMLFormElement>(null);
// router // router
@ -42,45 +30,29 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
// store hooks // store hooks
const { projectLabels, updateLabelPosition, projectLabelsTree } = useLabel(); const { projectLabels, updateLabelPosition, projectLabelsTree } = useLabel();
// portal
const renderDraggable = useDraggableInPortal();
const newLabel = () => { const newLabel = () => {
setIsUpdating(false); setIsUpdating(false);
setLabelForm(true); setLabelForm(true);
}; };
const onDragEnd = (result: DropResult) => { const onDrop = (
const { combine, draggableId, destination, source } = result; draggingLabelId: string,
droppedParentId: string | null,
// return if dropped outside the DragDropContext droppedLabelId: string | undefined,
if (!combine && !destination) return; dropAtEndOfList: boolean
) => {
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) { if (workspaceSlug && projectId) {
updateLabelPosition( updateLabelPosition(
workspaceSlug?.toString(), workspaceSlug?.toString(),
projectId?.toString(), projectId?.toString(),
childLabel, draggingLabelId,
parentLabel, droppedParentId,
index, droppedLabelId,
prevParentLabel == parentLabel, dropAtEndOfList
prevIndex
); );
return; return;
} }
}
}; };
return ( return (
@ -118,82 +90,35 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
</div> </div>
) : ( ) : (
projectLabelsTree && ( projectLabelsTree && (
<DragDropContext <div className="mt-3">
onDragEnd={onDragEnd}
autoScrollerOptions={{
startFromPercentage: 1,
disabled: false,
maxScrollAtPercentage: 0,
maxPixelScroll: 2,
}}
>
<Droppable
droppableId={LABELS_ROOT}
isCombineEnabled={!isDraggingGroup}
ignoreContainerClipping
isDropDisabled={isUpdating}
>
{(droppableProvided, droppableSnapshot) => (
<div className="mt-3" ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
{projectLabelsTree.map((label, index) => { {projectLabelsTree.map((label, index) => {
if (label.children && label.children.length) { if (label.children && label.children.length) {
return ( return (
<Draggable
key={`label.draggable.${label.id}`}
draggableId={`label.draggable.${label.id}.group`}
index={index}
isDragDisabled={isUpdating}
>
{(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => {
const isGroup = droppableSnapshot.draggingFromThisWith?.split(".")[3] === "group";
setIsDraggingGroup(isGroup);
return (
<div ref={provided.innerRef} {...provided.draggableProps} className="mt-3">
<ProjectSettingLabelGroup <ProjectSettingLabelGroup
key={label.id} key={label.id}
label={label} label={label}
labelChildren={label.children || []} labelChildren={label.children || []}
isDropDisabled={isGroup}
dragHandleProps={provided.dragHandleProps!}
handleLabelDelete={(label: IIssueLabel) => setSelectDeleteLabel(label)} handleLabelDelete={(label: IIssueLabel) => setSelectDeleteLabel(label)}
draggableSnapshot={snapshot}
isUpdating={isUpdating} isUpdating={isUpdating}
setIsUpdating={setIsUpdating} setIsUpdating={setIsUpdating}
isLastChild={index === projectLabelsTree.length - 1}
onDrop={onDrop}
/> />
</div>
);
}}
</Draggable>
); );
} }
return ( return (
<Draggable
key={`label.draggable.${label.id}`}
draggableId={`label.draggable.${label.id}`}
index={index}
isDragDisabled={isUpdating}
>
{renderDraggable((provided: DraggableProvided, snapshot: DraggableStateSnapshot) => (
<div ref={provided.innerRef} {...provided.draggableProps} className="mt-3">
<ProjectSettingLabelItem <ProjectSettingLabelItem
dragHandleProps={provided.dragHandleProps!}
draggableSnapshot={snapshot}
label={label} label={label}
key={label.id}
setIsUpdating={setIsUpdating} setIsUpdating={setIsUpdating}
handleLabelDelete={(label) => setSelectDeleteLabel(label)} handleLabelDelete={(label) => setSelectDeleteLabel(label)}
isChild={false} isChild={false}
isLastChild={index === projectLabelsTree.length - 1}
onDrop={onDrop}
/> />
</div>
))}
</Draggable>
); );
})} })}
{droppableProvided.placeholder}
</div> </div>
)}
</Droppable>
</DragDropContext>
) )
) )
) : ( ) : (

View File

@ -1,4 +1,4 @@
import { IIssueLabelTree } from "@plane/types"; import { IIssueLabel, IIssueLabelTree } from "@plane/types";
export const groupBy = (array: any[], key: string) => { export const groupBy = (array: any[], key: string) => {
const innerKey = key.split("."); // split the key by dot const innerKey = key.split("."); // split the key by dot
@ -77,7 +77,7 @@ export const orderGroupedDataByField = <T>(groupedData: GroupedItems<T>, orderBy
return groupedData; return groupedData;
}; };
export const buildTree = (array: any[], parent = null) => { export const buildTree = (array: IIssueLabel[], parent = null) => {
const tree: IIssueLabelTree[] = []; const tree: IIssueLabelTree[] = [];
array.forEach((item: any) => { array.forEach((item: any) => {

View File

@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.1.3", "@atlaskit/pragmatic-drag-and-drop": "^1.1.3",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.0.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", "@blueprintjs/popover2": "^1.13.3",
"@headlessui/react": "^1.7.3", "@headlessui/react": "^1.7.3",
"@hello-pangea/dnd": "^16.3.0", "@hello-pangea/dnd": "^16.3.0",
@ -63,7 +64,6 @@
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.2.5",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/js-cookie": "^3.0.2", "@types/js-cookie": "^3.0.2",
"@types/lodash": "^4.14.202", "@types/lodash": "^4.14.202",
@ -74,6 +74,7 @@
"@types/react-dom": "^18.2.17", "@types/react-dom": "^18.2.17",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"eslint-config-custom": "*", "eslint-config-custom": "*",
"prettier": "^3.2.5",
"tailwind-config-custom": "*", "tailwind-config-custom": "*",
"tsconfig": "*", "tsconfig": "*",
"typescript": "4.7.4" "typescript": "4.7.4"

View File

@ -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"; import { observer } from "mobx-react";
// layouts // layouts
import { PageHead } from "@/components/core"; import { PageHead } from "@/components/core";
@ -16,10 +18,25 @@ const LabelsSettingsPage: NextPageWithLayout = observer(() => {
const { currentProjectDetails } = useProject(); const { currentProjectDetails } = useProject();
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Labels` : undefined; const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Labels` : undefined;
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
// Enable Auto Scroll for Labels list
useEffect(() => {
const element = scrollableContainerRef.current;
if (!element) return;
return combine(
autoScrollForElements({
element,
})
);
}, [scrollableContainerRef?.current]);
return ( return (
<> <>
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
<div className="h-full w-full gap-10 overflow-y-auto py-8 pr-9"> <div ref={scrollableContainerRef} className="h-full w-full gap-10 overflow-y-auto py-8 pr-9">
<ProjectSettingsLabelList /> <ProjectSettingsLabelList />
</div> </div>
</> </>

View File

@ -36,12 +36,11 @@ export interface ILabelStore {
updateLabelPosition: ( updateLabelPosition: (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
labelId: string, draggingLabelId: string,
parentId: string | null | undefined, droppedParentId: string | null,
index: number, droppedLabelId: string | undefined,
isSameParent: boolean, dropAtEndOfList: boolean
prevIndex: number | undefined ) => Promise<void>;
) => Promise<IIssueLabel | undefined>;
deleteLabel: (workspaceSlug: string, projectId: string, labelId: string) => Promise<void>; deleteLabel: (workspaceSlug: string, projectId: string, labelId: string) => Promise<void>;
} }
@ -81,8 +80,8 @@ export class LabelStore implements ILabelStore {
*/ */
get workspaceLabels() { get workspaceLabels() {
const currentWorkspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; const currentWorkspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
const worksapceSlug = this.rootStore.app.router.workspaceSlug || ""; const workspaceSlug = this.rootStore.app.router.workspaceSlug || "";
if (!currentWorkspaceDetails || !this.fetchedMap[worksapceSlug]) return; if (!currentWorkspaceDetails || !this.fetchedMap[workspaceSlug]) return;
return sortBy( return sortBy(
Object.values(this.labelMap).filter((label) => label.workspace_id === currentWorkspaceDetails.id), Object.values(this.labelMap).filter((label) => label.workspace_id === currentWorkspaceDetails.id),
"sort_order" "sort_order"
@ -94,8 +93,8 @@ export class LabelStore implements ILabelStore {
*/ */
get projectLabels() { get projectLabels() {
const projectId = this.rootStore.app.router.projectId; const projectId = this.rootStore.app.router.projectId;
const worksapceSlug = this.rootStore.app.router.workspaceSlug || ""; const workspaceSlug = this.rootStore.app.router.workspaceSlug || "";
if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[worksapceSlug])) return; if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return;
return sortBy( return sortBy(
Object.values(this.labelMap).filter((label) => label.project_id === projectId), Object.values(this.labelMap).filter((label) => label.project_id === projectId),
"sort_order" "sort_order"
@ -111,8 +110,8 @@ export class LabelStore implements ILabelStore {
} }
getProjectLabels = computedFn((projectId: string | null) => { getProjectLabels = computedFn((projectId: string | null) => {
const worksapceSlug = this.rootStore.app.router.workspaceSlug || ""; const workspaceSlug = this.rootStore.app.router.workspaceSlug || "";
if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[worksapceSlug])) return; if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return;
return sortBy( return sortBy(
Object.values(this.labelMap).filter((label) => label.project_id === projectId), Object.values(this.labelMap).filter((label) => label.project_id === projectId),
"sort_order" "sort_order"
@ -186,7 +185,7 @@ export class LabelStore implements ILabelStore {
const originalLabel = this.labelMap[labelId]; const originalLabel = this.labelMap[labelId];
try { try {
runInAction(() => { 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); const response = await this.issueLabelService.patchIssueLabel(workspaceSlug, projectId, labelId, data);
return response; return response;
@ -213,50 +212,54 @@ export class LabelStore implements ILabelStore {
updateLabelPosition = async ( updateLabelPosition = async (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
labelId: string, draggingLabelId: string,
parentId: string | null | undefined, droppedParentId: string | null,
index: number, droppedLabelId: string | undefined,
isSameParent: boolean, dropAtEndOfList: boolean
prevIndex: number | undefined
) => { ) => {
const currLabel = this.labelMap?.[labelId]; const currLabel = this.labelMap?.[draggingLabelId];
const labelTree = this.projectLabelsTree; const labelTree = this.projectLabelsTree;
let currentArray: IIssueLabel[]; let currentArray: IIssueLabel[];
if (!currLabel || !labelTree) return; if (!currLabel || !labelTree) return;
const data: Partial<IIssueLabel> = { parent: parentId }; //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
//find array in which the label is to be added if (currLabel.parent === droppedParentId && !droppedLabelId) return;
if (!parentId) currentArray = labelTree;
else currentArray = labelTree?.find((label) => label.id === parentId)?.children || [];
//Add the array at the destination const data: Partial<IIssueLabel> = { parent: droppedParentId };
if (isSameParent && prevIndex !== undefined) currentArray.splice(prevIndex, 1);
currentArray.splice(index, 0, currLabel); // 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 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; let prevSortOrder: number | undefined, nextSortOrder: number | undefined;
if (typeof currentArray[index - 1] !== "undefined") { if (typeof currentArray[droppedLabelIndex - 1] !== "undefined") {
prevSortOrder = currentArray[index - 1].sort_order; prevSortOrder = currentArray[droppedLabelIndex - 1].sort_order;
} }
if (typeof currentArray[index + 1] !== "undefined") { if (typeof currentArray[droppedLabelIndex] !== "undefined") {
nextSortOrder = currentArray[index + 1].sort_order; nextSortOrder = currentArray[droppedLabelIndex].sort_order;
} }
let sortOrder: number; let sortOrder: number = 65535;
//based on the next and previous labelMap calculate current sort order //based on the next and previous labelMap calculate current sort order
if (prevSortOrder && nextSortOrder) { if (prevSortOrder && nextSortOrder) {
sortOrder = (prevSortOrder + nextSortOrder) / 2; sortOrder = (prevSortOrder + nextSortOrder) / 2;
} else if (nextSortOrder) { } else if (nextSortOrder) {
sortOrder = nextSortOrder + 10000; sortOrder = nextSortOrder / 2;
} else { } else if (prevSortOrder) {
sortOrder = prevSortOrder! / 2; sortOrder = prevSortOrder + 10000;
} }
data.sort_order = sortOrder; data.sort_order = sortOrder;
} }
return this.updateLabel(workspaceSlug, projectId, labelId, data);
return this.updateLabel(workspaceSlug, projectId, draggingLabelId, data);
}; };
/** /**

View File

@ -37,6 +37,14 @@
"@atlaskit/pragmatic-drag-and-drop" "^1.1.0" "@atlaskit/pragmatic-drag-and-drop" "^1.1.0"
"@babel/runtime" "^7.0.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": "@atlaskit/pragmatic-drag-and-drop@^1.1.0", "@atlaskit/pragmatic-drag-and-drop@^1.1.3":
version "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" resolved "https://registry.yarnpkg.com/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.1.3.tgz#ecbfa4dcd2f9bf9b87f3d1565cedb2661d1fae0a"