diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 52ea3771edb..4d98b8f7a24 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -27,3 +27,4 @@ export * from "./api_token"; export * from "./instance"; export * from "./app"; export * from "./common"; +export * from "./pragmatic"; diff --git a/packages/types/src/pragmatic.d.ts b/packages/types/src/pragmatic.d.ts new file mode 100644 index 00000000000..ca47e2d37a7 --- /dev/null +++ b/packages/types/src/pragmatic.d.ts @@ -0,0 +1,25 @@ +export type TDropTarget = { + element: Element; + data: Record<string | symbol, unknown>; +}; + +export type TDropTargetMiscellaneousData = { + dropEffect: string; + isActiveDueToStickiness: boolean; +}; + +export interface IPragmaticDropPayload { + location: { + initial: { + dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[]; + }; + current: { + dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[]; + }; + previous: { + dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[]; + }; + }; + source: TDropTarget; + self: TDropTarget & TDropTargetMiscellaneousData; +} diff --git a/packages/ui/src/drop-indicator.tsx b/packages/ui/src/drop-indicator.tsx new file mode 100644 index 00000000000..228c1a33012 --- /dev/null +++ b/packages/ui/src/drop-indicator.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { cn } from "../helpers"; + +type Props = { + isVisible: boolean; +}; + +export const DropIndicator = (props: Props) => { + return ( + <div + className={cn( + `block relative h-[2px] w-full + 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, + } + )} + /> + ); +}; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 24b76c3e0e4..78013962cc4 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -12,3 +12,4 @@ export * from "./tooltip"; export * from "./loader"; export * from "./control-link"; export * from "./toast"; +export * from "./drop-indicator"; \ No newline at end of file diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 41043085315..be9b94cdc52 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -1,5 +1,7 @@ -import { FC, useCallback, useRef, useState } from "react"; -import { DragDropContext, DragStart, DraggableLocation, DropResult, Droppable } from "@hello-pangea/dnd"; +import { FC, useCallback, 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 { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { TIssue } from "@plane/types"; @@ -9,7 +11,7 @@ import { DeleteIssueModal } from "@/components/issues"; import { ISSUE_DELETED } from "@/constants/event-tracker"; import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; -import { useEventTracker, useIssues, useUser } from "@/hooks/store"; +import { useEventTracker, useIssueDetail, useIssues, useKanbanView, useUser } from "@/hooks/store"; import { useIssuesActions } from "@/hooks/use-issues-actions"; // ui // types @@ -17,7 +19,7 @@ import { IQuickActionProps } from "../list/list-view-types"; //components import { KanBan } from "./default"; import { KanBanSwimLanes } from "./swimlanes"; -import { handleDragDrop } from "./utils"; +import { KanbanDropLocation, handleDragDrop, getSourceFromDropPayload } from "./utils"; export type KanbanStoreType = | EIssuesStoreType.PROJECT @@ -36,12 +38,6 @@ export interface IBaseKanBanLayout { isCompletedCycle?: boolean; } -type KanbanDragState = { - draggedIssueId?: string | null; - source?: DraggableLocation | null; - destination?: DraggableLocation | null; -}; - export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBaseKanBanLayout) => { const { QuickActions, @@ -61,16 +57,24 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas } = useUser(); const { captureIssueEvent } = useEventTracker(); const { issueMap, issuesFilter, issues } = useIssues(storeType); + const { + issue: { getIssueById }, + } = useIssueDetail(); const { updateIssue, removeIssue, removeIssueFromView, archiveIssue, restoreIssue, updateFilters } = useIssuesActions(storeType); + const deleteAreaRef = useRef<HTMLDivElement | null>(null); + const [isDragOverDelete, setIsDragOverDelete] = useState(false); + + const { isDragging } = useKanbanView(); + const issueIds = issues?.groupedIssueIds || []; const displayFilters = issuesFilter?.issueFilters?.displayFilters; const displayProperties = issuesFilter?.issueFilters?.displayProperties; - const sub_group_by: string | null = displayFilters?.sub_group_by || null; - const group_by: string | null = displayFilters?.group_by || null; + const sub_group_by = displayFilters?.sub_group_by; + const group_by = displayFilters?.group_by; const userDisplayFilters = displayFilters || null; @@ -81,8 +85,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas const scrollableContainerRef = useRef<HTMLDivElement | null>(null); // states - const [isDragStarted, setIsDragStarted] = useState<boolean>(false); - const [dragState, setDragState] = useState<KanbanDragState>({}); + const [draggedIssueId, setDraggedIssueId] = useState<string | undefined>(undefined); const [deleteIssueModal, setDeleteIssueModal] = useState(false); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -97,57 +100,72 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas [canEditPropertiesBasedOnProject, enableInlineEditing, isEditingAllowed] ); - const onDragStart = (dragStart: DragStart) => { - setDragState({ - draggedIssueId: dragStart.draggableId.split("__")[0], - }); - setIsDragStarted(true); - }; + // Enable Auto Scroll for Main Kanban + useEffect(() => { + const element = scrollableContainerRef.current; - const onDragEnd = async (result: DropResult) => { - setIsDragStarted(false); + if (!element) return; - if (!result) return; + return combine( + autoScrollForElements({ + element, + }) + ); + }, [scrollableContainerRef?.current]); + // Make the Issue Delete Box a Drop Target + useEffect(() => { + const element = deleteAreaRef.current; + + if (!element) return; + + return combine( + dropTargetForElements({ + element, + getData: () => ({ columnId: "issue-trash-box", groupId: "issue-trash-box", type: "DELETE" }), + onDragEnter: () => { + setIsDragOverDelete(true); + }, + onDragLeave: () => { + setIsDragOverDelete(false); + }, + onDrop: (payload) => { + setIsDragOverDelete(false); + const source = getSourceFromDropPayload(payload); + + if (!source) return; + + setDraggedIssueId(source.id); + setDeleteIssueModal(true); + }, + }) + ); + }, [deleteAreaRef?.current, setIsDragOverDelete, setDraggedIssueId, setDeleteIssueModal]); + + const handleOnDrop = async (source: KanbanDropLocation, destination: KanbanDropLocation) => { if ( - result.destination && - result.source && - result.source.droppableId && - result.destination.droppableId && - result.destination.droppableId === result.source.droppableId && - result.destination.index === result.source.index + source.columnId && + destination.columnId && + destination.columnId === source.columnId && + destination.id === source.id ) return; - if (handleDragDrop) { - if (result.destination?.droppableId && result.destination?.droppableId.split("__")[0] === "issue-trash-box") { - setDragState({ - ...dragState, - source: result.source, - destination: result.destination, - }); - setDeleteIssueModal(true); - } else { - await handleDragDrop( - result.source, - result.destination, - workspaceSlug?.toString(), - projectId?.toString(), - sub_group_by, - group_by, - issueMap, - issueIds, - updateIssue, - removeIssue - ).catch((err) => { - setToast({ - title: "Error", - type: TOAST_TYPE.ERROR, - message: err?.detail ?? "Failed to perform this action", - }); - }); - } - } + await handleDragDrop( + source, + destination, + getIssueById, + issues.getIssueIds, + updateIssue, + group_by, + sub_group_by + ).catch((err) => { + setToast({ + title: "Error", + type: TOAST_TYPE.ERROR, + message: err?.detail ?? "Failed to perform this action", + }); + }); }; const renderQuickActions = useCallback( @@ -168,26 +186,16 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas ); const handleDeleteIssue = async () => { - if (!handleDragDrop || !dragState.draggedIssueId) return; - await handleDragDrop( - dragState.source, - dragState.destination, - workspaceSlug?.toString(), - projectId?.toString(), - sub_group_by, - group_by, - issueMap, - issueIds, - updateIssue, - removeIssue - ).finally(() => { - const draggedIssue = issueMap[dragState.draggedIssueId!]; - removeIssue(draggedIssue.project_id, draggedIssue.id); + const draggedIssue = getIssueById(draggedIssueId ?? ""); + + if (!draggedIssueId || !draggedIssue) return; + + await removeIssue(draggedIssue.project_id, draggedIssueId).finally(() => { setDeleteIssueModal(false); - setDragState({}); + setDraggedIssueId(undefined); captureIssueEvent({ eventName: ISSUE_DELETED, - payload: { id: dragState.draggedIssueId!, state: "FAILED", element: "Kanban layout drag & drop" }, + payload: { id: draggedIssueId, state: "FAILED", element: "Kanban layout drag & drop" }, path: router.asPath, }); }); @@ -209,7 +217,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas return ( <> <DeleteIssueModal - dataId={dragState.draggedIssueId} + dataId={draggedIssueId} isOpen={deleteIssueModal} handleClose={() => setDeleteIssueModal(false)} onSubmit={handleDeleteIssue} @@ -222,58 +230,51 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas )} <div - className="vertical-scrollbar horizontal-scrollbar scrollbar-lg relative flex h-full w-full overflow-auto bg-custom-background-90" + className={`horizontal-scrollbar scrollbar-lg relative flex h-full w-full bg-custom-background-90 ${sub_group_by ? "vertical-scrollbar overflow-y-auto" : "overflow-x-auto overflow-y-hidden"}`} ref={scrollableContainerRef} > - <div className="relative h-max w-max min-w-full bg-custom-background-90 px-2"> - <DragDropContext onDragStart={onDragStart} onDragEnd={onDragEnd}> - {/* drag and delete component */} + <div className="relative h-full w-max min-w-full bg-custom-background-90 px-2"> + {/* drag and delete component */} + <div + className={`fixed left-1/2 -translate-x-1/2 ${ + isDragging ? "z-40" : "" + } top-3 mx-3 flex w-72 items-center justify-center`} + ref={deleteAreaRef} + > <div - className={`fixed left-1/2 -translate-x-1/2 ${ - isDragStarted ? "z-40" : "" - } top-3 mx-3 flex w-72 items-center justify-center`} + className={`${ + isDragging ? `opacity-100` : `opacity-0` + } flex w-full items-center justify-center rounded border-2 border-red-500/20 bg-custom-background-100 px-3 py-5 text-xs font-medium italic text-red-500 ${ + isDragOverDelete ? "bg-red-500 opacity-70 blur-2xl" : "" + } transition duration-300`} > - <Droppable droppableId="issue-trash-box" isDropDisabled={!isDragStarted}> - {(provided, snapshot) => ( - <div - className={`${ - isDragStarted ? `opacity-100` : `opacity-0` - } flex w-full items-center justify-center rounded border-2 border-red-500/20 bg-custom-background-100 px-3 py-5 text-xs font-medium italic text-red-500 ${ - snapshot.isDraggingOver ? "bg-red-500 opacity-70 blur-2xl" : "" - } transition duration-300`} - ref={provided.innerRef} - {...provided.droppableProps} - > - Drop here to delete the issue. - </div> - )} - </Droppable> + Drop here to delete the issue. </div> + </div> - <div className="h-max w-max"> - <KanBanView - issuesMap={issueMap} - issueIds={issueIds} - displayProperties={displayProperties} - sub_group_by={sub_group_by} - group_by={group_by} - updateIssue={updateIssue} - quickActions={renderQuickActions} - handleKanbanFilters={handleKanbanFilters} - kanbanFilters={kanbanFilters} - enableQuickIssueCreate={enableQuickAdd} - showEmptyGroup={userDisplayFilters?.show_empty_groups ?? true} - quickAddCallback={issues?.quickAddIssue} - viewId={viewId} - disableIssueCreation={!enableIssueCreation || !isEditingAllowed || isCompletedCycle} - canEditProperties={canEditProperties} - storeType={storeType} - addIssuesToView={addIssuesToView} - scrollableContainerRef={scrollableContainerRef} - isDragStarted={isDragStarted} - /> - </div> - </DragDropContext> + <div className="h-full w-max"> + <KanBanView + issuesMap={issueMap} + issueIds={issueIds} + displayProperties={displayProperties} + sub_group_by={sub_group_by} + group_by={group_by} + updateIssue={updateIssue} + quickActions={renderQuickActions} + handleKanbanFilters={handleKanbanFilters} + kanbanFilters={kanbanFilters} + enableQuickIssueCreate={enableQuickAdd} + showEmptyGroup={userDisplayFilters?.show_empty_groups ?? true} + quickAddCallback={issues?.quickAddIssue} + viewId={viewId} + disableIssueCreation={!enableIssueCreation || !isEditingAllowed || isCompletedCycle} + canEditProperties={canEditProperties} + storeType={storeType} + addIssuesToView={addIssuesToView} + scrollableContainerRef={scrollableContainerRef} + handleOnDrop={handleOnDrop} + /> + </div> </div> </div> </> diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index e437f1241b1..0b04bb7de59 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -1,12 +1,13 @@ -import { MutableRefObject, memo } from "react"; -import { Draggable, DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; +import { MutableRefObject, memo, 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 { observer } from "mobx-react-lite"; import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; // hooks -import { ControlLink, Tooltip } from "@plane/ui"; +import { ControlLink, DropIndicator, Tooltip } from "@plane/ui"; import RenderIfVisible from "@/components/core/render-if-visible-HOC"; import { cn } from "@/helpers/common.helper"; -import { useApplication, useIssueDetail, useProject } from "@/hooks/store"; +import { useApplication, useIssueDetail, useKanbanView, useProject } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; // components import { IssueProperties } from "../properties/all-properties"; @@ -22,12 +23,10 @@ interface IssueBlockProps { displayProperties: IIssueDisplayProperties | undefined; isDragDisabled: boolean; draggableId: string; - index: number; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; quickActions: (issue: TIssue) => React.ReactNode; canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>; - isDragStarted?: boolean; issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization } @@ -97,16 +96,14 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => { issuesMap, displayProperties, isDragDisabled, - draggableId, - index, updateIssue, quickActions, canEditProperties, scrollableContainerRef, - isDragStarted, issueIds, } = props; + const cardRef = useRef<HTMLDivElement | null>(null); const { router: { workspaceSlug }, } = useApplication(); @@ -122,63 +119,98 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => { const issue = issuesMap[issueId]; + const { setIsDragging: setIsKanbanDragging } = useKanbanView(); + + const [isDraggingOverBlock, setIsDraggingOverBlock] = useState(false); + const [isCurrentBlockDragging, setIsCurrentBlockDragging] = useState(false); + + // Make Issue block both as as Draggable and, + // as a DropTarget for other issues being dragged to get the location of drop + useEffect(() => { + const element = cardRef.current; + + if (!element) return; + + return combine( + draggable({ + element, + canDrag: () => !isDragDisabled, + getInitialData: () => ({ id: issue?.id, type: "ISSUE" }), + onDragStart: () => { + setIsCurrentBlockDragging(true); + setIsKanbanDragging(true); + }, + onDrop: () => { + setIsKanbanDragging(false); + setIsCurrentBlockDragging(false); + }, + }), + dropTargetForElements({ + element, + canDrop: (payload) => payload.source?.data?.id !== issue?.id, + getData: () => ({ id: issue?.id, type: "ISSUE" }), + onDragEnter: () => { + setIsDraggingOverBlock(true); + }, + onDragLeave: () => { + setIsDraggingOverBlock(false); + }, + onDrop: () => { + setIsDraggingOverBlock(false); + }, + }) + ); + }, [cardRef?.current, issue?.id, setIsCurrentBlockDragging, setIsDraggingOverBlock]); + if (!issue) return null; const canEditIssueProperties = canEditProperties(issue.project_id); return ( - <Draggable - key={draggableId} - draggableId={draggableId} - index={index} - isDragDisabled={!canEditIssueProperties || isDragDisabled} - > - {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( - <div - className="group/kanban-block relative p-1.5 hover:cursor-default" - {...provided.draggableProps} - {...provided.dragHandleProps} - ref={provided.innerRef} + <> + <DropIndicator isVisible={!isCurrentBlockDragging && isDraggingOverBlock} /> + <div + // make Z-index higher at the beginning of drag, to have a issue drag image of issue block without any overlaps + className={cn("group/kanban-block relative p-1.5", { "z-[1]": isCurrentBlockDragging })} + onDragStart={() => !isDragDisabled && setIsCurrentBlockDragging(true)} + > + <ControlLink + id={`issue-${issue.id}`} + href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${ + issue.id + }`} + target="_blank" + onClick={() => handleIssuePeekOverview(issue)} + disabled={!!issue?.tempId} > - <ControlLink - id={`issue-${issue.id}`} - href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${ - issue.id - }`} - target="_blank" - onClick={() => handleIssuePeekOverview(issue)} - disabled={!!issue?.tempId} + <div + className={cn( + "rounded border-[0.5px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400", + { "hover:cursor-grab": !isDragDisabled }, + { "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id }, + { "bg-custom-background-80 z-[100]": isCurrentBlockDragging } + )} + ref={cardRef} > - <div - className={cn( - "rounded border-[0.5px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400", - { "hover:cursor-pointer": !isDragDisabled }, - { "border-custom-primary-100": snapshot.isDragging }, - { "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id } - )} + <RenderIfVisible + classNames="space-y-2 px-3 py-2" + root={scrollableContainerRef} + defaultHeight="100px" + horizontalOffset={50} + changingReference={issueIds} > - <RenderIfVisible - classNames="space-y-2 px-3 py-2" - root={scrollableContainerRef} - defaultHeight="100px" - horizontalOffset={50} - alwaysRender={snapshot.isDragging} - pauseHeightUpdateWhileRendering={isDragStarted} - changingReference={issueIds} - > - <KanbanIssueDetailsBlock - issue={issue} - displayProperties={displayProperties} - updateIssue={updateIssue} - quickActions={quickActions} - isReadOnly={!canEditIssueProperties} - /> - </RenderIfVisible> - </div> - </ControlLink> - </div> - )} - </Draggable> + <KanbanIssueDetailsBlock + issue={issue} + displayProperties={displayProperties} + updateIssue={updateIssue} + quickActions={quickActions} + isReadOnly={!canEditIssueProperties} + /> + </RenderIfVisible> + </div> + </ControlLink> + </div> + </> ); }); diff --git a/web/components/issues/issue-layouts/kanban/blocks-list.tsx b/web/components/issues/issue-layouts/kanban/blocks-list.tsx index a906a329f14..c352e6b5c34 100644 --- a/web/components/issues/issue-layouts/kanban/blocks-list.tsx +++ b/web/components/issues/issue-layouts/kanban/blocks-list.tsx @@ -16,7 +16,6 @@ interface IssueBlocksListProps { quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>; - isDragStarted?: boolean; } const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => { @@ -32,14 +31,13 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => { quickActions, canEditProperties, scrollableContainerRef, - isDragStarted, } = props; return ( <> {issueIds && issueIds.length > 0 ? ( <> - {issueIds.map((issueId, index) => { + {issueIds.map((issueId) => { if (!issueId) return null; let draggableId = issueId; @@ -56,11 +54,9 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => { updateIssue={updateIssue} quickActions={quickActions} draggableId={draggableId} - index={index} isDragDisabled={isDragDisabled} canEditProperties={canEditProperties} scrollableContainerRef={scrollableContainerRef} - isDragStarted={isDragStarted} issueIds={issueIds} //passing to force render for virtualization whenever parent rerenders /> ); diff --git a/web/components/issues/issue-layouts/kanban/default.tsx b/web/components/issues/issue-layouts/kanban/default.tsx index ee1ceef53c8..cb40accf5f0 100644 --- a/web/components/issues/issue-layouts/kanban/default.tsx +++ b/web/components/issues/issue-layouts/kanban/default.tsx @@ -10,6 +10,7 @@ import { TSubGroupedIssues, TUnGroupedIssues, TIssueKanbanFilters, + TIssueGroupByOptions, } from "@plane/types"; // constants // hooks @@ -30,13 +31,14 @@ import { getGroupByColumns, isWorkspaceLevel } from "../utils"; import { KanbanStoreType } from "./base-kanban-root"; import { HeaderGroupByCard } from "./headers/group-by-card"; import { KanbanGroup } from "./kanban-group"; +import { KanbanDropLocation } from "./utils"; export interface IGroupByKanBan { issuesMap: IIssueMap; issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; displayProperties: IIssueDisplayProperties | undefined; - sub_group_by: string | null; - group_by: string | null; + sub_group_by: TIssueGroupByOptions | undefined; + group_by: TIssueGroupByOptions | undefined; sub_group_id: string; isDragDisabled: boolean; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; @@ -56,7 +58,7 @@ export interface IGroupByKanBan { addIssuesToView?: (issueIds: string[]) => Promise<TIssue>; canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>; - isDragStarted?: boolean; + handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>; showEmptyGroup?: boolean; subGroupIssueHeaderCount?: (listId: string) => number; } @@ -82,7 +84,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => { addIssuesToView, canEditProperties, scrollableContainerRef, - isDragStarted, + handleOnDrop, showEmptyGroup = true, subGroupIssueHeaderCount, } = props; @@ -188,7 +190,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => { disableIssueCreation={disableIssueCreation} canEditProperties={canEditProperties} scrollableContainerRef={scrollableContainerRef} - isDragStarted={isDragStarted} + handleOnDrop={handleOnDrop} /> )} </div> @@ -202,8 +204,8 @@ export interface IKanBan { issuesMap: IIssueMap; issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; displayProperties: IIssueDisplayProperties | undefined; - sub_group_by: string | null; - group_by: string | null; + sub_group_by: TIssueGroupByOptions | undefined; + group_by: TIssueGroupByOptions | undefined; sub_group_id?: string; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; @@ -223,7 +225,7 @@ export interface IKanBan { addIssuesToView?: (issueIds: string[]) => Promise<TIssue>; canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>; - isDragStarted?: boolean; + handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>; subGroupIssueHeaderCount?: (listId: string) => number; } @@ -247,7 +249,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => { addIssuesToView, canEditProperties, scrollableContainerRef, - isDragStarted, + handleOnDrop, showEmptyGroup, subGroupIssueHeaderCount, } = props; @@ -275,7 +277,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => { addIssuesToView={addIssuesToView} canEditProperties={canEditProperties} scrollableContainerRef={scrollableContainerRef} - isDragStarted={isDragStarted} + handleOnDrop={handleOnDrop} showEmptyGroup={showEmptyGroup} subGroupIssueHeaderCount={subGroupIssueHeaderCount} /> diff --git a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx index 44df59d9ac3..afdd3351585 100644 --- a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // lucide icons import { Minimize2, Maximize2, Circle, Plus } from "lucide-react"; -import { TIssue, ISearchIssueResponse, TIssueKanbanFilters } from "@plane/types"; +import { TIssue, ISearchIssueResponse, TIssueKanbanFilters, TIssueGroupByOptions } from "@plane/types"; // ui import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; // components @@ -16,8 +16,8 @@ import { useEventTracker } from "@/hooks/store"; import { KanbanStoreType } from "../base-kanban-root"; interface IHeaderGroupByCard { - sub_group_by: string | null; - group_by: string | null; + sub_group_by: TIssueGroupByOptions | undefined; + group_by: TIssueGroupByOptions | undefined; column_id: string; icon?: React.ReactNode; title: string; diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index b3e24dc2363..e913e0e31ae 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -1,6 +1,8 @@ -import { MutableRefObject } from "react"; -import { Droppable } from "@hello-pangea/dnd"; -// hooks +import { 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 { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; +//types import { TGroupedIssues, TIssue, @@ -8,10 +10,14 @@ import { IIssueMap, TSubGroupedIssues, TUnGroupedIssues, + TIssueGroupByOptions, } from "@plane/types"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks import { useProjectState } from "@/hooks/store"; //components -//types +import { KanbanDropLocation, getSourceFromDropPayload, getDestinationFromDropPayload } from "./utils"; import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from "."; interface IKanbanGroup { @@ -20,8 +26,8 @@ interface IKanbanGroup { peekIssueId?: string; issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; displayProperties: IIssueDisplayProperties | undefined; - sub_group_by: string | null; - group_by: string | null; + sub_group_by: TIssueGroupByOptions | undefined; + group_by: TIssueGroupByOptions | undefined; sub_group_id: string; isDragDisabled: boolean; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; @@ -38,7 +44,7 @@ interface IKanbanGroup { canEditProperties: (projectId: string | undefined) => boolean; groupByVisibilityToggle?: boolean; scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>; - isDragStarted?: boolean; + handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>; } export const KanbanGroup = (props: IKanbanGroup) => { @@ -60,14 +66,53 @@ export const KanbanGroup = (props: IKanbanGroup) => { quickAddCallback, viewId, scrollableContainerRef, - isDragStarted, + handleOnDrop, } = props; // hooks const projectState = useProjectState(); + const [isDraggingOverColumn, setIsDraggingOverColumn] = useState(false); + + const columnRef = useRef<HTMLDivElement | null>(null); + + // Enable Kanban Columns as Drop Targets + useEffect(() => { + const element = columnRef.current; + + if (!element) return; + + return combine( + dropTargetForElements({ + element, + getData: () => ({ groupId, subGroupId: sub_group_id, columnId: `${groupId}__${sub_group_id}`, type: "COLUMN" }), + onDragEnter: () => { + setIsDraggingOverColumn(true); + }, + onDragLeave: () => { + setIsDraggingOverColumn(false); + }, + onDragStart: () => { + setIsDraggingOverColumn(true); + }, + onDrop: (payload) => { + setIsDraggingOverColumn(false); + const source = getSourceFromDropPayload(payload); + const destination = getDestinationFromDropPayload(payload); + + if (!source || !destination) return; + + handleOnDrop(source, destination); + }, + }), + autoScrollForElements({ + element, + }) + ); + }, [columnRef?.current, groupId, sub_group_id, setIsDraggingOverColumn]); + const prePopulateQuickAddData = ( - groupByKey: string | null, - subGroupByKey: string | null, + groupByKey: string | undefined, + subGroupByKey: string | undefined | null, groupValue: string, subGroupValue: string ) => { @@ -118,48 +163,43 @@ export const KanbanGroup = (props: IKanbanGroup) => { }; return ( - <div className={`relative w-full h-full transition-all`}> - <Droppable droppableId={`${groupId}__${sub_group_id}`}> - {(provided: any, snapshot: any) => ( - <div - className={`relative h-full transition-all ${snapshot.isDraggingOver ? `bg-custom-background-80` : ``}`} - {...provided.droppableProps} - ref={provided.innerRef} - > - <KanbanIssueBlocksList - sub_group_id={sub_group_id} - columnId={groupId} - issuesMap={issuesMap} - peekIssueId={peekIssueId} - issueIds={(issueIds as TGroupedIssues)?.[groupId] || []} - displayProperties={displayProperties} - isDragDisabled={isDragDisabled} - updateIssue={updateIssue} - quickActions={quickActions} - canEditProperties={canEditProperties} - scrollableContainerRef={scrollableContainerRef} - isDragStarted={isDragStarted} - /> - - {provided.placeholder} + <div + id={`${groupId}__${sub_group_id}`} + className={cn( + "relative h-full transition-all", + { "bg-custom-background-80": isDraggingOverColumn }, + { "vertical-scrollbar scrollbar-md": !sub_group_by } + )} + ref={columnRef} + > + <KanbanIssueBlocksList + sub_group_id={sub_group_id} + columnId={groupId} + issuesMap={issuesMap} + peekIssueId={peekIssueId} + issueIds={(issueIds as TGroupedIssues)?.[groupId] || []} + displayProperties={displayProperties} + isDragDisabled={isDragDisabled} + updateIssue={updateIssue} + quickActions={quickActions} + canEditProperties={canEditProperties} + scrollableContainerRef={scrollableContainerRef} + /> - {enableQuickIssueCreate && !disableIssueCreation && ( - <div className="w-full bg-custom-background-90 py-0.5 sticky bottom-0"> - <KanBanQuickAddIssueForm - formKey="name" - groupId={groupId} - subGroupId={sub_group_id} - prePopulatedData={{ - ...(group_by && prePopulateQuickAddData(group_by, sub_group_by, groupId, sub_group_id)), - }} - quickAddCallback={quickAddCallback} - viewId={viewId} - /> - </div> - )} - </div> - )} - </Droppable> + {enableQuickIssueCreate && !disableIssueCreation && ( + <div className="w-full bg-custom-background-90 py-0.5 sticky bottom-0"> + <KanBanQuickAddIssueForm + formKey="name" + groupId={groupId} + subGroupId={sub_group_id} + prePopulatedData={{ + ...(group_by && prePopulateQuickAddData(group_by, sub_group_by, groupId, sub_group_id)), + }} + quickAddCallback={quickAddCallback} + viewId={viewId} + /> + </div> + )} </div> ); }; diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index 197d8d2934a..ae89664b529 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -10,6 +10,7 @@ import { TSubGroupedIssues, TUnGroupedIssues, TIssueKanbanFilters, + TIssueGroupByOptions, } from "@plane/types"; // components import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; @@ -18,13 +19,14 @@ import { KanbanStoreType } from "./base-kanban-root"; import { KanBan } from "./default"; import { HeaderGroupByCard } from "./headers/group-by-card"; import { HeaderSubGroupByCard } from "./headers/sub-group-by-card"; +import { KanbanDropLocation } from "./utils"; // types // constants interface ISubGroupSwimlaneHeader { issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; - sub_group_by: string | null; - group_by: string | null; + sub_group_by: TIssueGroupByOptions | undefined; + group_by: TIssueGroupByOptions | undefined; list: IGroupByColumn[]; kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; @@ -107,7 +109,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader { quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; - isDragStarted?: boolean; + handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>; disableIssueCreation?: boolean; storeType: KanbanStoreType; enableQuickIssueCreate: boolean; @@ -142,7 +144,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => { quickAddCallback, viewId, scrollableContainerRef, - isDragStarted, + handleOnDrop, } = props; const calculateIssueCount = (column_id: string) => { @@ -213,7 +215,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => { quickAddCallback={quickAddCallback} viewId={viewId} scrollableContainerRef={scrollableContainerRef} - isDragStarted={isDragStarted} + handleOnDrop={handleOnDrop} subGroupIssueHeaderCount={(groupByListId: string) => getSubGroupHeaderIssuesCount(issueIds as TSubGroupedIssues, groupByListId) } @@ -231,14 +233,14 @@ export interface IKanBanSwimLanes { issuesMap: IIssueMap; issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; displayProperties: IIssueDisplayProperties | undefined; - sub_group_by: string | null; - group_by: string | null; + sub_group_by: TIssueGroupByOptions | undefined; + group_by: TIssueGroupByOptions | undefined; updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined; quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; showEmptyGroup: boolean; - isDragStarted?: boolean; + handleOnDrop: (source: KanbanDropLocation, destination: KanbanDropLocation) => Promise<void>; disableIssueCreation?: boolean; storeType: KanbanStoreType; addIssuesToView?: (issueIds: string[]) => Promise<TIssue>; @@ -267,7 +269,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => { kanbanFilters, handleKanbanFilters, showEmptyGroup, - isDragStarted, + handleOnDrop, disableIssueCreation, enableQuickIssueCreate, canEditProperties, @@ -337,7 +339,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => { kanbanFilters={kanbanFilters} handleKanbanFilters={handleKanbanFilters} showEmptyGroup={showEmptyGroup} - isDragStarted={isDragStarted} + handleOnDrop={handleOnDrop} disableIssueCreation={disableIssueCreation} enableQuickIssueCreate={enableQuickIssueCreate} addIssuesToView={addIssuesToView} diff --git a/web/components/issues/issue-layouts/kanban/utils.ts b/web/components/issues/issue-layouts/kanban/utils.ts index 855f096e655..3981455dbc0 100644 --- a/web/components/issues/issue-layouts/kanban/utils.ts +++ b/web/components/issues/issue-layouts/kanban/utils.ts @@ -1,29 +1,131 @@ -import { DraggableLocation } from "@hello-pangea/dnd"; -import { TGroupedIssues, IIssueMap, TSubGroupedIssues, TUnGroupedIssues, TIssue } from "@plane/types"; +import pull from "lodash/pull"; +import { IPragmaticDropPayload, TIssue, TIssueGroupByOptions } from "@plane/types"; +import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/issue-helper.store"; + +export type KanbanDropLocation = { + columnId: string; + groupId: string; + subGroupId?: string; + id: string | undefined; +}; + +/** + * get Kanban Source data from Pragmatic Payload + * @param payload + * @returns + */ +export const getSourceFromDropPayload = (payload: IPragmaticDropPayload): KanbanDropLocation | undefined => { + const { location, source: sourceIssue } = payload; + + const sourceIssueData = sourceIssue.data; + let sourceColumData; + + const sourceDropTargets = location?.initial?.dropTargets ?? []; + for (const dropTarget of sourceDropTargets) { + const dropTargetData = dropTarget?.data; + + if (!dropTargetData) continue; + + if (dropTargetData.type === "COLUMN") { + sourceColumData = dropTargetData; + } + } + + if (sourceIssueData?.id === undefined || !sourceColumData?.groupId) return; + + return { + groupId: sourceColumData.groupId as string, + subGroupId: sourceColumData.subGroupId as string, + columnId: sourceColumData.columnId as string, + id: sourceIssueData.id as string, + }; +}; + +/** + * get Destination Source data from Pragmatic Payload + * @param payload + * @returns + */ +export const getDestinationFromDropPayload = (payload: IPragmaticDropPayload): KanbanDropLocation | undefined => { + const { location } = payload; + + let destinationIssueData, destinationColumnData; + + const destDropTargets = location?.current?.dropTargets ?? []; + + for (const dropTarget of destDropTargets) { + const dropTargetData = dropTarget?.data; + + if (!dropTargetData) continue; + + if (dropTargetData.type === "COLUMN" || dropTargetData.type === "DELETE") { + destinationColumnData = dropTargetData; + } + + if (dropTargetData.type === "ISSUE") { + destinationIssueData = dropTargetData; + } + } -const handleSortOrder = (destinationIssues: string[], destinationIndex: number, issueMap: IIssueMap) => { + if (!destinationColumnData?.groupId) return; + + return { + groupId: destinationColumnData.groupId as string, + subGroupId: destinationColumnData.subGroupId as string, + columnId: destinationColumnData.columnId as string, + id: destinationIssueData?.id as string | undefined, + }; +}; + +/** + * Returns Sort order of the issue block at the position of drop + * @param destinationIssues + * @param destinationIssueId + * @param getIssueById + * @returns + */ +const handleSortOrder = ( + destinationIssues: string[], + destinationIssueId: string | undefined, + getIssueById: (issueId: string) => TIssue | undefined +) => { const sortOrderDefaultValue = 65535; let currentIssueState = {}; + const destinationIndex = destinationIssueId + ? destinationIssues.indexOf(destinationIssueId) + : destinationIssues.length; + if (destinationIssues && destinationIssues.length > 0) { if (destinationIndex === 0) { - const destinationIssueId = destinationIssues[destinationIndex]; + const destinationIssueId = destinationIssues[0]; + const destinationIssue = getIssueById(destinationIssueId); + if (!destinationIssue) return currentIssueState; + currentIssueState = { ...currentIssueState, - sort_order: issueMap[destinationIssueId].sort_order - sortOrderDefaultValue, + sort_order: destinationIssue.sort_order - sortOrderDefaultValue, }; } else if (destinationIndex === destinationIssues.length) { - const destinationIssueId = destinationIssues[destinationIndex - 1]; + const destinationIssueId = destinationIssues[destinationIssues.length - 1]; + const destinationIssue = getIssueById(destinationIssueId); + if (!destinationIssue) return currentIssueState; + currentIssueState = { ...currentIssueState, - sort_order: issueMap[destinationIssueId].sort_order + sortOrderDefaultValue, + sort_order: destinationIssue.sort_order + sortOrderDefaultValue, }; } else { const destinationTopIssueId = destinationIssues[destinationIndex - 1]; const destinationBottomIssueId = destinationIssues[destinationIndex]; + + const destinationTopIssue = getIssueById(destinationTopIssueId); + const destinationBottomIssue = getIssueById(destinationBottomIssueId); + if (!destinationTopIssue || !destinationBottomIssue) return currentIssueState; + currentIssueState = { ...currentIssueState, - sort_order: (issueMap[destinationTopIssueId].sort_order + issueMap[destinationBottomIssueId].sort_order) / 2, + sort_order: (destinationTopIssue.sort_order + destinationBottomIssue.sort_order) / 2, }; } } else { @@ -37,116 +139,71 @@ const handleSortOrder = (destinationIssues: string[], destinationIndex: number, }; export const handleDragDrop = async ( - source: DraggableLocation | null | undefined, - destination: DraggableLocation | null | undefined, - workspaceSlug: string | undefined, - projectId: string | undefined, // projectId for all views or user id in profile issues - subGroupBy: string | null, - groupBy: string | null, - issueMap: IIssueMap, - issueWithIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined, + source: KanbanDropLocation, + destination: KanbanDropLocation, + getIssueById: (issueId: string) => TIssue | undefined, + getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined, updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined, - removeIssue: (projectId: string, issueId: string) => Promise<void> | undefined + groupBy: TIssueGroupByOptions | undefined, + subGroupBy: TIssueGroupByOptions | undefined ) => { - if (!issueMap || !issueWithIds || !source || !destination || !workspaceSlug || !projectId) return; - - let updatedIssue: any = {}; - - const sourceDroppableId = source?.droppableId; - const destinationDroppableId = destination?.droppableId; + if (!source.id || !groupBy || (subGroupBy && (!source.subGroupId || !destination.subGroupId))) return; - const sourceColumnId = (sourceDroppableId && sourceDroppableId.split("__")) || null; - const destinationColumnId = (destinationDroppableId && destinationDroppableId.split("__")) || null; + let updatedIssue: Partial<TIssue> = {}; + const sourceIssues = getIssueIds(source.groupId, source.subGroupId); + const destinationIssues = getIssueIds(destination.groupId, destination.subGroupId); - if (!sourceColumnId || !destinationColumnId || !sourceDroppableId || !destinationDroppableId) return; + const sourceIssue = getIssueById(source.id); - const sourceGroupByColumnId = sourceColumnId[0] || null; - const destinationGroupByColumnId = destinationColumnId[0] || null; + if (!sourceIssues || !destinationIssues || !sourceIssue) return; - const sourceSubGroupByColumnId = sourceColumnId[1] || null; - const destinationSubGroupByColumnId = destinationColumnId[1] || null; + updatedIssue = { + id: sourceIssue.id, + project_id: sourceIssue.project_id, + }; - if ( - !workspaceSlug || - !projectId || - !groupBy || - !sourceGroupByColumnId || - !destinationGroupByColumnId || - !sourceSubGroupByColumnId || - !destinationSubGroupByColumnId - ) - return; + // for both horizontal and vertical dnd + updatedIssue = { + ...updatedIssue, + ...handleSortOrder(destinationIssues, destination.id, getIssueById), + }; - if (destinationGroupByColumnId === "issue-trash-box") { - const sourceIssues: string[] = subGroupBy - ? (issueWithIds as TSubGroupedIssues)[sourceSubGroupByColumnId][sourceGroupByColumnId] - : (issueWithIds as TGroupedIssues)[sourceGroupByColumnId]; + if (source.groupId && destination.groupId && source.groupId !== destination.groupId) { + const groupKey = ISSUE_FILTER_DEFAULT_DATA[groupBy]; + let groupValue = sourceIssue[groupKey]; - const [removed] = sourceIssues.splice(source.index, 1); - - if (removed) { - return await removeIssue(projectId, removed); + if (Array.isArray(groupValue)) { + pull(groupValue, source.groupId); + groupValue.push(destination.groupId); + } else { + groupValue = destination.groupId; } - } else { - //spreading the array to stop changing the original reference - //since we are removing an id from array further down - const sourceIssues = [ - ...(subGroupBy - ? (issueWithIds as TSubGroupedIssues)[sourceSubGroupByColumnId][sourceGroupByColumnId] - : (issueWithIds as TGroupedIssues)[sourceGroupByColumnId]), - ]; - const destinationIssues = subGroupBy - ? (issueWithIds as TSubGroupedIssues)[sourceSubGroupByColumnId][destinationGroupByColumnId] - : (issueWithIds as TGroupedIssues)[destinationGroupByColumnId]; - - const [removed] = sourceIssues.splice(source.index, 1); - const removedIssueDetail = issueMap[removed]; - - updatedIssue = { - id: removedIssueDetail?.id, - project_id: removedIssueDetail?.project_id, - }; - // for both horizontal and vertical dnd - updatedIssue = { - ...updatedIssue, - ...handleSortOrder( - sourceDroppableId === destinationDroppableId ? sourceIssues : destinationIssues, - destination.index, - issueMap - ), - }; + updatedIssue = { ...updatedIssue, [groupKey]: groupValue }; + } + + if (subGroupBy && source.subGroupId && destination.subGroupId && source.subGroupId !== destination.subGroupId) { + const subGroupKey = ISSUE_FILTER_DEFAULT_DATA[subGroupBy]; + let subGroupValue = sourceIssue[subGroupKey]; - if (subGroupBy && sourceSubGroupByColumnId && destinationSubGroupByColumnId) { - if (sourceSubGroupByColumnId === destinationSubGroupByColumnId) { - if (sourceGroupByColumnId != destinationGroupByColumnId) { - if (groupBy === "state") updatedIssue = { ...updatedIssue, state_id: destinationGroupByColumnId }; - if (groupBy === "priority") updatedIssue = { ...updatedIssue, priority: destinationGroupByColumnId }; - } - } else { - if (subGroupBy === "state") - updatedIssue = { - ...updatedIssue, - state_id: destinationSubGroupByColumnId, - priority: destinationGroupByColumnId, - }; - if (subGroupBy === "priority") - updatedIssue = { - ...updatedIssue, - state_id: destinationGroupByColumnId, - priority: destinationSubGroupByColumnId, - }; - } + if (Array.isArray(subGroupValue)) { + pull(subGroupValue, source.subGroupId); + subGroupValue.push(destination.subGroupId); } else { - // for horizontal dnd - if (sourceColumnId != destinationColumnId) { - if (groupBy === "state") updatedIssue = { ...updatedIssue, state_id: destinationGroupByColumnId }; - if (groupBy === "priority") updatedIssue = { ...updatedIssue, priority: destinationGroupByColumnId }; - } + subGroupValue = destination.subGroupId; } - if (updatedIssue && updatedIssue?.id) { - return updateIssue && (await updateIssue(updatedIssue.project_id, updatedIssue.id, updatedIssue)); - } + updatedIssue = { ...updatedIssue, [subGroupKey]: subGroupValue }; + } + + if (updatedIssue) { + return ( + updateIssue && + (await updateIssue(sourceIssue.project_id, sourceIssue.id, { + ...updatedIssue, + id: sourceIssue.id, + project_id: sourceIssue.project_id, + })) + ); } }; diff --git a/web/package.json b/web/package.json index de73ad407ff..eb0be63441f 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,8 @@ "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.1.3", + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.0.3", "@blueprintjs/popover2": "^1.13.3", "@headlessui/react": "^1.7.3", "@hello-pangea/dnd": "^16.3.0", @@ -61,6 +63,7 @@ "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", @@ -71,7 +74,6 @@ "@types/react-dom": "^18.2.17", "@types/uuid": "^8.3.4", "eslint-config-custom": "*", - "prettier": "^2.8.7", "tailwind-config-custom": "*", "tsconfig": "*", "typescript": "4.7.4" diff --git a/web/store/issue/cycle/issue.store.ts b/web/store/issue/cycle/issue.store.ts index a5d7e0670df..abdd141419b 100644 --- a/web/store/issue/cycle/issue.store.ts +++ b/web/store/issue/cycle/issue.store.ts @@ -23,6 +23,7 @@ export interface ICycleIssues { // computed groupedIssueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined; // actions + getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; fetchIssues: ( workspaceSlug: string, projectId: string, @@ -142,6 +143,30 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { return issues; } + getIssueIds = (groupId?: string, subGroupId?: string) => { + const groupedIssueIds = this.groupedIssueIds; + + const displayFilters = this.rootIssueStore?.cycleIssuesFilter?.issueFilters?.displayFilters; + if (!displayFilters || !groupedIssueIds) return undefined; + + const subGroupBy = displayFilters?.sub_group_by; + const groupBy = displayFilters?.group_by; + + if (!groupBy && !subGroupBy) { + return groupedIssueIds as string[]; + } + + if (groupBy && subGroupBy && groupId && subGroupId) { + return (groupedIssueIds as TSubGroupedIssues)?.[subGroupId]?.[groupId] as string[]; + } + + if (groupBy && groupId) { + return (groupedIssueIds as TGroupedIssues)?.[groupId] as string[]; + } + + return undefined; + }; + fetchIssues = async ( workspaceSlug: string, projectId: string, diff --git a/web/store/issue/draft/issue.store.ts b/web/store/issue/draft/issue.store.ts index d755a4549bb..c7d94e85c23 100644 --- a/web/store/issue/draft/issue.store.ts +++ b/web/store/issue/draft/issue.store.ts @@ -20,6 +20,7 @@ export interface IDraftIssues { // computed groupedIssueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined; // actions + getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; fetchIssues: (workspaceSlug: string, projectId: string, loadType: TLoader) => Promise<TIssue[]>; createIssue: (workspaceSlug: string, projectId: string, data: Partial<TIssue>) => Promise<TIssue>; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>; @@ -97,6 +98,30 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues { return issues; } + getIssueIds = (groupId?: string, subGroupId?: string) => { + const groupedIssueIds = this.groupedIssueIds; + + const displayFilters = this.rootIssueStore?.draftIssuesFilter?.issueFilters?.displayFilters; + if (!displayFilters || !groupedIssueIds) return undefined; + + const subGroupBy = displayFilters?.sub_group_by; + const groupBy = displayFilters?.group_by; + + if (!groupBy && !subGroupBy) { + return groupedIssueIds as string[]; + } + + if (groupBy && subGroupBy && groupId && subGroupId) { + return (groupedIssueIds as TSubGroupedIssues)?.[subGroupId]?.[groupId] as string[]; + } + + if (groupBy && groupId) { + return (groupedIssueIds as TGroupedIssues)?.[groupId] as string[]; + } + + return undefined; + }; + fetchIssues = async (workspaceSlug: string, projectId: string, loadType: TLoader = "init-loader") => { try { this.loader = loadType; @@ -141,8 +166,6 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues { updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => { try { - await this.issueDraftService.updateDraftIssue(workspaceSlug, projectId, issueId, data); - this.rootStore.issues.updateIssue(issueId, data); if (data.hasOwnProperty("is_draft") && data?.is_draft === false) { @@ -153,6 +176,8 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues { }); }); } + + await this.issueDraftService.updateDraftIssue(workspaceSlug, projectId, issueId, data); } catch (error) { this.fetchIssues(workspaceSlug, projectId, "mutation"); throw error; diff --git a/web/store/issue/helpers/issue-helper.store.ts b/web/store/issue/helpers/issue-helper.store.ts index 8dafa2694c7..3876d675cff 100644 --- a/web/store/issue/helpers/issue-helper.store.ts +++ b/web/store/issue/helpers/issue-helper.store.ts @@ -35,7 +35,7 @@ export type TIssueHelperStore = { getGroupArray(value: boolean | number | string | string[] | null, isDate?: boolean): string[]; }; -const ISSUE_FILTER_DEFAULT_DATA: Record<TIssueDisplayFilterOptions, keyof TIssue> = { +export const ISSUE_FILTER_DEFAULT_DATA: Record<TIssueDisplayFilterOptions, keyof TIssue> = { project: "project_id", cycle: "cycle_id", module: "module_ids", diff --git a/web/store/issue/issue_kanban_view.store.ts b/web/store/issue/issue_kanban_view.store.ts index fa25038387d..12f5306cba1 100644 --- a/web/store/issue/issue_kanban_view.store.ts +++ b/web/store/issue/issue_kanban_view.store.ts @@ -1,6 +1,7 @@ import { action, computed, makeObservable, observable } from "mobx"; import { computedFn } from "mobx-utils"; import { IssueRootStore } from "./root.store"; +import { TIssueGroupByOptions } from "@plane/types"; // types export interface IIssueKanBanViewStore { @@ -8,12 +9,17 @@ export interface IIssueKanBanViewStore { groupByHeaderMinMax: string[]; subgroupByIssuesVisibility: string[]; }; + isDragging: boolean; // computed - getCanUserDragDrop: (group_by: string | null, sub_group_by: string | null) => boolean; + getCanUserDragDrop: ( + group_by: TIssueGroupByOptions | undefined, + sub_group_by: TIssueGroupByOptions | undefined + ) => boolean; canUserDragDropVertically: boolean; canUserDragDropHorizontally: boolean; // actions handleKanBanToggle: (toggle: "groupByHeaderMinMax" | "subgroupByIssuesVisibility", value: string) => void; + setIsDragging: (isDragging: boolean) => void; } export class IssueKanBanViewStore implements IIssueKanBanViewStore { @@ -21,30 +27,39 @@ export class IssueKanBanViewStore implements IIssueKanBanViewStore { groupByHeaderMinMax: string[]; subgroupByIssuesVisibility: string[]; } = { groupByHeaderMinMax: [], subgroupByIssuesVisibility: [] }; + isDragging = false; // root store rootStore; constructor(_rootStore: IssueRootStore) { makeObservable(this, { kanBanToggle: observable, + isDragging: observable.ref, // computed canUserDragDropVertically: computed, canUserDragDropHorizontally: computed, // actions handleKanBanToggle: action, + setIsDragging: action.bound, }); this.rootStore = _rootStore; } - getCanUserDragDrop = computedFn((group_by: string | null, sub_group_by: string | null) => { - if (group_by && ["state", "priority"].includes(group_by)) { - if (!sub_group_by) return true; - if (sub_group_by && ["state", "priority"].includes(sub_group_by)) return true; + setIsDragging = (isDragging: boolean) => { + this.isDragging = isDragging; + }; + + getCanUserDragDrop = computedFn( + (group_by: TIssueGroupByOptions | undefined, sub_group_by: TIssueGroupByOptions | undefined) => { + if (group_by && ["state", "priority"].includes(group_by)) { + if (!sub_group_by) return true; + if (sub_group_by && ["state", "priority"].includes(sub_group_by)) return true; + } + return false; } - return false; - }); + ); get canUserDragDropVertically() { return false; diff --git a/web/store/issue/module/issue.store.ts b/web/store/issue/module/issue.store.ts index e1af2c256b0..6e44e95a2fd 100644 --- a/web/store/issue/module/issue.store.ts +++ b/web/store/issue/module/issue.store.ts @@ -21,6 +21,7 @@ export interface IModuleIssues { // computed groupedIssueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined; // actions + getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; fetchIssues: ( workspaceSlug: string, projectId: string, @@ -146,6 +147,30 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { return issues; } + getIssueIds = (groupId?: string, subGroupId?: string) => { + const groupedIssueIds = this.groupedIssueIds; + + const displayFilters = this.rootIssueStore?.moduleIssuesFilter?.issueFilters?.displayFilters; + if (!displayFilters || !groupedIssueIds) return undefined; + + const subGroupBy = displayFilters?.sub_group_by; + const groupBy = displayFilters?.group_by; + + if (!groupBy && !subGroupBy) { + return groupedIssueIds as string[]; + } + + if (groupBy && subGroupBy && groupId && subGroupId) { + return (groupedIssueIds as TSubGroupedIssues)?.[subGroupId]?.[groupId] as string[]; + } + + if (groupBy && groupId) { + return (groupedIssueIds as TGroupedIssues)?.[groupId] as string[]; + } + + return undefined; + }; + fetchIssues = async ( workspaceSlug: string, projectId: string, diff --git a/web/store/issue/profile/issue.store.ts b/web/store/issue/profile/issue.store.ts index 6da65634211..9a754b4f559 100644 --- a/web/store/issue/profile/issue.store.ts +++ b/web/store/issue/profile/issue.store.ts @@ -23,6 +23,7 @@ export interface IProfileIssues { viewFlags: ViewFlags; // actions setViewId: (viewId: "assigned" | "created" | "subscribed") => void; + getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; fetchIssues: ( workspaceSlug: string, projectId: string | undefined, @@ -118,6 +119,30 @@ export class ProfileIssues extends IssueHelperStore implements IProfileIssues { return issues; } + getIssueIds = (groupId?: string, subGroupId?: string) => { + const groupedIssueIds = this.groupedIssueIds; + + const displayFilters = this.rootStore?.projectIssuesFilter?.issueFilters?.displayFilters; + if (!displayFilters || !groupedIssueIds) return undefined; + + const subGroupBy = displayFilters?.sub_group_by; + const groupBy = displayFilters?.group_by; + + if (!groupBy && !subGroupBy) { + return groupedIssueIds as string[]; + } + + if (groupBy && subGroupBy && groupId && subGroupId) { + return (groupedIssueIds as TSubGroupedIssues)?.[subGroupId]?.[groupId] as string[]; + } + + if (groupBy && groupId) { + return (groupedIssueIds as TGroupedIssues)?.[groupId] as string[]; + } + + return undefined; + }; + get viewFlags() { if (this.currentView === "subscribed") return { diff --git a/web/store/issue/project-views/issue.store.ts b/web/store/issue/project-views/issue.store.ts index 4fc365daabd..2166f254f1b 100644 --- a/web/store/issue/project-views/issue.store.ts +++ b/web/store/issue/project-views/issue.store.ts @@ -17,6 +17,7 @@ export interface IProjectViewIssues { // computed groupedIssueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined; // actions + getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; fetchIssues: ( workspaceSlug: string, projectId: string, @@ -114,6 +115,30 @@ export class ProjectViewIssues extends IssueHelperStore implements IProjectViewI return issues; } + getIssueIds = (groupId?: string, subGroupId?: string) => { + const groupedIssueIds = this.groupedIssueIds; + + const displayFilters = this.rootIssueStore?.projectViewIssuesFilter?.issueFilters?.displayFilters; + if (!displayFilters || !groupedIssueIds) return undefined; + + const subGroupBy = displayFilters?.sub_group_by; + const groupBy = displayFilters?.group_by; + + if (!groupBy && !subGroupBy) { + return groupedIssueIds as string[]; + } + + if (groupBy && subGroupBy && groupId && subGroupId) { + return (groupedIssueIds as TSubGroupedIssues)?.[subGroupId]?.[groupId] as string[]; + } + + if (groupBy && groupId) { + return (groupedIssueIds as TGroupedIssues)?.[groupId] as string[]; + } + + return undefined; + }; + fetchIssues = async (workspaceSlug: string, projectId: string, loadType: TLoader = "init-loader", viewId: string) => { try { this.loader = loadType; diff --git a/web/store/issue/project/issue.store.ts b/web/store/issue/project/issue.store.ts index 5c329749aa3..123c943e88a 100644 --- a/web/store/issue/project/issue.store.ts +++ b/web/store/issue/project/issue.store.ts @@ -18,6 +18,7 @@ export interface IProjectIssues { viewFlags: ViewFlags; // computed groupedIssueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined; + getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; // action fetchIssues: (workspaceSlug: string, projectId: string, loadType: TLoader) => Promise<TIssue[]>; createIssue: (workspaceSlug: string, projectId: string, data: Partial<TIssue>) => Promise<TIssue>; @@ -100,6 +101,30 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { return issues; } + getIssueIds = (groupId?: string, subGroupId?: string) => { + const groupedIssueIds = this.groupedIssueIds; + + const displayFilters = this.rootStore?.projectIssuesFilter?.issueFilters?.displayFilters; + if (!displayFilters || !groupedIssueIds) return undefined; + + const subGroupBy = displayFilters?.sub_group_by; + const groupBy = displayFilters?.group_by; + + if (!groupBy && !subGroupBy) { + return groupedIssueIds as string[]; + } + + if (groupBy && subGroupBy && groupId && subGroupId) { + return (groupedIssueIds as TSubGroupedIssues)?.[subGroupId]?.[groupId] as string[]; + } + + if (groupBy && groupId) { + return (groupedIssueIds as TGroupedIssues)?.[groupId] as string[]; + } + + return undefined; + }; + fetchIssues = async (workspaceSlug: string, projectId: string, loadType: TLoader = "init-loader") => { try { this.loader = loadType; diff --git a/yarn.lock b/yarn.lock index d8cca2a7725..8b19e36f990 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29,6 +29,23 @@ jsonpointer "^5.0.0" leven "^3.1.0" +"@atlaskit/pragmatic-drag-and-drop-auto-scroll@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@atlaskit/pragmatic-drag-and-drop-auto-scroll/-/pragmatic-drag-and-drop-auto-scroll-1.0.3.tgz#bb088a3d8eb77d9454dfdead433e594c5f793dae" + integrity sha512-gfXwzQZRsWSLd/88IRNKwf2e5r3smZlDGLopg5tFkSqsL03VDHgd6wch6OfPhRK2kSCrT1uXv5n9hOpVBu678g== + 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" + integrity sha512-lx6ZMPSU8zPhUfAkdKajNAFWDDIqdtM8eQzCsqCRalXWumpclcvqeN8VCLkmclcQDEUhV8c2utKbcuhm7hvRIw== + dependencies: + "@babel/runtime" "^7.0.0" + bind-event-listener "^2.1.1" + raf-schd "^4.0.3" + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.22.13", "@babel/code-frame@^7.23.5": version "7.23.5" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.5.tgz#9009b69a8c602293476ad598ff53e4562e15c244" @@ -921,6 +938,13 @@ resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310" integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== +"@babel/runtime@^7.0.0": + version "7.24.4" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.4.tgz#de795accd698007a66ba44add6cc86542aff1edd" + integrity sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.1.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.18.3", "@babel/runtime@^7.21.0", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.5", "@babel/runtime@^7.23.6", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.23.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.6.tgz#c05e610dc228855dc92ef1b53d07389ed8ab521d" @@ -3354,6 +3378,11 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +bind-event-listener@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/bind-event-listener/-/bind-event-listener-2.1.1.tgz#5e57290181af3027ff53ba6109e417a1e3cbb6f3" + integrity sha512-O+a5c0D2se/u2VlBJmPRn45IB6R4mYMh1ok3dWxrIZ2pmLqzggBhb875mbq73508ylzofc0+hT9W41x4Y2s8lg== + bl@^4.0.3: version "4.1.0" resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" @@ -6866,11 +6895,16 @@ prettier-plugin-tailwindcss@^0.5.4: resolved "https://registry.yarnpkg.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.9.tgz#fdc2bd95a02b64702ebd2d6c7ddd300198de3cc6" integrity sha512-9x3t1s2Cjbut2QiP+O0mDqV3gLXTe2CgRlQDgucopVkUdw26sQi53p/q4qvGxMLBDfk/dcTV57Aa/zYwz9l8Ew== -prettier@^2.8.7, prettier@^2.8.8: +prettier@^2.8.8: version "2.8.8" resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== +prettier@^3.2.5: + version "3.2.5" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368" + integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A== + prettier@latest: version "3.1.1" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.1.1.tgz#6ba9f23165d690b6cbdaa88cb0807278f7019848"