[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,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";

View File

@ -3,9 +3,12 @@ import { cn } from "../helpers";
type Props = {
isVisible: boolean;
classNames?: string;
};
export const DropIndicator = (props: Props) => {
const { isVisible, classNames = "" } = props;
return (
<div
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
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";
interface IDragHandle {
isDragging: boolean;
dragHandleProps: DraggableProvidedDragHandleProps;
}
export const DragHandle = (props: IDragHandle) => {
const { isDragging, dragHandleProps } = props;
export const DragHandle = forwardRef<HTMLButtonElement | null, IDragHandle>((props, ref) => {
const { isDragging } = props;
return (
<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"
}`}
{...dragHandleProps}
ref={ref}
>
<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" />
</button>
);
};
});
DragHandle.displayName = "DragHandle";

View File

@ -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<HTMLButtonElement | null>;
}
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 (
<div className="group 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} />
</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 {
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<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) => {
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<Props> = observer((props) => {
];
return (
<Disclosure
as="div"
className={`rounded border-[0.5px] border-custom-border-200 text-custom-text-100 ${
groupDragSnapshot.combineTargetFor ? "bg-custom-background-80" : "bg-custom-background-100"
}`}
defaultOpen
>
{({ open }) => (
<>
<Droppable
key={`label.group.droppable.${label.id}`}
droppableId={`label.group.droppable.${label.id}`}
isDropDisabled={groupDragSnapshot.isDragging || isUpdating || isDropDisabled}
<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
as="div"
className={`rounded text-custom-text-100 ${
!isDroppingInLabel ? "border-[0.5px] border-custom-border-200" : ""
} ${isDragging ? "bg-custom-background-80" : "bg-custom-background-100"}`}
defaultOpen
>
{(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">
{isEditLabelForm ? (
<CreateUpdateLabelInline
labelForm={isEditLabelForm}
setLabelForm={setEditLabelForm}
isUpdating
labelToUpdate={label}
onClose={() => {
setEditLabelForm(false);
setIsUpdating(false);
}}
/>
) : (
<LabelItemBlock
label={label}
isDragging={groupDragSnapshot.isDragging}
customMenuItems={customMenuItems}
dragHandleProps={dragHandleProps}
handleLabelDelete={handleLabelDelete}
isLabelGroup
/>
)}
<Disclosure.Button>
<span>
<ChevronDown
className={`h-4 w-4 text-custom-sidebar-text-400 ${!open ? "rotate-90 transform" : ""}`}
{({ open }) => (
<>
<div className={`py-3 pl-1 pr-3 ${!isUpdating && "max-h-full overflow-y-hidden"}`}>
<>
<div className="relative flex cursor-pointer items-center justify-between gap-2">
{isEditLabelForm ? (
<CreateUpdateLabelInline
labelForm={isEditLabelForm}
setLabelForm={setEditLabelForm}
isUpdating
labelToUpdate={label}
onClose={() => {
setEditLabelForm(false);
setIsUpdating(false);
}}
/>
</span>
</Disclosure.Button>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="ml-6 mt-2.5">
{labelChildren.map((child, index) => (
<div key={child.id} className={`group flex w-full items-center text-sm`}>
<Draggable
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
label={child}
handleLabelDelete={() => handleLabelDelete(child)}
draggableSnapshot={snapshot}
dragHandleProps={provided.dragHandleProps!}
setIsUpdating={setIsUpdating}
isChild
/>
</div>
))}
</Draggable>
</div>
))}
</div>
</Disclosure.Panel>
</Transition>
{droppableProvided.placeholder}
</>
</div>
) : (
<LabelItemBlock
label={label}
isDragging={isDragging}
customMenuItems={customMenuItems}
handleLabelDelete={handleLabelDelete}
isLabelGroup
dragHandleRef={dragHandleRef}
/>
)}
<Disclosure.Button>
<span>
<ChevronDown
className={`h-4 w-4 text-custom-sidebar-text-400 ${!open ? "rotate-90 transform" : ""}`}
/>
</span>
</Disclosure.Button>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="ml-6">
{labelChildren.map((child, index) => (
<div key={child.id} className={`group flex w-full items-center text-sm`}>
<div className="w-full">
<ProjectSettingLabelItem
label={child}
handleLabelDelete={() => handleLabelDelete(child)}
setIsUpdating={setIsUpdating}
isParentDragging={isDragging}
isChild
isLastChild={index === labelChildren.length - 1}
onDrop={onDrop}
/>
</div>
</div>
))}
</div>
</Disclosure.Panel>
</Transition>
</>
</div>
</>
)}
</Droppable>
</>
</Disclosure>
</div>
)}
</Disclosure>
</LabelDndHOC>
);
});

View File

@ -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<SetStateAction<boolean>>;
isParentDragging?: boolean;
isChild: boolean;
isLastChild: boolean;
onDrop: (
draggingLabelId: string,
droppedParentId: string | null,
droppedLabelId: string | undefined,
dropAtEndOfList: boolean
) => void;
};
export const ProjectSettingLabelItem: React.FC<Props> = (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> = (props) => {
];
return (
<div
className={`group relative flex items-center justify-between gap-2 space-y-3 rounded border-[0.5px] border-custom-border-200 ${
!isChild && combineTargetFor ? "bg-custom-background-80" : ""
} ${isDragging ? "bg-custom-background-80 shadow-custom-shadow-xs" : ""} bg-custom-background-100 px-1 py-2.5`}
>
{isEditLabelForm ? (
<CreateUpdateLabelInline
labelForm={isEditLabelForm}
setLabelForm={setEditLabelForm}
isUpdating
labelToUpdate={label}
onClose={() => {
setEditLabelForm(false);
setIsUpdating(false);
}}
/>
) : (
<LabelItemBlock
label={label}
isDragging={isDragging}
customMenuItems={customMenuItems}
dragHandleProps={dragHandleProps}
handleLabelDelete={handleLabelDelete}
/>
<LabelDndHOC label={label} isGroup={false} isChild={isChild} isLastChild={isLastChild} onDrop={onDrop}>
{(isDragging, isDroppingInLabel, dragHandleRef) => (
<div
className={`rounded ${isDroppingInLabel ? "border-[2px] border-custom-primary-100" : "border-[1.5px] border-transparent"}`}
>
<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 ? (
<CreateUpdateLabelInline
labelForm={isEditLabelForm}
setLabelForm={setEditLabelForm}
isUpdating
labelToUpdate={label}
onClose={() => {
setEditLabelForm(false);
setIsUpdating(false);
}}
/>
) : (
<LabelItemBlock
label={label}
isDragging={isDragging}
customMenuItems={customMenuItems}
handleLabelDelete={handleLabelDelete}
dragHandleRef={dragHandleRef}
/>
)}
</div>
</div>
)}
</div>
</LabelDndHOC>
);
};

View File

@ -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<IIssueLabel | null>(null);
const [isDraggingGroup, setIsDraggingGroup] = useState(false);
// refs
const scrollToRef = useRef<HTMLFormElement>(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(() => {
</div>
) : (
projectLabelsTree && (
<DragDropContext
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) => {
if (label.children && label.children.length) {
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
key={label.id}
label={label}
labelChildren={label.children || []}
isDropDisabled={isGroup}
dragHandleProps={provided.dragHandleProps!}
handleLabelDelete={(label: IIssueLabel) => setSelectDeleteLabel(label)}
draggableSnapshot={snapshot}
isUpdating={isUpdating}
setIsUpdating={setIsUpdating}
/>
</div>
);
}}
</Draggable>
);
}
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
dragHandleProps={provided.dragHandleProps!}
draggableSnapshot={snapshot}
label={label}
setIsUpdating={setIsUpdating}
handleLabelDelete={(label) => setSelectDeleteLabel(label)}
isChild={false}
/>
</div>
))}
</Draggable>
);
})}
{droppableProvided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<div className="mt-3">
{projectLabelsTree.map((label, index) => {
if (label.children && label.children.length) {
return (
<ProjectSettingLabelGroup
key={label.id}
label={label}
labelChildren={label.children || []}
handleLabelDelete={(label: IIssueLabel) => setSelectDeleteLabel(label)}
isUpdating={isUpdating}
setIsUpdating={setIsUpdating}
isLastChild={index === projectLabelsTree.length - 1}
onDrop={onDrop}
/>
);
}
return (
<ProjectSettingLabelItem
label={label}
key={label.id}
setIsUpdating={setIsUpdating}
handleLabelDelete={(label) => setSelectDeleteLabel(label)}
isChild={false}
isLastChild={index === projectLabelsTree.length - 1}
onDrop={onDrop}
/>
);
})}
</div>
)
)
) : (

View File

@ -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 = <T>(groupedData: GroupedItems<T>, orderBy
return groupedData;
};
export const buildTree = (array: any[], parent = null) => {
export const buildTree = (array: IIssueLabel[], parent = null) => {
const tree: IIssueLabelTree[] = [];
array.forEach((item: any) => {

View File

@ -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"

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";
// 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<HTMLDivElement | null>(null);
// Enable Auto Scroll for Labels list
useEffect(() => {
const element = scrollableContainerRef.current;
if (!element) return;
return combine(
autoScrollForElements({
element,
})
);
}, [scrollableContainerRef?.current]);
return (
<>
<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 />
</div>
</>

View File

@ -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<IIssueLabel | undefined>;
draggingLabelId: string,
droppedParentId: string | null,
droppedLabelId: string | undefined,
dropAtEndOfList: boolean
) => Promise<void>;
deleteLabel: (workspaceSlug: string, projectId: string, labelId: string) => Promise<void>;
}
@ -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<IIssueLabel> = { 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<IIssueLabel> = { 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);
};
/**

View File

@ -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"