-
Notifications
You must be signed in to change notification settings - Fork 1.7k
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
base: preview
Are you sure you want to change the base?
Changes from 7 commits
0f641ec
12743b5
0a6cb1b
23b30d0
0bec7a1
a612ec7
b2a58a3
1b124c6
d1cc0e0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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"; | ||||||
|
@@ -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); | ||||||
|
||||||
// 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); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.",
+ });
+ });
+ }
|
||||||
|
||||||
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({ | ||||||
|
@@ -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]); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Avoid including mutable refs in the useEffect dependency array Including Apply this diff to adjust the dependency array: - }, [elementRef.current, isDragging, favorite.id, handleMoveToFolder]);
+ }, [isDragging, favorite.id, handleMoveToFolder]); 📝 Committable suggestion
Suggested change
|
||||||
|
||||||
|
||||||
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); | ||||||
|
||||||
|
@@ -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", | ||||||
|
@@ -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> | ||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add error handling to getGroupedFavorites call
The fetch operation should include error handling to manage potential failures gracefully.
Apply this diff:
Also applies to: 58-58