forked from github/plane
[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:
parent
68c870b791
commit
10ed12e589
15
packages/types/src/pragmatic.d.ts
vendored
15
packages/types/src/pragmatic.d.ts
vendored
@ -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";
|
@ -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
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -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";
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
161
web/components/labels/label-drag-n-drop-HOC.tsx
Normal file
161
web/components/labels/label-drag-n-drop-HOC.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
67
web/components/labels/label-utils.ts
Normal file
67
web/components/labels/label-utils.ts
Normal 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;
|
||||||
|
};
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
@ -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) => {
|
||||||
|
@ -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"
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user