Skip to content
Merged
174 changes: 94 additions & 80 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,48 +26,41 @@ 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.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
Expand All @@ -73,89 +70,102 @@ 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)
}
},
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef.current, isDragging, favorite.id, handleOnDrop]);
}, [isDragging, favorite.id, handleMoveToFolder]);


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

Expand All @@ -174,10 +184,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 +327,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