diff --git a/web/components/project/sidebar-list-item.tsx b/web/components/project/sidebar-list-item.tsx index 5dcc31c3f..4d45a515f 100644 --- a/web/components/project/sidebar-list-item.tsx +++ b/web/components/project/sidebar-list-item.tsx @@ -1,8 +1,13 @@ -import { useRef, useState } from "react"; -import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; +import { 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, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; +import { createRoot } from "react-dom/client"; import { MoreVertical, PenSquare, @@ -28,6 +33,7 @@ import { ContrastIcon, LayersIcon, setPromiseToast, + DropIndicator, } from "@plane/ui"; import { LeaveProjectModal, ProjectLogo, PublishProjectModal } from "@/components/project"; import { EUserProjectRoles } from "@/constants/project"; @@ -36,17 +42,23 @@ import { cn } from "@/helpers/common.helper"; import { useAppTheme, useEventTracker, useProject } from "@/hooks/store"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; import { usePlatformOS } from "@/hooks/use-platform-os"; +import { HIGHLIGHT_CLASS, highlightIssueOnDrop } from "../issues/issue-layouts/utils"; // helpers // components type Props = { projectId: string; - provided?: DraggableProvided; - snapshot?: DraggableStateSnapshot; handleCopyText: () => void; - shortContextMenu?: boolean; + handleOnProjectDrop?: ( + sourceId: string | undefined, + destinationId: string | undefined, + shouldDropAtEnd: boolean + ) => void; + projectListType: "JOINED" | "FAVORITES"; disableDrag?: boolean; + disableDrop?: boolean; + isLastChild: boolean; }; const navigation = (workspaceSlug: string, projectId: string) => [ @@ -89,7 +101,8 @@ const navigation = (workspaceSlug: string, projectId: string) => [ export const ProjectSidebarListItem: React.FC = observer((props) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { projectId, provided, snapshot, handleCopyText, shortContextMenu = false, disableDrag } = props; + const { projectId, handleCopyText, disableDrag, disableDrop, isLastChild, handleOnProjectDrop, projectListType } = + props; // store hooks const { sidebarCollapsed: isCollapsed, toggleSidebar } = useAppTheme(); const { setTrackElement } = useEventTracker(); @@ -99,8 +112,12 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false); const [publishModalOpen, setPublishModal] = useState(false); const [isMenuActive, setIsMenuActive] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined); // refs const actionSectionRef = useRef(null); + const projectRef = useRef(null); + const dragHandleRef = useRef(null); // router const router = useRouter(); const { workspaceSlug, projectId: URLProjectId } = router.query; @@ -160,7 +177,97 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { } }; + useEffect(() => { + const element = projectRef.current; + const dragHandleElement = dragHandleRef.current; + + if (!element) return; + + return combine( + draggable({ + element, + canDrag: () => !disableDrag, + dragHandle: dragHandleElement ?? undefined, + getInitialData: () => ({ id: projectId, dragInstanceId: "PROJECTS" }), + onDragStart: () => { + setIsDragging(true); + }, + onDrop: () => { + setIsDragging(false); + }, + onGenerateDragPreview: ({ nativeSetDragImage }) => { + // Add a custom drag image + setCustomNativeDragPreview({ + getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }), + render: ({ container }) => { + const root = createRoot(container); + root.render( +
+
+ {project && } +
+

{project?.name}

+
+ ); + return () => root.unmount(); + }, + nativeSetDragImage, + }); + }, + }), + dropTargetForElements({ + element, + canDrop: ({ source }) => + !disableDrop && source?.data?.id !== projectId && source?.data?.dragInstanceId === "PROJECTS", + getData: ({ input, element }) => { + const data = { id: projectId }; + + // attach instruction for last in list + return attachInstruction(data, { + input, + element, + currentLevel: 0, + indentPerLevel: 0, + mode: isLastChild ? "last-in-group" : "standard", + }); + }, + onDrag: ({ self }) => { + const extractedInstruction = extractInstruction(self?.data)?.type; + // check if the highlight is to be shown above or below + setInstruction( + extractedInstruction + ? extractedInstruction === "reorder-below" && isLastChild + ? "DRAG_BELOW" + : "DRAG_OVER" + : undefined + ); + }, + onDragLeave: () => { + setInstruction(undefined); + }, + onDrop: ({ self, source }) => { + setInstruction(undefined); + const extractedInstruction = extractInstruction(self?.data)?.type; + const currentInstruction = extractedInstruction + ? extractedInstruction === "reorder-below" && isLastChild + ? "DRAG_BELOW" + : "DRAG_OVER" + : undefined; + if (!currentInstruction) return; + + const sourceId = source?.data?.id as string | undefined; + const destinationId = self?.data?.id as string | undefined; + + handleOnProjectDrop && handleOnProjectDrop(sourceId, destinationId, currentInstruction === "DRAG_BELOW"); + + highlightIssueOnDrop(`sidebar-${sourceId}-${projectListType}`); + }, + }) + ); + }, [projectRef?.current, dragHandleRef?.current, projectId, isLastChild, projectListType, handleOnProjectDrop]); + useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); + useOutsideClickDetector(projectRef, () => projectRef?.current?.classList?.remove(HIGHLIGHT_CLASS)); if (!project) return null; @@ -168,48 +275,47 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { <> setPublishModal(false)} /> - + {({ open }) => ( - <> +
+
- {provided && !disableDrag && ( + {!disableDrag && ( )} - + = observer((props) => { )} >
-
+
{!isCollapsed &&

{project.name}

} @@ -380,7 +486,8 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { })} - + {isLastChild && } +
)} diff --git a/web/components/project/sidebar-list.tsx b/web/components/project/sidebar-list.tsx index ed1c90692..b6eb0c708 100644 --- a/web/components/project/sidebar-list.tsx +++ b/web/components/project/sidebar-list.tsx @@ -1,5 +1,6 @@ import { useState, FC, useRef, useEffect } from "react"; -import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd"; +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 { useRouter } from "next/router"; import { ChevronDown, ChevronRight, Plus } from "lucide-react"; @@ -54,21 +55,28 @@ export const ProjectSidebarList: FC = observer(() => { }); }; - const onDragEnd = (result: DropResult) => { - const { source, destination, draggableId } = result; - if (!destination || !workspaceSlug) return; - if (source.index === destination.index) return; + const handleOnProjectDrop = ( + sourceId: string | undefined, + destinationId: string | undefined, + shouldDropAtEnd: boolean + ) => { + if (!sourceId || !destinationId || !workspaceSlug) return; + if (sourceId === destinationId) return; const joinedProjectsList: IProject[] = []; joinedProjects.map((projectId) => { const projectDetails = getProjectById(projectId); if (projectDetails) joinedProjectsList.push(projectDetails); }); + + const sourceIndex = joinedProjects.indexOf(sourceId); + const destinationIndex = shouldDropAtEnd ? joinedProjects.length : joinedProjects.indexOf(destinationId); + if (joinedProjectsList.length <= 0) return; - const updatedSortOrder = orderJoinedProjects(source.index, destination.index, draggableId, joinedProjectsList); + const updatedSortOrder = orderJoinedProjects(sourceIndex, destinationIndex, sourceId, joinedProjectsList); if (updatedSortOrder != undefined) - updateProjectView(workspaceSlug.toString(), draggableId, { sort_order: updatedSortOrder }).catch(() => { + updateProjectView(workspaceSlug.toString(), sourceId, { sort_order: updatedSortOrder }).catch(() => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", @@ -98,7 +106,21 @@ export const ProjectSidebarList: FC = observer(() => { currentContainerRef.removeEventListener("scroll", handleScroll); } }; - }, []); + }, [containerRef]); + + useEffect(() => { + const element = containerRef.current; + + if (!element) return; + + return combine( + autoScrollForElements({ + element, + canScroll: ({ source }) => source?.data?.dragInstanceId === "PROJECTS", + getAllowedAxis: () => "vertical", + }) + ); + }, [containerRef]); return ( <> @@ -123,156 +145,117 @@ export const ProjectSidebarList: FC = observer(() => { } )} > - - - {(provided) => ( -
- {favoriteProjects && favoriteProjects.length > 0 && ( - - {({ open }) => ( - <> - {!isCollapsed && ( -
- - Favorites - {open ? ( - - ) : ( - - )} - - {isAuthorizedUser && ( - - )} -
- )} - + {favoriteProjects && favoriteProjects.length > 0 && ( + + {({ open }) => ( + <> + {!isCollapsed && ( +
+ + Favorites + {open ? : } + + {isAuthorizedUser && ( +
- )} - - - - - {(provided) => ( -
- {joinedProjects && joinedProjects.length > 0 && ( - - {({ open }) => ( - <> - {!isCollapsed && ( -
- - Your projects - {open ? ( - - ) : ( - - )} - - {isAuthorizedUser && ( - - )} -
- )} - + + )} +
+ )} + + + {favoriteProjects.map((projectId, index) => ( + handleCopyText(projectId)} + projectListType="FAVORITES" + disableDrag + disableDrop + isLastChild={index === favoriteProjects.length - 1} + /> + ))} + + + + )} +
+ )} +
+
+ {joinedProjects && joinedProjects.length > 0 && ( + + {({ open }) => ( + <> + {!isCollapsed && ( +
+ + Your projects + {open ? : } + + {isAuthorizedUser && ( +
- )} - - + + + )} +
+ )} + + + {joinedProjects.map((projectId, index) => ( + handleCopyText(projectId)} + isLastChild={index === joinedProjects.length - 1} + handleOnProjectDrop={handleOnProjectDrop} + /> + ))} + + + + )} + + )} +
{isAuthorizedUser && joinedProjects && joinedProjects.length === 0 && (