Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WEB-2774] Chore: re-ordering functionality for entities in favorites. #6078

Open
wants to merge 9 commits into
base: preview
Choose a base branch
from
194 changes: 116 additions & 78 deletions web/core/components/workspace/sidebar/favorites/favorite-folder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@
import { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";

import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import uniqBy from "lodash/uniqBy";
import orderBy from "lodash/orderBy";
import { useParams } from "next/navigation";
import { createRoot } from "react-dom/client";
import { PenSquare, Star, MoreHorizontal, ChevronRight, GripVertical } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";

// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// ui
import { IFavorite } from "@plane/types";
import { IFavorite, InstructionType } from "@plane/types";
import { CustomMenu, Tooltip, DropIndicator, setToast, TOAST_TYPE, FavoriteFolderIcon, DragHandle } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
Expand All @@ -22,47 +26,47 @@ import { useFavorite } from "@/hooks/store/use-favorite";
import { usePlatformOS } from "@/hooks/use-platform-os";
// constants
import { FavoriteRoot } from "./favorite-items";
import { getDestinationStateSequence } from "./favorites.helpers";
import { getCanDrop, TargetData, getInstructionFromPayload, getDestinationStateSequence } from "./favorites.helpers";
import { NewFavoriteFolder } from "./new-fav-folder";

type Props = {
isLastChild: boolean;
favorite: IFavorite;
handleRemoveFromFavorites: (favorite: IFavorite) => void;
handleRemoveFromFavoritesFolder: (favoriteId: string) => void;
handleReorder: (favoriteId: string, sequence: number) => void;
};

export const FavoriteFolder: React.FC<Props> = (props) => {
const { favorite, handleRemoveFromFavorites, handleRemoveFromFavoritesFolder } = props;
const { favorite, handleRemoveFromFavorites, handleRemoveFromFavoritesFolder, handleReorder, isLastChild} = props;
// store hooks
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();

const { isMobile } = usePlatformOS();
const { moveFavorite, getGroupedFavorites, groupedFavorites, moveFavoriteFolder } = useFavorite();
const { getGroupedFavorites, groupedFavorites, moveFavoriteToFolder } = useFavorite();
const { workspaceSlug } = useParams();
// states
const [isMenuActive, setIsMenuActive] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [folderToRename, setFolderToRename] = useState<string | boolean | null>(null);
const [isDraggedOver, setIsDraggedOver] = useState(false);
const [closestEdge, setClosestEdge] = useState<string | null>(null);
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add error handling to getGroupedFavorites call

The fetch operation should include error handling to manage potential failures gracefully.

Apply this diff:

-if(!favorite.children) getGroupedFavorites(workspaceSlug.toString(), favorite.id);
+if(!favorite.children) {
+  getGroupedFavorites(workspaceSlug.toString(), favorite.id)
+    .catch((error) => {
+      setToast({
+        type: TOAST_TYPE.ERROR,
+        title: "Error!",
+        message: "Failed to fetch favorites.",
+      });
+    });
+}

Also applies to: 58-58


// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const elementRef = useRef<HTMLDivElement | null>(null);

!favorite.children && getGroupedFavorites(workspaceSlug.toString(), favorite.id);
if(!favorite.children) getGroupedFavorites(workspaceSlug.toString(), favorite.id);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error handling for grouped favorites fetch

The conditional fetch should include error handling to gracefully handle failures.

-  if(!favorite.children) getGroupedFavorites(workspaceSlug.toString(), favorite.id);
+  if(!favorite.children) {
+    getGroupedFavorites(workspaceSlug.toString(), favorite.id)
+      .catch((error) => {
+        setToast({
+          type: TOAST_TYPE.ERROR,
+          title: "Error!",
+          message: "Failed to fetch favorites.",
+        });
+      });
+  }

Committable suggestion skipped: line range outside the PR's diff.


const handleOnDrop = (source: string, destination: string) => {
moveFavorite(workspaceSlug.toString(), source, {
const handleMoveToFolder = (source: string, destination: string) => {
moveFavoriteToFolder(workspaceSlug.toString(), source, {
parent: destination,
})
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Favorite moved successfully.",
});
// setToast({
mathalav55 marked this conversation as resolved.
Show resolved Hide resolved
// type: TOAST_TYPE.SUCCESS,
// title: "Success!",
// message: "Favorite moved successfully.",
// });
})
.catch(() => {
setToast({
Expand All @@ -73,89 +77,119 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
});
};

const handleOnDropFolder = (payload: Partial<IFavorite>) => {
moveFavoriteFolder(workspaceSlug.toString(), favorite.id, payload)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Folder moved successfully.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to move folder.",
});
});
};

useEffect(() => {
const element = elementRef.current;

if (!element) return;
const initialData = { type: "PARENT", id: favorite.id, is_folder: favorite.is_folder };
const initialData = { id: favorite.id, isGroup: true, isChild: false };

return combine(
draggable({
element,
getInitialData: () => initialData,
onDragStart: () => setIsDragging(true),
onDrop: (data) => {
setIsDraggedOver(false);
if (!data.location.current.dropTargets[0]) return;
const destinationData = data.location.current.dropTargets[0].data;

if (favorite.id && destinationData) {
const edge = extractClosestEdge(destinationData) || undefined;
const payload = {
id: favorite.id,
sequence: Math.round(
getDestinationStateSequence(groupedFavorites, destinationData.id as string, edge) || 0
),
};

handleOnDropFolder(payload);
}
onGenerateDragPreview: ({ nativeSetDragImage }) =>{
setCustomNativeDragPreview({
getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
render: ({ container }) => {
const root = createRoot(container);
root.render(
<div className="rounded flex gap-1 bg-custom-background-100 text-sm p-1 pr-2">
<div className="size-5 grid place-items-center flex-shrink-0">
<FavoriteFolderIcon />
</div>
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{favorite.name}</p>
</div>
);
return () => root.unmount();
},
nativeSetDragImage,
});
},
onDrop: () => {
setIsDragging(false)
}, // canDrag: () => isDraggable,
}),
dropTargetForElements({
element,
getData: ({ input, element }) =>
attachClosestEdge(initialData, {
canDrop: ({ source }) => getCanDrop(source, favorite, false),
getData: ({ input, element }) =>{

const blockedStates: InstructionType[] = [];
if(!isLastChild){
blockedStates.push('reorder-below');
}

return attachInstruction(initialData,{
input,
element,
allowedEdges: ["top", "bottom"],
}),
onDragEnter: (args) => {
setIsDragging(true);
setIsDraggedOver(true);
args.source.data.is_folder && setClosestEdge(extractClosestEdge(args.self.data));
currentLevel: 0,
indentPerLevel: 0,
mode: isLastChild ? 'last-in-group' : 'standard',
block: blockedStates
})
},
onDragLeave: () => {
setIsDragging(false);
setIsDraggedOver(false);
setClosestEdge(null);
onDrag: ({source, self, location}) => {
const instruction = getInstructionFromPayload(self,source, location);
setInstruction(instruction);
},
onDragStart: () => {
setIsDragging(true);
onDragLeave: () => {
setInstruction(undefined);
},
onDrop: ({ self, source }) => {
setIsDragging(false);
setIsDraggedOver(false);
const sourceId = source?.data?.id as string | undefined;
const destinationId = self?.data?.id as string | undefined;
if (source.data.is_folder) return;
if (sourceId === destinationId) return;
if (!sourceId || !destinationId) return;
if (groupedFavorites[sourceId].parent === destinationId) return;
handleOnDrop(sourceId, destinationId);
onDrop: ({ source, location }) => {
setInstruction(undefined);

const dropTargets = location?.current?.dropTargets ?? []
if(!dropTargets || dropTargets.length <= 0) return;
const dropTarget = dropTargets.length > 1 ? dropTargets.find(target=>target?.data?.isChild) : dropTargets[0];

const dropTargetData = dropTarget?.data as TargetData;

if(!dropTarget || !dropTargetData) return;
const instruction = getInstructionFromPayload(dropTarget, source, location);
const parentId = instruction === 'make-child' ? dropTargetData.id : dropTargetData.parentId;
const droppedFavId = instruction !== "make-child" ? dropTargetData.id : undefined;
const sourceData = source.data as TargetData;

if(!sourceData.id) return
if(parentId){
if(parentId !== sourceData.parentId){
handleMoveToFolder(sourceData.id,parentId)
}
} else {
if(sourceData.isChild){
handleRemoveFromFavoritesFolder(sourceData.id)
}
}
if(droppedFavId){
if(instruction === 'make-child') return; /** Reorder iniside the folder skipped here. It is handled in root element */
const destinationSequence = getDestinationStateSequence(groupedFavorites,droppedFavId,instruction)
handleReorder(sourceData.id,destinationSequence || 0)
}


// if(parentId)
mathalav55 marked this conversation as resolved.
Show resolved Hide resolved
// handleMoveToFolder(sourceData.id,parentId);
// else
// handleRemoveFromFavoritesFolder(sourceData.id)

// setIsDragging(false);
// const sourceId = source?.data?.id as string | undefined;
// const destinationId = self?.data?.id as string | undefined;

// if (source.data.is_folder) return;
// if (sourceId === destinationId) return;
// if (!sourceId || !destinationId) return;
// if (groupedFavorites[sourceId].parent === destinationId) return;

// handleMoveToFolder(sourceId, destinationId);
},
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef.current, isDragging, favorite.id, handleOnDrop]);
}, [elementRef.current, isDragging, favorite.id, handleMoveToFolder]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid including mutable refs in the useEffect dependency array

Including elementRef.current in the dependency array of useEffect is unnecessary and can lead to unexpected behaviors. Mutable refs like elementRef.current do not trigger re-renders when they change, so it's safe to omit them from the dependency array.

Apply this diff to adjust the dependency array:

- }, [elementRef.current, isDragging, favorite.id, handleMoveToFolder]);
+ }, [isDragging, favorite.id, handleMoveToFolder]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
}, [elementRef.current, isDragging, favorite.id, handleMoveToFolder]);
}, [isDragging, favorite.id, handleMoveToFolder]);



useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));

Expand All @@ -174,10 +208,11 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
// id={`sidebar-${projectId}-${projectListType}`}
className={cn("relative", {
"bg-custom-sidebar-background-80 opacity-60": isDragging,
"border-[2px] border-custom-primary-100" : instruction === 'make-child'
})}
>
{/* draggable drop top indicator */}
<DropIndicator isVisible={isDraggedOver && closestEdge === "top"} />
<DropIndicator isVisible={instruction === "reorder-above"}/>
<div
className={cn(
"group/project-item relative w-full px-2 py-1.5 flex items-center rounded-md text-custom-sidebar-text-100 hover:bg-custom-sidebar-background-90",
Expand Down Expand Up @@ -316,21 +351,24 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
"px-2": !isSidebarCollapsed,
})}
>
{uniqBy(favorite.children, "id").map((child) => (
{orderBy(favorite.children,'sequence','desc').map((child,index) => (
<FavoriteRoot
key={child.id}
workspaceSlug={workspaceSlug.toString()}
favorite={child}
isLastChild={index === favorite.children.length - 1}
parentId={favorite.id}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}
favoriteMap={groupedFavorites}
handleReorder={handleReorder}
/>
))}
</Disclosure.Panel>
</Transition>
)}
{/* draggable drop bottom indicator */}
<DropIndicator isVisible={isDraggedOver && closestEdge === "bottom"} />{" "}
{ isLastChild && <DropIndicator isVisible={instruction === "reorder-below"} />}
</div>
)}
</Disclosure>
Expand Down
Loading
Loading