From 68c870b7919119c08e47c29e0b01a7c49cc56316 Mon Sep 17 00:00:00 2001
From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
Date: Wed, 17 Apr 2024 18:18:14 +0530
Subject: [PATCH 1/6] [WEB-1023] chore: page mention transactions (#4222)
* chore: null validation for old value in pages
* chore: page transaction activity
* chore: serialized description_html
---
apiserver/plane/app/views/page/base.py | 8 ++++++--
apiserver/plane/bgtasks/page_transaction_task.py | 16 ++++++++--------
2 files changed, 14 insertions(+), 10 deletions(-)
diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py
index 4076a53b19..29dc2dbf5c 100644
--- a/apiserver/plane/app/views/page/base.py
+++ b/apiserver/plane/app/views/page/base.py
@@ -1,6 +1,7 @@
# Python imports
import json
from datetime import datetime
+from django.core.serializers.json import DjangoJSONEncoder
# Django imports
from django.db import connection
@@ -142,6 +143,7 @@ class PageViewSet(BaseViewSet):
serializer = PageDetailSerializer(
page, data=request.data, partial=True
)
+ page_description = page.description_html
if serializer.is_valid():
serializer.save()
# capture the page transaction
@@ -150,11 +152,13 @@ class PageViewSet(BaseViewSet):
new_value=request.data,
old_value=json.dumps(
{
- "description_html": page.description_html,
- }
+ "description_html": page_description,
+ },
+ cls=DjangoJSONEncoder,
),
page_id=pk,
)
+
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
diff --git a/apiserver/plane/bgtasks/page_transaction_task.py b/apiserver/plane/bgtasks/page_transaction_task.py
index 57f4f644e4..eceb3693e7 100644
--- a/apiserver/plane/bgtasks/page_transaction_task.py
+++ b/apiserver/plane/bgtasks/page_transaction_task.py
@@ -37,10 +37,10 @@ def page_transaction(new_value, old_value, page_id):
page = Page.objects.get(pk=page_id)
new_page_mention = PageLog.objects.filter(page_id=page_id).exists()
- old_value = json.loads(old_value)
+ old_value = json.loads(old_value) if old_value else {}
new_transactions = []
- deleted_transaction_ids = set()
+ deleted_transaction_ids = set()
# TODO - Add "issue-embed-component", "img", "todo" components
components = ["mention-component"]
@@ -49,8 +49,8 @@ def page_transaction(new_value, old_value, page_id):
new_mentions = extract_components(new_value, component)
new_mentions_ids = {mention["id"] for mention in new_mentions}
- old_mention_ids = {mention["id"] for mention in old_mentions}
- deleted_transaction_ids.update(old_mention_ids - new_mentions_ids)
+ old_mention_ids = {mention["id"] for mention in old_mentions}
+ deleted_transaction_ids.update(old_mention_ids - new_mentions_ids)
new_transactions.extend(
PageLog(
@@ -68,9 +68,9 @@ def page_transaction(new_value, old_value, page_id):
)
# Create new PageLog objects for new transactions
- PageLog.objects.bulk_create(new_transactions, batch_size=10, ignore_conflicts=True)
+ PageLog.objects.bulk_create(
+ new_transactions, batch_size=10, ignore_conflicts=True
+ )
# Delete the removed transactions
- PageLog.objects.filter(
- transaction__in=deleted_transaction_ids
- ).delete()
+ PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
From 10ed12e589a38e83cb40026b6e8f09305ff88364 Mon Sep 17 00:00:00 2001
From: rahulramesha <71900764+rahulramesha@users.noreply.github.com>
Date: Wed, 17 Apr 2024 18:20:02 +0530
Subject: [PATCH 2/6] [WEB-1005] chore: pragmatic drag n drop implementation
for labels (#4223)
* pragmatic drag n drop implementation for labels
* minor code quality improvements
---
packages/types/src/pragmatic.d.ts | 31 ++-
packages/ui/src/drop-indicator.tsx | 8 +-
.../labels/label-block/drag-handle.tsx | 15 +-
.../labels/label-block/label-item-block.tsx | 9 +-
.../labels/label-drag-n-drop-HOC.tsx | 161 ++++++++++++++
web/components/labels/label-utils.ts | 67 ++++++
.../labels/project-setting-label-group.tsx | 209 ++++++++----------
.../labels/project-setting-label-item.tsx | 75 ++++---
.../labels/project-setting-label-list.tsx | 165 ++++----------
web/helpers/array.helper.ts | 4 +-
web/package.json | 3 +-
.../projects/[projectId]/settings/labels.tsx | 21 +-
web/store/label.store.ts | 75 ++++---
yarn.lock | 8 +
14 files changed, 517 insertions(+), 334 deletions(-)
create mode 100644 web/components/labels/label-drag-n-drop-HOC.tsx
create mode 100644 web/components/labels/label-utils.ts
diff --git a/packages/types/src/pragmatic.d.ts b/packages/types/src/pragmatic.d.ts
index ca47e2d37a..439e2b54fb 100644
--- a/packages/types/src/pragmatic.d.ts
+++ b/packages/types/src/pragmatic.d.ts
@@ -8,18 +8,27 @@ export type TDropTargetMiscellaneousData = {
isActiveDueToStickiness: boolean;
};
-export interface IPragmaticDropPayload {
- location: {
- initial: {
- dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[];
- };
- current: {
- dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[];
- };
- previous: {
- dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[];
- };
+export interface IPragmaticPayloadLocation {
+ initial: {
+ dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[];
};
+ current: {
+ dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[];
+ };
+ previous: {
+ dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[];
+ };
+}
+
+export interface IPragmaticDropPayload {
+ location: IPragmaticPayloadLocation;
source: TDropTarget;
self: TDropTarget & TDropTargetMiscellaneousData;
}
+
+export type InstructionType =
+ | "reparent"
+ | "reorder-above"
+ | "reorder-below"
+ | "make-child"
+ | "instruction-blocked";
\ No newline at end of file
diff --git a/packages/ui/src/drop-indicator.tsx b/packages/ui/src/drop-indicator.tsx
index 228c1a3301..7ffc83a4ba 100644
--- a/packages/ui/src/drop-indicator.tsx
+++ b/packages/ui/src/drop-indicator.tsx
@@ -3,9 +3,12 @@ import { cn } from "../helpers";
type Props = {
isVisible: boolean;
+ classNames?: string;
};
export const DropIndicator = (props: Props) => {
+ const { isVisible, classNames = "" } = props;
+
return (
{
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,
- }
+ "bg-custom-primary-100 before:bg-custom-primary-100 after:bg-custom-primary-100": isVisible,
+ },
+ classNames
)}
/>
);
diff --git a/web/components/labels/label-block/drag-handle.tsx b/web/components/labels/label-block/drag-handle.tsx
index a71637bb94..64aaa075f6 100644
--- a/web/components/labels/label-block/drag-handle.tsx
+++ b/web/components/labels/label-block/drag-handle.tsx
@@ -1,24 +1,25 @@
-import { DraggableProvidedDragHandleProps } from "@hello-pangea/dnd";
+import { forwardRef } from "react";
import { MoreVertical } from "lucide-react";
interface IDragHandle {
isDragging: boolean;
- dragHandleProps: DraggableProvidedDragHandleProps;
}
-export const DragHandle = (props: IDragHandle) => {
- const { isDragging, dragHandleProps } = props;
+export const DragHandle = forwardRef
((props, ref) => {
+ const { isDragging } = props;
return (
);
-};
+});
+
+DragHandle.displayName = "DragHandle";
diff --git a/web/components/labels/label-block/label-item-block.tsx b/web/components/labels/label-block/label-item-block.tsx
index d154060593..e3236c91d2 100644
--- a/web/components/labels/label-block/label-item-block.tsx
+++ b/web/components/labels/label-block/label-item-block.tsx
@@ -1,5 +1,4 @@
-import { useRef, useState } from "react";
-import { DraggableProvidedDragHandleProps } from "@hello-pangea/dnd";
+import { MutableRefObject, useRef, useState } from "react";
import { LucideIcon, X } from "lucide-react";
import { IIssueLabel } from "@plane/types";
//ui
@@ -24,13 +23,13 @@ interface ILabelItemBlock {
label: IIssueLabel;
isDragging: boolean;
customMenuItems: ICustomMenuItem[];
- dragHandleProps: DraggableProvidedDragHandleProps;
handleLabelDelete: (label: IIssueLabel) => void;
isLabelGroup?: boolean;
+ dragHandleRef: MutableRefObject;
}
export const LabelItemBlock = (props: ILabelItemBlock) => {
- const { label, isDragging, customMenuItems, dragHandleProps, handleLabelDelete, isLabelGroup } = props;
+ const { label, isDragging, customMenuItems, handleLabelDelete, isLabelGroup, dragHandleRef } = props;
// states
const [isMenuActive, setIsMenuActive] = useState(false);
// refs
@@ -41,7 +40,7 @@ export const LabelItemBlock = (props: ILabelItemBlock) => {
return (
-
+
diff --git a/web/components/labels/label-drag-n-drop-HOC.tsx b/web/components/labels/label-drag-n-drop-HOC.tsx
new file mode 100644
index 0000000000..3898d6b7a3
--- /dev/null
+++ b/web/components/labels/label-drag-n-drop-HOC.tsx
@@ -0,0 +1,161 @@
+import { MutableRefObject, useEffect, useRef, useState } from "react";
+import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
+import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
+import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
+import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
+import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
+import { observer } from "mobx-react";
+import { createRoot } from "react-dom/client";
+// types
+import { IIssueLabel, InstructionType } from "@plane/types";
+// ui
+import { DropIndicator } from "@plane/ui";
+// components
+import { LabelName } from "./label-block/label-name";
+import { TargetData, getCanDrop, getInstructionFromPayload } from "./label-utils";
+
+type LabelDragPreviewProps = {
+ label: IIssueLabel;
+ isGroup: boolean;
+};
+
+export const LabelDragPreview = (props: LabelDragPreviewProps) => {
+ const { label, isGroup } = props;
+
+ return (
+
+
+
+ );
+};
+
+type Props = {
+ label: IIssueLabel;
+ isGroup: boolean;
+ isChild: boolean;
+ isLastChild: boolean;
+ children: (
+ isDragging: boolean,
+ isDroppingInLabel: boolean,
+ dragHandleRef: MutableRefObject
+ ) => JSX.Element;
+ onDrop: (
+ draggingLabelId: string,
+ droppedParentId: string | null,
+ droppedLabelId: string | undefined,
+ dropAtEndOfList: boolean
+ ) => void;
+};
+
+export const LabelDndHOC = observer((props: Props) => {
+ const { label, isGroup, isChild, isLastChild, children, onDrop } = props;
+
+ const [isDragging, setIsDragging] = useState(false);
+ const [instruction, setInstruction] = useState(undefined);
+ // refs
+ const labelRef = useRef(null);
+ const dragHandleRef = useRef(null);
+
+ useEffect(() => {
+ const element = labelRef.current;
+ const dragHandleElement = dragHandleRef.current;
+
+ if (!element) return;
+
+ return combine(
+ draggable({
+ element,
+ dragHandle: dragHandleElement ?? undefined,
+ getInitialData: () => ({ id: label?.id, parentId: label?.parent, isGroup, isChild }),
+ onDragStart: () => {
+ setIsDragging(true);
+ },
+ onDrop: () => {
+ setIsDragging(false);
+ },
+ onGenerateDragPreview: ({ nativeSetDragImage }) => {
+ setCustomNativeDragPreview({
+ getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
+ render: ({ container }) => {
+ const root = createRoot(container);
+ root.render( );
+ return () => root.unmount();
+ },
+ nativeSetDragImage,
+ });
+ },
+ }),
+ dropTargetForElements({
+ element,
+ canDrop: ({ source }) => getCanDrop(source, label, isChild),
+ getData: ({ input, element }) => {
+ const data = { id: label?.id, parentId: label?.parent, isGroup, isChild };
+
+ const blockedStates: InstructionType[] = [];
+
+ // if is currently a child then block make-child instruction
+ if (isChild) blockedStates.push("make-child");
+ // if is currently is not a last child then block reorder-below instruction
+ if (!isLastChild) blockedStates.push("reorder-below");
+
+ return attachInstruction(data, {
+ input,
+ element,
+ currentLevel: isChild ? 1 : 0,
+ indentPerLevel: 0,
+ mode: isLastChild ? "last-in-group" : "standard",
+ block: blockedStates,
+ });
+ },
+ onDrag: ({ self, source, location }) => {
+ const instruction = getInstructionFromPayload(self, source, location);
+ setInstruction(instruction);
+ },
+ onDragLeave: () => {
+ setInstruction(undefined);
+ },
+ onDrop: ({ source, location }) => {
+ setInstruction(undefined);
+
+ const dropTargets = location?.current?.dropTargets ?? [];
+
+ if (isChild || !dropTargets || dropTargets.length <= 0) return;
+
+ // if the label is dropped on both a child and it's parent at the same time then get only the child's drop target
+ const dropTarget =
+ dropTargets.length > 1 ? dropTargets.find((target) => target?.data?.isChild) : dropTargets[0];
+
+ let parentId: string | null = null,
+ dropAtEndOfList = false;
+
+ const dropTargetData = dropTarget?.data as TargetData;
+
+ if (!dropTarget || !dropTargetData) return;
+
+ // get possible instructions for the dropTarget
+ const instruction = getInstructionFromPayload(dropTarget, source, location);
+
+ // if instruction is make child the set parentId as current dropTarget Id or else set it as dropTarget's parentId
+ parentId = instruction === "make-child" ? dropTargetData.id : dropTargetData.parentId;
+ // if instruction is any other than make-child, i.e., reorder-above and reorder-below then set the droppedId as dropTarget's id
+ const droppedLabelId = instruction !== "make-child" ? dropTargetData.id : undefined;
+ // if instruction is to reorder-below that is enabled only for end of the last items in the list then dropAtEndOfList as true
+ if (instruction === "reorder-below") dropAtEndOfList = true;
+
+ const sourceData = source.data as TargetData;
+ if (sourceData.id) onDrop(sourceData.id as string, parentId, droppedLabelId, dropAtEndOfList);
+ },
+ })
+ );
+ }, [labelRef?.current, dragHandleRef?.current, label, isChild, isGroup, isLastChild, onDrop]);
+
+ const isMakeChild = instruction == "make-child";
+
+ return (
+
+
+ {children(isDragging, isMakeChild, dragHandleRef)}
+ {isLastChild && }
+
+ );
+});
diff --git a/web/components/labels/label-utils.ts b/web/components/labels/label-utils.ts
new file mode 100644
index 0000000000..1919e53f3d
--- /dev/null
+++ b/web/components/labels/label-utils.ts
@@ -0,0 +1,67 @@
+import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
+import { IIssueLabel, IPragmaticPayloadLocation, InstructionType, TDropTarget } from "@plane/types";
+
+export type TargetData = {
+ id: string;
+ parentId: string | null;
+ isGroup: boolean;
+ isChild: boolean;
+};
+
+/**
+ * extracts the Payload and translates the instruction for the current dropTarget based on drag and drop payload
+ * @param dropTarget dropTarget for which the instruction is required
+ * @param source the dragging label data that is being dragged on the dropTarget
+ * @param location location includes the data of all the dropTargets the source is being dragged on
+ * @returns Instruction for dropTarget
+ */
+export const getInstructionFromPayload = (
+ dropTarget: TDropTarget,
+ source: TDropTarget,
+ location: IPragmaticPayloadLocation
+): InstructionType | undefined => {
+ const dropTargetData = dropTarget?.data as TargetData;
+ const sourceData = source?.data as TargetData;
+
+ const allDropTargets = location?.current?.dropTargets;
+
+ // if all the dropTargets are greater than 1 meaning the source is being dragged on a group and its child at the same time
+ // and also if the dropTarget in question is also a group then, it should be a child of the current Droptarget
+ if (allDropTargets?.length > 1 && dropTargetData?.isGroup) return "make-child";
+
+ if (!dropTargetData || !sourceData) return undefined;
+
+ let instruction = extractInstruction(dropTargetData)?.type;
+
+ // If the instruction is blocked then set an instruction based on if dropTarget it is a child or not
+ if (instruction === "instruction-blocked") {
+ instruction = dropTargetData.isChild ? "reorder-above" : "make-child";
+ }
+
+ // if source that is being dragged is a group. A group cannon be a child of any other label,
+ // hence if current instruction is to be a child of dropTarget then reorder-above instead
+ if (instruction === "make-child" && sourceData.isGroup) instruction = "reorder-above";
+
+ return instruction;
+};
+
+/**
+ * This provides a boolean to indicate if the label can be dropped onto the droptarget
+ * @param source
+ * @param label
+ * @param isCurrentChild if the dropTarget is a child
+ * @returns
+ */
+export const getCanDrop = (source: TDropTarget, label: IIssueLabel | undefined, isCurrentChild: boolean) => {
+ const sourceData = source?.data;
+
+ if (!sourceData) return false;
+
+ // a label cannot be dropped on to itself and it's parent cannon be dropped on the child
+ if (sourceData.id === label?.id || sourceData.id === label?.parent) return false;
+
+ // if current dropTarget is a child and the label being dropped is a group then don't enable drop
+ if (isCurrentChild && sourceData.isGroup) return false;
+
+ return true;
+};
diff --git a/web/components/labels/project-setting-label-group.tsx b/web/components/labels/project-setting-label-group.tsx
index 0a3efc9df6..3302cb6bf1 100644
--- a/web/components/labels/project-setting-label-group.tsx
+++ b/web/components/labels/project-setting-label-group.tsx
@@ -1,51 +1,38 @@
import React, { Dispatch, SetStateAction, useState } from "react";
-import {
- Draggable,
- DraggableProvided,
- DraggableProvidedDragHandleProps,
- DraggableStateSnapshot,
- Droppable,
-} from "@hello-pangea/dnd";
import { observer } from "mobx-react-lite";
import { ChevronDown, Pencil, Trash2 } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
-
// store
// icons
-import { IIssueLabel } from "@plane/types";
// types
-import useDraggableInPortal from "@/hooks/use-draggable-portal";
+import { IIssueLabel } from "@plane/types";
+// components
import { CreateUpdateLabelInline } from "./create-update-label-inline";
import { ICustomMenuItem, LabelItemBlock } from "./label-block/label-item-block";
+import { LabelDndHOC } from "./label-drag-n-drop-HOC";
import { ProjectSettingLabelItem } from "./project-setting-label-item";
type Props = {
label: IIssueLabel;
labelChildren: IIssueLabel[];
handleLabelDelete: (label: IIssueLabel) => void;
- dragHandleProps: DraggableProvidedDragHandleProps;
- draggableSnapshot: DraggableStateSnapshot;
isUpdating: boolean;
setIsUpdating: Dispatch>;
- isDropDisabled: boolean;
+ isLastChild: boolean;
+ onDrop: (
+ draggingLabelId: string,
+ droppedParentId: string | null,
+ droppedLabelId: string | undefined,
+ dropAtEndOfList: boolean
+ ) => void;
};
export const ProjectSettingLabelGroup: React.FC = observer((props) => {
- const {
- label,
- labelChildren,
- handleLabelDelete,
- draggableSnapshot: groupDragSnapshot,
- dragHandleProps,
- isUpdating,
- setIsUpdating,
- isDropDisabled,
- } = props;
+ const { label, labelChildren, handleLabelDelete, isUpdating, setIsUpdating, isLastChild, onDrop } = props;
+ // states
const [isEditLabelForm, setEditLabelForm] = useState(false);
- const renderDraggable = useDraggableInPortal();
-
const customMenuItems: ICustomMenuItem[] = [
{
CustomIcon: Pencil,
@@ -67,101 +54,89 @@ export const ProjectSettingLabelGroup: React.FC = observer((props) => {
];
return (
-
- {({ open }) => (
- <>
-
+ {(isDragging, isDroppingInLabel, dragHandleRef) => (
+
+
- {(droppableProvided) => (
-
- <>
-
- {isEditLabelForm ? (
-
{
- setEditLabelForm(false);
- setIsUpdating(false);
- }}
- />
- ) : (
-
- )}
-
-
-
- (
+ <>
+
+ <>
+
+ {isEditLabelForm ? (
+ {
+ setEditLabelForm(false);
+ setIsUpdating(false);
+ }}
/>
-
-
-
-
-
-
- {labelChildren.map((child, index) => (
-
-
- {renderDraggable((provided: DraggableProvided, snapshot: DraggableStateSnapshot) => (
-
-
handleLabelDelete(child)}
- draggableSnapshot={snapshot}
- dragHandleProps={provided.dragHandleProps!}
- setIsUpdating={setIsUpdating}
- isChild
- />
-
- ))}
-
-
- ))}
-
-
-
- {droppableProvided.placeholder}
- >
-
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {labelChildren.map((child, index) => (
+
+
+
handleLabelDelete(child)}
+ setIsUpdating={setIsUpdating}
+ isParentDragging={isDragging}
+ isChild
+ isLastChild={index === labelChildren.length - 1}
+ onDrop={onDrop}
+ />
+
+
+ ))}
+
+
+
+ >
+
+ >
)}
-
- >
+
+
)}
-
+
);
});
diff --git a/web/components/labels/project-setting-label-item.tsx b/web/components/labels/project-setting-label-item.tsx
index d8bbee7191..0a596fe413 100644
--- a/web/components/labels/project-setting-label-item.tsx
+++ b/web/components/labels/project-setting-label-item.tsx
@@ -1,27 +1,32 @@
import React, { Dispatch, SetStateAction, useState } from "react";
-import { DraggableProvidedDragHandleProps, DraggableStateSnapshot } from "@hello-pangea/dnd";
import { useRouter } from "next/router";
import { X, Pencil } from "lucide-react";
+// types
import { IIssueLabel } from "@plane/types";
// hooks
import { useLabel } from "@/hooks/store";
-// types
// components
import { CreateUpdateLabelInline } from "./create-update-label-inline";
import { ICustomMenuItem, LabelItemBlock } from "./label-block/label-item-block";
+import { LabelDndHOC } from "./label-drag-n-drop-HOC";
type Props = {
label: IIssueLabel;
handleLabelDelete: (label: IIssueLabel) => void;
- draggableSnapshot: DraggableStateSnapshot;
- dragHandleProps: DraggableProvidedDragHandleProps;
setIsUpdating: Dispatch>;
+ isParentDragging?: boolean;
isChild: boolean;
+ isLastChild: boolean;
+ onDrop: (
+ draggingLabelId: string,
+ droppedParentId: string | null,
+ droppedLabelId: string | undefined,
+ dropAtEndOfList: boolean
+ ) => void;
};
export const ProjectSettingLabelItem: React.FC = (props) => {
- const { label, setIsUpdating, handleLabelDelete, draggableSnapshot, dragHandleProps, isChild } = props;
- const { combineTargetFor, isDragging } = draggableSnapshot;
+ const { label, setIsUpdating, handleLabelDelete, isChild, isLastChild, isParentDragging = false, onDrop } = props;
// states
const [isEditLabelForm, setEditLabelForm] = useState(false);
// router
@@ -59,31 +64,39 @@ export const ProjectSettingLabelItem: React.FC = (props) => {
];
return (
-
- {isEditLabelForm ? (
-
{
- setEditLabelForm(false);
- setIsUpdating(false);
- }}
- />
- ) : (
-
+
+ {(isDragging, isDroppingInLabel, dragHandleRef) => (
+
+
+ {isEditLabelForm ? (
+ {
+ setEditLabelForm(false);
+ setIsUpdating(false);
+ }}
+ />
+ ) : (
+
+ )}
+
+
)}
-
+
);
};
diff --git a/web/components/labels/project-setting-label-list.tsx b/web/components/labels/project-setting-label-list.tsx
index bbc4023d10..8de57e8b20 100644
--- a/web/components/labels/project-setting-label-list.tsx
+++ b/web/components/labels/project-setting-label-list.tsx
@@ -1,12 +1,4 @@
import React, { useState, useRef } from "react";
-import {
- DragDropContext,
- Draggable,
- DraggableProvided,
- DraggableStateSnapshot,
- DropResult,
- Droppable,
-} from "@hello-pangea/dnd";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { IIssueLabel } from "@plane/types";
@@ -21,20 +13,16 @@ import {
} from "@/components/labels";
import { EmptyStateType } from "@/constants/empty-state";
import { useLabel } from "@/hooks/store";
-import useDraggableInPortal from "@/hooks/use-draggable-portal";
// components
// ui
// types
// constants
-const LABELS_ROOT = "labels.root";
-
export const ProjectSettingsLabelList: React.FC = observer(() => {
// states
const [showLabelForm, setLabelForm] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [selectDeleteLabel, setSelectDeleteLabel] = useState(null);
- const [isDraggingGroup, setIsDraggingGroup] = useState(false);
// refs
const scrollToRef = useRef(null);
// router
@@ -42,44 +30,28 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
const { workspaceSlug, projectId } = router.query;
// store hooks
const { projectLabels, updateLabelPosition, projectLabelsTree } = useLabel();
- // portal
- const renderDraggable = useDraggableInPortal();
const newLabel = () => {
setIsUpdating(false);
setLabelForm(true);
};
- const onDragEnd = (result: DropResult) => {
- const { combine, draggableId, destination, source } = result;
-
- // return if dropped outside the DragDropContext
- if (!combine && !destination) return;
-
- const childLabel = draggableId.split(".")[2];
- let parentLabel: string | undefined | null = destination?.droppableId?.split(".")[3];
- const index = destination?.index || 0;
-
- const prevParentLabel: string | undefined | null = source?.droppableId?.split(".")[3];
- const prevIndex = source?.index;
-
- if (combine && combine.draggableId) parentLabel = combine?.draggableId?.split(".")[2];
-
- if (destination?.droppableId === LABELS_ROOT) parentLabel = null;
-
- if (result.reason == "DROP" && childLabel != parentLabel) {
- if (workspaceSlug && projectId) {
- updateLabelPosition(
- workspaceSlug?.toString(),
- projectId?.toString(),
- childLabel,
- parentLabel,
- index,
- prevParentLabel == parentLabel,
- prevIndex
- );
- return;
- }
+ const onDrop = (
+ draggingLabelId: string,
+ droppedParentId: string | null,
+ droppedLabelId: string | undefined,
+ dropAtEndOfList: boolean
+ ) => {
+ if (workspaceSlug && projectId) {
+ updateLabelPosition(
+ workspaceSlug?.toString(),
+ projectId?.toString(),
+ draggingLabelId,
+ droppedParentId,
+ droppedLabelId,
+ dropAtEndOfList
+ );
+ return;
}
};
@@ -118,82 +90,35 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
) : (
projectLabelsTree && (
-
-
- {(droppableProvided, droppableSnapshot) => (
-
- {projectLabelsTree.map((label, index) => {
- if (label.children && label.children.length) {
- return (
-
- {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => {
- const isGroup = droppableSnapshot.draggingFromThisWith?.split(".")[3] === "group";
- setIsDraggingGroup(isGroup);
-
- return (
-
-
setSelectDeleteLabel(label)}
- draggableSnapshot={snapshot}
- isUpdating={isUpdating}
- setIsUpdating={setIsUpdating}
- />
-
- );
- }}
-
- );
- }
- return (
-
- {renderDraggable((provided: DraggableProvided, snapshot: DraggableStateSnapshot) => (
-
-
setSelectDeleteLabel(label)}
- isChild={false}
- />
-
- ))}
-
- );
- })}
- {droppableProvided.placeholder}
-
- )}
-
-
+
+ {projectLabelsTree.map((label, index) => {
+ if (label.children && label.children.length) {
+ return (
+
setSelectDeleteLabel(label)}
+ isUpdating={isUpdating}
+ setIsUpdating={setIsUpdating}
+ isLastChild={index === projectLabelsTree.length - 1}
+ onDrop={onDrop}
+ />
+ );
+ }
+ return (
+ setSelectDeleteLabel(label)}
+ isChild={false}
+ isLastChild={index === projectLabelsTree.length - 1}
+ onDrop={onDrop}
+ />
+ );
+ })}
+
)
)
) : (
diff --git a/web/helpers/array.helper.ts b/web/helpers/array.helper.ts
index 45b0b5739f..c2799bfa20 100644
--- a/web/helpers/array.helper.ts
+++ b/web/helpers/array.helper.ts
@@ -1,4 +1,4 @@
-import { IIssueLabelTree } from "@plane/types";
+import { IIssueLabel, IIssueLabelTree } from "@plane/types";
export const groupBy = (array: any[], key: string) => {
const innerKey = key.split("."); // split the key by dot
@@ -77,7 +77,7 @@ export const orderGroupedDataByField = (groupedData: GroupedItems, orderBy
return groupedData;
};
-export const buildTree = (array: any[], parent = null) => {
+export const buildTree = (array: IIssueLabel[], parent = null) => {
const tree: IIssueLabelTree[] = [];
array.forEach((item: any) => {
diff --git a/web/package.json b/web/package.json
index fbdb9b07ae..468664f454 100644
--- a/web/package.json
+++ b/web/package.json
@@ -14,6 +14,7 @@
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.1.3",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.0.3",
+ "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@blueprintjs/popover2": "^1.13.3",
"@headlessui/react": "^1.7.3",
"@hello-pangea/dnd": "^16.3.0",
@@ -63,7 +64,6 @@
"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",
@@ -74,6 +74,7 @@
"@types/react-dom": "^18.2.17",
"@types/uuid": "^8.3.4",
"eslint-config-custom": "*",
+ "prettier": "^3.2.5",
"tailwind-config-custom": "*",
"tsconfig": "*",
"typescript": "4.7.4"
diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/labels.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/labels.tsx
index 9f4361c96b..cf13b6d257 100644
--- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/labels.tsx
+++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/labels.tsx
@@ -1,4 +1,6 @@
-import { ReactElement } from "react";
+import { ReactElement, useEffect, useRef } from "react";
+import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
+import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
// layouts
import { PageHead } from "@/components/core";
@@ -16,10 +18,25 @@ const LabelsSettingsPage: NextPageWithLayout = observer(() => {
const { currentProjectDetails } = useProject();
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Labels` : undefined;
+ const scrollableContainerRef = useRef(null);
+
+ // Enable Auto Scroll for Labels list
+ useEffect(() => {
+ const element = scrollableContainerRef.current;
+
+ if (!element) return;
+
+ return combine(
+ autoScrollForElements({
+ element,
+ })
+ );
+ }, [scrollableContainerRef?.current]);
+
return (
<>
-
+
>
diff --git a/web/store/label.store.ts b/web/store/label.store.ts
index b91b9db150..3f343f9a4d 100644
--- a/web/store/label.store.ts
+++ b/web/store/label.store.ts
@@ -36,12 +36,11 @@ export interface ILabelStore {
updateLabelPosition: (
workspaceSlug: string,
projectId: string,
- labelId: string,
- parentId: string | null | undefined,
- index: number,
- isSameParent: boolean,
- prevIndex: number | undefined
- ) => Promise
;
+ draggingLabelId: string,
+ droppedParentId: string | null,
+ droppedLabelId: string | undefined,
+ dropAtEndOfList: boolean
+ ) => Promise;
deleteLabel: (workspaceSlug: string, projectId: string, labelId: string) => Promise;
}
@@ -81,8 +80,8 @@ export class LabelStore implements ILabelStore {
*/
get workspaceLabels() {
const currentWorkspaceDetails = this.rootStore.workspaceRoot.currentWorkspace;
- const worksapceSlug = this.rootStore.app.router.workspaceSlug || "";
- if (!currentWorkspaceDetails || !this.fetchedMap[worksapceSlug]) return;
+ const workspaceSlug = this.rootStore.app.router.workspaceSlug || "";
+ if (!currentWorkspaceDetails || !this.fetchedMap[workspaceSlug]) return;
return sortBy(
Object.values(this.labelMap).filter((label) => label.workspace_id === currentWorkspaceDetails.id),
"sort_order"
@@ -94,8 +93,8 @@ export class LabelStore implements ILabelStore {
*/
get projectLabels() {
const projectId = this.rootStore.app.router.projectId;
- const worksapceSlug = this.rootStore.app.router.workspaceSlug || "";
- if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[worksapceSlug])) return;
+ const workspaceSlug = this.rootStore.app.router.workspaceSlug || "";
+ if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return;
return sortBy(
Object.values(this.labelMap).filter((label) => label.project_id === projectId),
"sort_order"
@@ -111,8 +110,8 @@ export class LabelStore implements ILabelStore {
}
getProjectLabels = computedFn((projectId: string | null) => {
- const worksapceSlug = this.rootStore.app.router.workspaceSlug || "";
- if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[worksapceSlug])) return;
+ const workspaceSlug = this.rootStore.app.router.workspaceSlug || "";
+ if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return;
return sortBy(
Object.values(this.labelMap).filter((label) => label.project_id === projectId),
"sort_order"
@@ -186,7 +185,7 @@ export class LabelStore implements ILabelStore {
const originalLabel = this.labelMap[labelId];
try {
runInAction(() => {
- set(this.labelMap, [labelId], { ...this.labelMap[labelId], ...data });
+ set(this.labelMap, [labelId], { ...originalLabel, ...data });
});
const response = await this.issueLabelService.patchIssueLabel(workspaceSlug, projectId, labelId, data);
return response;
@@ -213,50 +212,54 @@ export class LabelStore implements ILabelStore {
updateLabelPosition = async (
workspaceSlug: string,
projectId: string,
- labelId: string,
- parentId: string | null | undefined,
- index: number,
- isSameParent: boolean,
- prevIndex: number | undefined
+ draggingLabelId: string,
+ droppedParentId: string | null,
+ droppedLabelId: string | undefined,
+ dropAtEndOfList: boolean
) => {
- const currLabel = this.labelMap?.[labelId];
+ const currLabel = this.labelMap?.[draggingLabelId];
const labelTree = this.projectLabelsTree;
let currentArray: IIssueLabel[];
if (!currLabel || !labelTree) return;
- const data: Partial = { parent: parentId };
- //find array in which the label is to be added
- if (!parentId) currentArray = labelTree;
- else currentArray = labelTree?.find((label) => label.id === parentId)?.children || [];
+ //If its is dropped in the same parent then, there is not specific label on which it is mentioned then keep it's original position
+ if (currLabel.parent === droppedParentId && !droppedLabelId) return;
- //Add the array at the destination
- if (isSameParent && prevIndex !== undefined) currentArray.splice(prevIndex, 1);
- currentArray.splice(index, 0, currLabel);
+ const data: Partial = { parent: droppedParentId };
+
+ // find array in which the label is to be added
+ if (!droppedParentId) currentArray = labelTree;
+ else currentArray = labelTree?.find((label) => label.id === droppedParentId)?.children || [];
+
+ let droppedLabelIndex = currentArray.findIndex((label) => label.id === droppedLabelId);
+ //if the position of droppedLabelId cannot be determined then drop it at the end of the list
+ if (dropAtEndOfList || droppedLabelIndex === -1) droppedLabelIndex = currentArray.length;
//if currently adding to a new array, then let backend assign a sort order
- if (currentArray.length > 1) {
+ if (currentArray.length > 0) {
let prevSortOrder: number | undefined, nextSortOrder: number | undefined;
- if (typeof currentArray[index - 1] !== "undefined") {
- prevSortOrder = currentArray[index - 1].sort_order;
+ if (typeof currentArray[droppedLabelIndex - 1] !== "undefined") {
+ prevSortOrder = currentArray[droppedLabelIndex - 1].sort_order;
}
- if (typeof currentArray[index + 1] !== "undefined") {
- nextSortOrder = currentArray[index + 1].sort_order;
+ if (typeof currentArray[droppedLabelIndex] !== "undefined") {
+ nextSortOrder = currentArray[droppedLabelIndex].sort_order;
}
- let sortOrder: number;
+ let sortOrder: number = 65535;
//based on the next and previous labelMap calculate current sort order
if (prevSortOrder && nextSortOrder) {
sortOrder = (prevSortOrder + nextSortOrder) / 2;
} else if (nextSortOrder) {
- sortOrder = nextSortOrder + 10000;
- } else {
- sortOrder = prevSortOrder! / 2;
+ sortOrder = nextSortOrder / 2;
+ } else if (prevSortOrder) {
+ sortOrder = prevSortOrder + 10000;
}
data.sort_order = sortOrder;
}
- return this.updateLabel(workspaceSlug, projectId, labelId, data);
+
+ return this.updateLabel(workspaceSlug, projectId, draggingLabelId, data);
};
/**
diff --git a/yarn.lock b/yarn.lock
index 9a3d22cf3d..a33c24f885 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -37,6 +37,14 @@
"@atlaskit/pragmatic-drag-and-drop" "^1.1.0"
"@babel/runtime" "^7.0.0"
+"@atlaskit/pragmatic-drag-and-drop-hitbox@^1.0.3":
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-1.0.3.tgz#fcce42f5e2c5a26007f4422397acad29608d3784"
+ integrity sha512-/Sbu/HqN2VGLYBhnsG7SbRNg98XKkbF6L7XDdBi+izRybfaK1FeMfodPpm/xnBHPJzwYMdkE0qtLyv6afhgMUA==
+ dependencies:
+ "@atlaskit/pragmatic-drag-and-drop" "^1.1.0"
+ "@babel/runtime" "^7.0.0"
+
"@atlaskit/pragmatic-drag-and-drop@^1.1.0", "@atlaskit/pragmatic-drag-and-drop@^1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.1.3.tgz#ecbfa4dcd2f9bf9b87f3d1565cedb2661d1fae0a"
From f0cb48006f806f66a4f2b3927cd1333bb1e55d94 Mon Sep 17 00:00:00 2001
From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Date: Wed, 17 Apr 2024 19:41:34 +0530
Subject: [PATCH 3/6] [WEB-1024] fix: textarea component auto-resize (#4221)
* chore: updated resize hook logic
* fix: page title overflow issue
* chore: add length validation to page title
---
packages/ui/src/form-fields/textarea.tsx | 27 +++------
.../ui/src/hooks/use-auto-resize-textarea.ts | 24 ++++++++
web/components/pages/editor/editor-body.tsx | 2 +-
web/components/pages/editor/title.tsx | 57 ++++++++++++++-----
4 files changed, 74 insertions(+), 36 deletions(-)
create mode 100644 packages/ui/src/hooks/use-auto-resize-textarea.ts
diff --git a/packages/ui/src/form-fields/textarea.tsx b/packages/ui/src/form-fields/textarea.tsx
index 271b76d83a..de225d68f3 100644
--- a/packages/ui/src/form-fields/textarea.tsx
+++ b/packages/ui/src/form-fields/textarea.tsx
@@ -1,6 +1,8 @@
-import * as React from "react";
+import React, { useRef } from "react";
// helpers
import { cn } from "../../helpers";
+// hooks
+import { useAutoResizeTextArea } from "../hooks/use-auto-resize-textarea";
export interface TextAreaProps extends React.TextareaHTMLAttributes {
mode?: "primary" | "transparent";
@@ -8,21 +10,6 @@ export interface TextAreaProps extends React.TextareaHTMLAttributes when the value changes.
-const useAutoSizeTextArea = (textAreaRef: HTMLTextAreaElement | null, value: any) => {
- React.useEffect(() => {
- if (textAreaRef) {
- // We need to reset the height momentarily to get the correct scrollHeight for the textarea
- textAreaRef.style.height = "0px";
- const scrollHeight = textAreaRef.scrollHeight;
-
- // We then set the height directly, outside of the render loop
- // Trying to set this with state or a ref will product an incorrect value.
- textAreaRef.style.height = scrollHeight + "px";
- }
- }, [textAreaRef, value]);
-};
-
const TextArea = React.forwardRef((props, ref) => {
const {
id,
@@ -35,10 +22,10 @@ const TextArea = React.forwardRef((props, re
className = "",
...rest
} = props;
-
- const textAreaRef = React.useRef(ref);
-
- useAutoSizeTextArea(textAreaRef?.current, value);
+ // refs
+ const textAreaRef = useRef(ref);
+ // auto re-size
+ useAutoResizeTextArea(textAreaRef);
return (