[WEB-1004] feat: Pragmatic dnd implementation for Kanban (#4189)

* Pragmatic drag and drop implmentation of Kanban

* refactor pragmatic dnd implementation and fix bugs

* fix dnd for modules, cycles, draft and project views
This commit is contained in:
rahulramesha 2024-04-15 17:02:53 +05:30 committed by GitHub
parent 384624a21b
commit 7a21855ab6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 756 additions and 377 deletions

View File

@ -27,3 +27,4 @@ export * from "./api_token";
export * from "./instance";
export * from "./app";
export * from "./common";
export * from "./pragmatic";

25
packages/types/src/pragmatic.d.ts vendored Normal file
View File

@ -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;
}

View File

@ -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,
}
)}
/>
);
};

View File

@ -12,3 +12,4 @@ export * from "./tooltip";
export * from "./loader";
export * from "./control-link";
export * from "./toast";
export * from "./drop-indicator";

View File

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

View File

@ -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>
</>
);
});

View File

@ -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
/>
);

View File

@ -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}
/>

View File

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

View File

@ -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}
/>
<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}
/>
{provided.placeholder}
{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>
);
};

View File

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

View File

@ -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";
const handleSortOrder = (destinationIssues: string[], destinationIndex: number, issueMap: IIssueMap) => {
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;
}
}
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;
if (!source.id || !groupBy || (subGroupBy && (!source.subGroupId || !destination.subGroupId))) return;
let updatedIssue: any = {};
let updatedIssue: Partial<TIssue> = {};
const sourceIssues = getIssueIds(source.groupId, source.subGroupId);
const destinationIssues = getIssueIds(destination.groupId, destination.subGroupId);
const sourceDroppableId = source?.droppableId;
const destinationDroppableId = destination?.droppableId;
const sourceIssue = getIssueById(source.id);
const sourceColumnId = (sourceDroppableId && sourceDroppableId.split("__")) || null;
const destinationColumnId = (destinationDroppableId && destinationDroppableId.split("__")) || null;
if (!sourceIssues || !destinationIssues || !sourceIssue) return;
if (!sourceColumnId || !destinationColumnId || !sourceDroppableId || !destinationDroppableId) return;
updatedIssue = {
id: sourceIssue.id,
project_id: sourceIssue.project_id,
};
const sourceGroupByColumnId = sourceColumnId[0] || null;
const destinationGroupByColumnId = destinationColumnId[0] || null;
// for both horizontal and vertical dnd
updatedIssue = {
...updatedIssue,
...handleSortOrder(destinationIssues, destination.id, getIssueById),
};
const sourceSubGroupByColumnId = sourceColumnId[1] || null;
const destinationSubGroupByColumnId = destinationColumnId[1] || null;
if (source.groupId && destination.groupId && source.groupId !== destination.groupId) {
const groupKey = ISSUE_FILTER_DEFAULT_DATA[groupBy];
let groupValue = sourceIssue[groupKey];
if (
!workspaceSlug ||
!projectId ||
!groupBy ||
!sourceGroupByColumnId ||
!destinationGroupByColumnId ||
!sourceSubGroupByColumnId ||
!destinationSubGroupByColumnId
)
return;
if (destinationGroupByColumnId === "issue-trash-box") {
const sourceIssues: string[] = subGroupBy
? (issueWithIds as TSubGroupedIssues)[sourceSubGroupByColumnId][sourceGroupByColumnId]
: (issueWithIds as TGroupedIssues)[sourceGroupByColumnId];
const [removed] = sourceIssues.splice(source.index, 1);
if (removed) {
return await removeIssue(projectId, removed);
}
} 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
),
};
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(groupValue)) {
pull(groupValue, source.groupId);
groupValue.push(destination.groupId);
} else {
// for horizontal dnd
if (sourceColumnId != destinationColumnId) {
if (groupBy === "state") updatedIssue = { ...updatedIssue, state_id: destinationGroupByColumnId };
if (groupBy === "priority") updatedIssue = { ...updatedIssue, priority: destinationGroupByColumnId };
}
groupValue = destination.groupId;
}
if (updatedIssue && updatedIssue?.id) {
return updateIssue && (await updateIssue(updatedIssue.project_id, updatedIssue.id, updatedIssue));
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 (Array.isArray(subGroupValue)) {
pull(subGroupValue, source.subGroupId);
subGroupValue.push(destination.subGroupId);
} else {
subGroupValue = destination.subGroupId;
}
updatedIssue = { ...updatedIssue, [subGroupKey]: subGroupValue };
}
if (updatedIssue) {
return (
updateIssue &&
(await updateIssue(sourceIssue.project_id, sourceIssue.id, {
...updatedIssue,
id: sourceIssue.id,
project_id: sourceIssue.project_id,
}))
);
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
@ -6871,11 +6900,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"