From afc2ca65cf17062e55e64173823a1b6d47532096 Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Tue, 21 May 2024 16:25:57 +0530 Subject: [PATCH] [WEB-1138] feat: List lssue Layout Drag and Drop (#4536) * List Dnd Complete feature * fix minor bugs in list dnd * remove double overlay in kanban post refactor * add missing dependencies to useEffects * make provision to add to the last issue of the group * show current child issues to also be disabled if the parent issue is being dragged * fix last issue border * fix code static analysis suggestions * prevent context menu on drag handle --- .../ui/src}/drag-handle.tsx | 14 +- packages/ui/src/index.ts | 1 + .../issue-layouts/group-drag-overlay.tsx | 61 +++++ .../issue-layouts/kanban/base-kanban-root.tsx | 96 +------ .../issues/issue-layouts/kanban/block.tsx | 4 +- .../issue-layouts/kanban/kanban-group.tsx | 71 ++--- .../issue-layouts/list/base-list-root.tsx | 14 +- .../issues/issue-layouts/list/block-root.tsx | 86 ++++++- .../issues/issue-layouts/list/block.tsx | 59 ++++- .../issues/issue-layouts/list/blocks-list.tsx | 34 ++- .../issues/issue-layouts/list/default.tsx | 132 +++++----- .../issues/issue-layouts/list/list-group.tsx | 242 ++++++++++++++++++ web/components/issues/issue-layouts/utils.tsx | 28 +- .../labels/label-block/label-item-block.tsx | 3 +- web/constants/issue.ts | 10 + web/hooks/use-group-dragndrop.ts | 124 +++++++++ web/store/issue/archived/issue.store.ts | 21 ++ web/store/issue/issue_kanban_view.store.ts | 3 +- 18 files changed, 751 insertions(+), 252 deletions(-) rename {web/components/labels/label-block => packages/ui/src}/drag-handle.tsx (57%) create mode 100644 web/components/issues/issue-layouts/group-drag-overlay.tsx create mode 100644 web/components/issues/issue-layouts/list/list-group.tsx create mode 100644 web/hooks/use-group-dragndrop.ts diff --git a/web/components/labels/label-block/drag-handle.tsx b/packages/ui/src/drag-handle.tsx similarity index 57% rename from web/components/labels/label-block/drag-handle.tsx rename to packages/ui/src/drag-handle.tsx index 64aaa075f..d04f7929f 100644 --- a/web/components/labels/label-block/drag-handle.tsx +++ b/packages/ui/src/drag-handle.tsx @@ -1,19 +1,29 @@ +import React from "react"; import { forwardRef } from "react"; import { MoreVertical } from "lucide-react"; interface IDragHandle { isDragging: boolean; + disabled?: boolean; } export const DragHandle = forwardRef((props, ref) => { - const { isDragging } = props; + const { isDragging, disabled = false } = props; + + if (disabled) { + return
; + } return (
); diff --git a/web/components/issues/issue-layouts/list/block-root.tsx b/web/components/issues/issue-layouts/list/block-root.tsx index 42f1d58e3..3832ab7a6 100644 --- a/web/components/issues/issue-layouts/list/block-root.tsx +++ b/web/components/issues/issue-layouts/list/block-root.tsx @@ -1,12 +1,18 @@ -import React, { FC, MutableRefObject, useState } from "react"; +import React, { FC, MutableRefObject, useEffect, useRef, useState } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; import { observer } from "mobx-react"; import { IIssueDisplayProperties, TIssue, TIssueMap } from "@plane/types"; // components +import { DropIndicator } from "@plane/ui"; import RenderIfVisible from "@/components/core/render-if-visible-HOC"; import { IssueBlock } from "@/components/issues/issue-layouts/list"; // hooks import { useIssueDetail } from "@/hooks/store"; +import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; // types +import { HIGHLIGHT_CLASS, getIssueBlockId } from "../utils"; import { TRenderQuickActions } from "./list-view-types"; type Props = { @@ -20,6 +26,11 @@ type Props = { nestingLevel: number; spacingLeft?: number; containerRef: MutableRefObject; + groupId: string; + isDragAllowed: boolean; + canDropOverIssue: boolean; + isParentIssueBeingDragged?: boolean; + isLastChild?: boolean; }; export const IssueBlockRoot: FC = observer((props) => { @@ -27,6 +38,7 @@ export const IssueBlockRoot: FC = observer((props) => { issueIds, issueId, issuesMap, + groupId, updateIssue, quickActions, canEditProperties, @@ -34,26 +46,84 @@ export const IssueBlockRoot: FC = observer((props) => { nestingLevel, spacingLeft = 14, containerRef, + isDragAllowed, + canDropOverIssue, + isParentIssueBeingDragged = false, + isLastChild = false, } = props; // states const [isExpanded, setExpanded] = useState(false); + const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined); + const [isCurrentBlockDragging, setIsCurrentBlockDragging] = useState(false); + // ref + const issueBlockRef = useRef(null); // store hooks const { subIssues: subIssuesStore } = useIssueDetail(); + const isSubIssue = nestingLevel !== 0; + + useEffect(() => { + const blockElement = issueBlockRef.current; + + if (!blockElement) return; + + return combine( + dropTargetForElements({ + element: blockElement, + canDrop: ({ source }) => source?.data?.id !== issueId && !isSubIssue && canDropOverIssue, + getData: ({ input, element }) => { + const data = { id: issueId, type: "ISSUE" }; + + // 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: () => { + setInstruction(undefined); + }, + }) + ); + }, [issueId, isLastChild, issueBlockRef, isSubIssue, canDropOverIssue, setInstruction]); + + useOutsideClickDetector(issueBlockRef, () => { + issueBlockRef?.current?.classList?.remove(HIGHLIGHT_CLASS); + }); + if (!issueId) return null; const subIssues = subIssuesStore.subIssuesByIssueId(issueId); return ( - <> +
+ = observer((props) => { setExpanded={setExpanded} nestingLevel={nestingLevel} spacingLeft={spacingLeft} + canDrag={!isSubIssue && isDragAllowed} + isCurrentBlockDragging={isParentIssueBeingDragged || isCurrentBlockDragging} + setIsCurrentBlockDragging={setIsCurrentBlockDragging} /> @@ -81,8 +154,13 @@ export const IssueBlockRoot: FC = observer((props) => { nestingLevel={nestingLevel + 1} spacingLeft={spacingLeft + (displayProperties?.key ? 12 : 0)} containerRef={containerRef} + groupId={groupId} + isDragAllowed={isDragAllowed} + canDropOverIssue={canDropOverIssue} + isParentIssueBeingDragged={isParentIssueBeingDragged || isCurrentBlockDragging} /> ))} - + {isLastChild && } +
); }); diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 0f2d02ff4..d38eb1199 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -1,10 +1,12 @@ -import { Dispatch, MouseEvent, SetStateAction, useRef } from "react"; +import { Dispatch, MouseEvent, SetStateAction, useEffect, useRef } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { observer } from "mobx-react-lite"; import { ChevronRight } from "lucide-react"; // types import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types"; // ui -import { Spinner, Tooltip, ControlLink } from "@plane/ui"; +import { Spinner, Tooltip, ControlLink, DragHandle } from "@plane/ui"; // components import { IssueProperties } from "@/components/issues/issue-layouts/properties"; // helpers @@ -18,6 +20,7 @@ import { TRenderQuickActions } from "./list-view-types"; interface IssueBlockProps { issueId: string; issuesMap: TIssueMap; + groupId: string; updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; @@ -26,12 +29,16 @@ interface IssueBlockProps { spacingLeft?: number; isExpanded: boolean; setExpanded: Dispatch>; + isCurrentBlockDragging: boolean; + setIsCurrentBlockDragging: React.Dispatch>; + canDrag: boolean; } -export const IssueBlock: React.FC = observer((props: IssueBlockProps) => { +export const IssueBlock = observer((props: IssueBlockProps) => { const { issuesMap, issueId, + groupId, updateIssue, quickActions, displayProperties, @@ -40,9 +47,13 @@ export const IssueBlock: React.FC = observer((props: IssueBlock spacingLeft = 14, isExpanded, setExpanded, + isCurrentBlockDragging, + setIsCurrentBlockDragging, + canDrag, } = props; - // refs - const parentRef = useRef(null); + // ref + const issueRef = useRef(null); + const dragHandleRef = useRef(null); // hooks const { workspaceSlug } = useAppRouter(); const { getProjectIdentifierById } = useProject(); @@ -59,6 +70,29 @@ export const IssueBlock: React.FC = observer((props: IssueBlock const issue = issuesMap[issueId]; const subIssues = subIssuesStore.subIssuesByIssueId(issueId); const { isMobile } = usePlatformOS(); + + useEffect(() => { + const element = issueRef.current; + const dragHandleElement = dragHandleRef.current; + + if (!element || !dragHandleElement) return; + + return combine( + draggable({ + element, + dragHandle: dragHandleElement, + canDrag: () => canDrag, + getInitialData: () => ({ id: issueId, type: "ISSUE", groupId }), + onDragStart: () => { + setIsCurrentBlockDragging(true); + }, + onDrop: () => { + setIsCurrentBlockDragging(false); + }, + }) + ); + }, [issueRef?.current, canDrag, issueId, groupId, dragHandleRef?.current, setIsCurrentBlockDragging]); + if (!issue) return null; const canEditIssueProperties = canEditProperties(issue.project_id); @@ -84,13 +118,14 @@ export const IssueBlock: React.FC = observer((props: IssueBlock return (
@@ -98,11 +133,11 @@ export const IssueBlock: React.FC = observer((props: IssueBlock
- -
+ +
{subIssuesCount > 0 && (