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

* fixed re order for favorites

* fixed lint errors

* added reorder

* fixed reorder inside folder

* fixed lint issues

* memoized reorder

* removed unnecessary comments

* seprated duplicate logic to a common file

* removed code comments
This commit is contained in:
Vamsi Krishna 2024-11-26 19:15:21 +05:30 committed by GitHub
parent 6376a09318
commit fa2e60101f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 336 additions and 155 deletions

View file

@ -2,27 +2,31 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { DragLocationHistory, ElementDragPayload, DropTargetRecord } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; 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 orderBy from "lodash/orderBy";
import uniqBy from "lodash/uniqBy";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { createRoot } from "react-dom/client";
import { PenSquare, Star, MoreHorizontal, ChevronRight, GripVertical } from "lucide-react"; import { PenSquare, Star, MoreHorizontal, ChevronRight, GripVertical } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
// plane helpers // plane helpers
import { useOutsideClickDetector } from "@plane/helpers"; import { useOutsideClickDetector } from "@plane/helpers";
// ui // ui
import { IFavorite } from "@plane/types"; import { IFavorite, InstructionType } from "@plane/types";
import { CustomMenu, Tooltip, DropIndicator, setToast, TOAST_TYPE, FavoriteFolderIcon, DragHandle } from "@plane/ui"; import { CustomMenu, Tooltip, DropIndicator, FavoriteFolderIcon, DragHandle } from "@plane/ui";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { useAppTheme } from "@/hooks/store"; import { useAppTheme } from "@/hooks/store";
import { useFavorite } from "@/hooks/store/use-favorite";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// constants // constants
import { FavoriteRoot } from "./favorite-items"; import { FavoriteRoot } from "./favorite-items";
import { getDestinationStateSequence } from "./favorites.helpers"; import { getCanDrop, getInstructionFromPayload } from "./favorites.helpers";
import { NewFavoriteFolder } from "./new-fav-folder"; import { NewFavoriteFolder } from "./new-fav-folder";
type Props = { type Props = {
@ -30,132 +34,95 @@ type Props = {
favorite: IFavorite; favorite: IFavorite;
handleRemoveFromFavorites: (favorite: IFavorite) => void; handleRemoveFromFavorites: (favorite: IFavorite) => void;
handleRemoveFromFavoritesFolder: (favoriteId: string) => void; handleRemoveFromFavoritesFolder: (favoriteId: string) => void;
handleReorder: (favoriteId: string, sequence: number) => void;
handleDrop: (self: DropTargetRecord,source: ElementDragPayload, location: DragLocationHistory) => void;
}; };
export const FavoriteFolder: React.FC<Props> = (props) => { export const FavoriteFolder: React.FC<Props> = (props) => {
const { favorite, handleRemoveFromFavorites, handleRemoveFromFavoritesFolder } = props; const { favorite, handleRemoveFromFavorites, isLastChild, handleDrop } = props;
// store hooks // store hooks
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme(); const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { moveFavorite, getGroupedFavorites, groupedFavorites, moveFavoriteFolder } = useFavorite();
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
// states // states
const [isMenuActive, setIsMenuActive] = useState(false); const [isMenuActive, setIsMenuActive] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [folderToRename, setFolderToRename] = useState<string | boolean | null>(null); const [folderToRename, setFolderToRename] = useState<string | boolean | null>(null);
const [isDraggedOver, setIsDraggedOver] = useState(false); const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
const [closestEdge, setClosestEdge] = useState<string | null>(null);
// refs // refs
const actionSectionRef = useRef<HTMLDivElement | null>(null); const actionSectionRef = useRef<HTMLDivElement | null>(null);
const elementRef = useRef<HTMLDivElement | null>(null); const elementRef = useRef<HTMLDivElement | null>(null);
!favorite.children && getGroupedFavorites(workspaceSlug.toString(), favorite.id);
const handleOnDrop = (source: string, destination: string) => {
moveFavorite(workspaceSlug.toString(), source, {
parent: destination,
})
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Favorite moved successfully.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to move favorite.",
});
});
};
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(() => { useEffect(() => {
const element = elementRef.current; const element = elementRef.current;
if (!element) return; 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( return combine(
draggable({ draggable({
element, element,
getInitialData: () => initialData, getInitialData: () => initialData,
onDragStart: () => setIsDragging(true), onDragStart: () => setIsDragging(true),
onDrop: (data) => { onGenerateDragPreview: ({ nativeSetDragImage }) =>{
setIsDraggedOver(false); setCustomNativeDragPreview({
if (!data.location.current.dropTargets[0]) return; getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
const destinationData = data.location.current.dropTargets[0].data; render: ({ container }) => {
const root = createRoot(container);
if (favorite.id && destinationData) { root.render(
const edge = extractClosestEdge(destinationData) || undefined; <div className="rounded flex gap-1 bg-custom-background-100 text-sm p-1 pr-2">
const payload = { <div className="size-5 grid place-items-center flex-shrink-0">
id: favorite.id, <FavoriteFolderIcon />
sequence: Math.round( </div>
getDestinationStateSequence(groupedFavorites, destinationData.id as string, edge) || 0 <p className="truncate text-sm font-medium text-custom-sidebar-text-200">{favorite.name}</p>
), </div>
}; );
return () => root.unmount();
handleOnDropFolder(payload); },
} nativeSetDragImage,
});
},
onDrop: () => {
setIsDragging(false)
}, // canDrag: () => isDraggable, }, // canDrag: () => isDraggable,
}), }),
dropTargetForElements({ dropTargetForElements({
element, element,
getData: ({ input, element }) => canDrop: ({ source }) => getCanDrop(source, favorite, false),
attachClosestEdge(initialData, { getData: ({ input, element }) =>{
const blockedStates: InstructionType[] = [];
if(!isLastChild){
blockedStates.push('reorder-below');
}
return attachInstruction(initialData,{
input, input,
element, element,
allowedEdges: ["top", "bottom"], currentLevel: 0,
}), indentPerLevel: 0,
onDragEnter: (args) => { mode: isLastChild ? 'last-in-group' : 'standard',
setIsDragging(true); block: blockedStates
setIsDraggedOver(true); })
args.source.data.is_folder && setClosestEdge(extractClosestEdge(args.self.data)); },
onDrag: ({source, self, location}) => {
const instruction = getInstructionFromPayload(self,source, location);
setInstruction(instruction);
}, },
onDragLeave: () => { onDragLeave: () => {
setIsDragging(false); setInstruction(undefined);
setIsDraggedOver(false);
setClosestEdge(null);
},
onDragStart: () => {
setIsDragging(true);
},
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: ({ self, source, location})=>{
setInstruction(undefined);
handleDrop(self, source,location);
}
}) })
); );
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef.current, isDragging, favorite.id, handleOnDrop]); }, [isDragging, favorite.id ]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
@ -174,10 +141,11 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
// id={`sidebar-${projectId}-${projectListType}`} // id={`sidebar-${projectId}-${projectListType}`}
className={cn("relative", { className={cn("relative", {
"bg-custom-sidebar-background-80 opacity-60": isDragging, "bg-custom-sidebar-background-80 opacity-60": isDragging,
"border-[2px] border-custom-primary-100" : instruction === 'make-child'
})} })}
> >
{/* draggable drop top indicator */} {/* draggable drop top indicator */}
<DropIndicator isVisible={isDraggedOver && closestEdge === "top"} /> <DropIndicator isVisible={instruction === "reorder-above"}/>
<div <div
className={cn( 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", "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 +284,22 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
"px-2": !isSidebarCollapsed, "px-2": !isSidebarCollapsed,
})} })}
> >
{uniqBy(favorite.children, "id").map((child) => ( {orderBy(favorite.children,'sequence','desc').map((child,index) => (
<FavoriteRoot <FavoriteRoot
key={child.id} key={child.id}
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
favorite={child} favorite={child}
isLastChild={index === favorite.children.length - 1}
parentId={favorite.id}
handleRemoveFromFavorites={handleRemoveFromFavorites} handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder} handleDrop={handleDrop}
favoriteMap={groupedFavorites}
/> />
))} ))}
</Disclosure.Panel> </Disclosure.Panel>
</Transition> </Transition>
)} )}
{/* draggable drop bottom indicator */} {/* draggable drop bottom indicator */}
<DropIndicator isVisible={isDraggedOver && closestEdge === "bottom"} />{" "} { isLastChild && <DropIndicator isVisible={instruction === "reorder-below"} />}
</div> </div>
)} )}
</Disclosure> </Disclosure>

View file

@ -2,13 +2,20 @@
import React, { FC, useEffect, useRef, useState } from "react"; import React, { FC, useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { DropTargetRecord, DragLocationHistory } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
import { draggable, dropTargetForElements, ElementDragPayload } 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 { observer } from "mobx-react";
// plane helpers // plane helpers
import { createRoot } from "react-dom/client";
import { useOutsideClickDetector } from "@plane/helpers"; import { useOutsideClickDetector } from "@plane/helpers";
// ui // ui
import { IFavorite } from "@plane/types"; import { IFavorite, InstructionType } from "@plane/types";
// components // components
import { DropIndicator } from "@plane/ui";
import { import {
FavoriteItemDragHandle, FavoriteItemDragHandle,
FavoriteItemQuickAction, FavoriteItemQuickAction,
@ -18,67 +25,114 @@ import {
// hooks // hooks
import { useAppTheme } from "@/hooks/store"; import { useAppTheme } from "@/hooks/store";
import { useFavoriteItemDetails } from "@/hooks/use-favorite-item-details"; import { useFavoriteItemDetails } from "@/hooks/use-favorite-item-details";
//helpers
import { getCanDrop, getInstructionFromPayload} from "../favorites.helpers";
type Props = { type Props = {
isLastChild: boolean;
parentId: string | undefined;
workspaceSlug: string; workspaceSlug: string;
favorite: IFavorite; favorite: IFavorite;
favoriteMap: Record<string, IFavorite>;
handleRemoveFromFavorites: (favorite: IFavorite) => void; handleRemoveFromFavorites: (favorite: IFavorite) => void;
handleRemoveFromFavoritesFolder: (favoriteId: string) => void; handleDrop: (self: DropTargetRecord,source: ElementDragPayload, location: DragLocationHistory) => void;
}; };
export const FavoriteRoot: FC<Props> = observer((props) => { export const FavoriteRoot: FC<Props> = observer((props) => {
// props // props
const { workspaceSlug, favorite, favoriteMap, handleRemoveFromFavorites, handleRemoveFromFavoritesFolder } = props; const {
isLastChild,
parentId,
workspaceSlug,
favorite,
handleRemoveFromFavorites,
handleDrop,
} = props;
// store hooks // store hooks
const { sidebarCollapsed } = useAppTheme(); const { sidebarCollapsed } = useAppTheme();
const { itemLink, itemIcon, itemTitle } = useFavoriteItemDetails(workspaceSlug, favorite);
//state //state
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isMenuActive, setIsMenuActive] = useState(false); const [isMenuActive, setIsMenuActive] = useState(false);
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
//ref //ref
const elementRef = useRef<HTMLDivElement>(null); const elementRef = useRef<HTMLDivElement>(null);
const actionSectionRef = useRef<HTMLDivElement | null>(null); const actionSectionRef = useRef<HTMLDivElement | null>(null);
const handleQuickAction = (value: boolean) => setIsMenuActive(value); const handleQuickAction = (value: boolean) => setIsMenuActive(value);
const { itemLink, itemIcon, itemTitle } = useFavoriteItemDetails(workspaceSlug, favorite);
// drag and drop // drag and drop
useEffect(() => { useEffect(() => {
const element = elementRef.current; const element = elementRef.current;
if (!element) return; if (!element) return;
const initialData = { id: favorite.id, isGroup: false, isChild: !!parentId, parentId };
return combine( return combine(
draggable({ draggable({
element, element,
dragHandle: elementRef.current, dragHandle: elementRef.current,
canDrag: () => true, getInitialData: () => initialData,
getInitialData: () => ({ id: favorite.id, type: "CHILD" }),
onDragStart: () => { onDragStart: () => {
setIsDragging(true); setIsDragging(true);
}, },
onDrop: () => { onDrop: () => {
setIsDragging(false); setIsDragging(false);
}, },
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
render: ({ container }) => {
const root = createRoot(container);
root.render(
<div className="rounded bg-custom-background-100 text-sm p-1 pr-2">
<FavoriteItemTitle
href={itemLink}
icon={itemIcon}
title={itemTitle}
isSidebarCollapsed={!!sidebarCollapsed}
/>
</div>
);
return () => root.unmount();
},
nativeSetDragImage,
});
},
}), }),
dropTargetForElements({ dropTargetForElements({
element, element,
canDrop: ({ source }) => getCanDrop(source, favorite, !!parentId),
onDragStart: () => { onDragStart: () => {
setIsDragging(true); setIsDragging(true);
}, },
onDragEnter: () => { getData: ({ input, element }) =>{
setIsDragging(true);
const blockedStates: InstructionType[] = ['make-child'];
if(!isLastChild){
blockedStates.push('reorder-below');
}
return attachInstruction(initialData,{
input,
element,
currentLevel: 1,
indentPerLevel: 0,
mode: isLastChild ? 'last-in-group' : 'standard',
block: blockedStates
})
},
onDrag: ({ self, source, location }) => {
const instruction = getInstructionFromPayload(self, source, location);
setInstruction(instruction);
}, },
onDragLeave: () => { onDragLeave: () => {
setIsDragging(false); setInstruction(undefined);
}, },
onDrop: ({ source }) => { onDrop: ({ self, source, location }) => {
setIsDragging(false); setInstruction(undefined);
const sourceId = source?.data?.id as string | undefined; handleDrop(self,source,location)
if (!sourceId || !favoriteMap[sourceId].parent) return;
handleRemoveFromFavoritesFolder(sourceId);
}, },
}) })
); );
@ -89,6 +143,7 @@ export const FavoriteRoot: FC<Props> = observer((props) => {
return ( return (
<> <>
<DropIndicator isVisible={instruction === "reorder-above"}/>
<FavoriteItemWrapper elementRef={elementRef} isMenuActive={isMenuActive} sidebarCollapsed={sidebarCollapsed}> <FavoriteItemWrapper elementRef={elementRef} isMenuActive={isMenuActive} sidebarCollapsed={sidebarCollapsed}>
{!sidebarCollapsed && <FavoriteItemDragHandle isDragging={isDragging} sort_order={favorite.sort_order} />} {!sidebarCollapsed && <FavoriteItemDragHandle isDragging={isDragging} sort_order={favorite.sort_order} />}
<FavoriteItemTitle href={itemLink} icon={itemIcon} title={itemTitle} isSidebarCollapsed={!!sidebarCollapsed} /> <FavoriteItemTitle href={itemLink} icon={itemIcon} title={itemTitle} isSidebarCollapsed={!!sidebarCollapsed} />
@ -102,6 +157,7 @@ export const FavoriteRoot: FC<Props> = observer((props) => {
/> />
)} )}
</FavoriteItemWrapper> </FavoriteItemWrapper>
{ isLastChild && <DropIndicator isVisible={instruction === "reorder-below"} />}
</> </>
); );
}); });

View file

@ -1,7 +1,12 @@
"use client"; "use client";
import React, { useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
DragLocationHistory,
DropTargetRecord,
ElementDragPayload,
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import orderBy from "lodash/orderBy"; import orderBy from "lodash/orderBy";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
@ -23,6 +28,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components // plane web components
import { FavoriteFolder } from "./favorite-folder"; import { FavoriteFolder } from "./favorite-folder";
import { FavoriteRoot } from "./favorite-items"; import { FavoriteRoot } from "./favorite-items";
import { getDestinationStateSequence, getInstructionFromPayload, TargetData } from "./favorites.helpers";
import { NewFavoriteFolder } from "./new-fav-folder"; import { NewFavoriteFolder } from "./new-fav-folder";
export const SidebarFavoritesMenu = observer(() => { export const SidebarFavoritesMenu = observer(() => {
@ -33,7 +39,14 @@ export const SidebarFavoritesMenu = observer(() => {
// store hooks // store hooks
const { sidebarCollapsed } = useAppTheme(); const { sidebarCollapsed } = useAppTheme();
const { favoriteIds, groupedFavorites, deleteFavorite, removeFromFavoriteFolder } = useFavorite(); const {
favoriteIds,
groupedFavorites,
deleteFavorite,
removeFromFavoriteFolder,
reOrderFavorite,
moveFavoriteToFolder,
} = useFavorite();
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
@ -46,6 +59,66 @@ export const SidebarFavoritesMenu = observer(() => {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const elementRef = useRef(null); const elementRef = useRef(null);
const handleMoveToFolder = (sourceId: string, destinationId: string) => {
moveFavoriteToFolder(workspaceSlug.toString(), sourceId, {
parent: destinationId,
}).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to move favorite.",
});
});
};
const handleDrop = (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => {
const isFolder = self.data?.isGroup;
const dropTargets = location?.current?.dropTargets ?? [];
if (!dropTargets || dropTargets.length <= 0) return;
const dropTarget =
dropTargets.length > 1 ? dropTargets.find((target: DropTargetRecord) => 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 (isFolder) {
// handle move to a new parent folder if dropped on a folder
if (parentId && parentId !== sourceData.parentId) {
handleMoveToFolder(sourceData.id, parentId);
}
//handle remove from folder if dropped outside of the folder
if (parentId && sourceData.isChild) {
handleRemoveFromFavoritesFolder(sourceData.id);
}
// handle reordering at root level
if (droppedFavId) {
if (instruction != "make-child") {
const destinationSequence = getDestinationStateSequence(groupedFavorites, droppedFavId, instruction);
handleReorder(sourceData.id, destinationSequence || 0);
}
}
} else {
//handling reordering for favorites
if (droppedFavId) {
const destinationSequence = getDestinationStateSequence(groupedFavorites, droppedFavId, instruction);
handleReorder(sourceData.id, destinationSequence || 0);
}
// handle removal from folder if dropped outside a folder
if (!parentId && sourceData.isChild) {
handleRemoveFromFavoritesFolder(sourceData.id);
}
}
};
const handleRemoveFromFavorites = (favorite: IFavorite) => { const handleRemoveFromFavorites = (favorite: IFavorite) => {
deleteFavorite(workspaceSlug.toString(), favorite.id) deleteFavorite(workspaceSlug.toString(), favorite.id)
.then(() => { .then(() => {
@ -64,18 +137,7 @@ export const SidebarFavoritesMenu = observer(() => {
}); });
}; };
const handleRemoveFromFavoritesFolder = (favoriteId: string) => { const handleRemoveFromFavoritesFolder = (favoriteId: string) => {
removeFromFavoriteFolder(workspaceSlug.toString(), favoriteId, { removeFromFavoriteFolder(workspaceSlug.toString(), favoriteId).catch(() => {
id: favoriteId,
parent: null,
})
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Favorite moved successfully.",
});
})
.catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: "Error!",
@ -83,6 +145,22 @@ export const SidebarFavoritesMenu = observer(() => {
}); });
}); });
}; };
const handleReorder = useCallback(
(favoriteId: string, sequence: number) => {
reOrderFavorite(workspaceSlug.toString(), favoriteId, {
sequence: sequence,
}).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed reorder favorite",
});
});
},
[workspaceSlug, reOrderFavorite]
);
useEffect(() => { useEffect(() => {
if (sidebarCollapsed) toggleFavoriteMenu(true); if (sidebarCollapsed) toggleFavoriteMenu(true);
}, [sidebarCollapsed, toggleFavoriteMenu]); }, [sidebarCollapsed, toggleFavoriteMenu]);
@ -109,7 +187,6 @@ export const SidebarFavoritesMenu = observer(() => {
const sourceId = source?.data?.id as string | undefined; const sourceId = source?.data?.id as string | undefined;
console.log({ sourceId }); console.log({ sourceId });
if (!sourceId || !groupedFavorites[sourceId].parent) return; if (!sourceId || !groupedFavorites[sourceId].parent) return;
handleRemoveFromFavoritesFolder(sourceId);
}, },
}) })
); );
@ -138,7 +215,7 @@ export const SidebarFavoritesMenu = observer(() => {
<FolderPlus <FolderPlus
onClick={() => { onClick={() => {
setCreateNewFolder(true); setCreateNewFolder(true);
!isFavoriteMenuOpen && toggleFavoriteMenu(!isFavoriteMenuOpen); if (!isFavoriteMenuOpen) toggleFavoriteMenu(!isFavoriteMenuOpen);
}} }}
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform")} className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform")}
/> />
@ -179,7 +256,7 @@ export const SidebarFavoritesMenu = observer(() => {
) : ( ) : (
orderBy(Object.values(groupedFavorites), "sequence", "desc") orderBy(Object.values(groupedFavorites), "sequence", "desc")
.filter((fav) => !fav.parent) .filter((fav) => !fav.parent)
.map((fav, index) => ( .map((fav, index, { length }) => (
<Tooltip <Tooltip
key={fav.id} key={fav.id}
tooltipContent={fav?.entity_data ? fav.entity_data?.name : fav?.name} tooltipContent={fav?.entity_data ? fav.entity_data?.name : fav?.name}
@ -191,17 +268,20 @@ export const SidebarFavoritesMenu = observer(() => {
{fav.is_folder ? ( {fav.is_folder ? (
<FavoriteFolder <FavoriteFolder
favorite={fav} favorite={fav}
isLastChild={index === favoriteIds.length - 1} isLastChild={index === length - 1}
handleRemoveFromFavorites={handleRemoveFromFavorites} handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder} handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}
handleReorder={handleReorder}
handleDrop={handleDrop}
/> />
) : ( ) : (
<FavoriteRoot <FavoriteRoot
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
favorite={fav} favorite={fav}
isLastChild={index === length - 1}
parentId={undefined}
handleRemoveFromFavorites={handleRemoveFromFavorites} handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder} handleDrop={handleDrop}
favoriteMap={groupedFavorites}
/> />
)} )}
</Tooltip> </Tooltip>

View file

@ -1,5 +1,13 @@
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import orderBy from "lodash/orderBy"; import orderBy from "lodash/orderBy";
import { IFavorite } from "@plane/types"; import { IFavorite, InstructionType, IPragmaticPayloadLocation, TDropTarget } from "@plane/types";
export type TargetData = {
id: string;
parentId: string | null;
isGroup: boolean;
isChild: boolean;
}
export const getDestinationStateSequence = ( export const getDestinationStateSequence = (
favoriteMap: Record<string, IFavorite>, favoriteMap: Record<string, IFavorite>,
@ -9,6 +17,7 @@ export const getDestinationStateSequence = (
const defaultSequence = 65535; const defaultSequence = 65535;
if (!edge) return defaultSequence; if (!edge) return defaultSequence;
const favoriteIds = orderBy(Object.values(favoriteMap), "sequence", "desc") const favoriteIds = orderBy(Object.values(favoriteMap), "sequence", "desc")
.filter((fav: IFavorite) => !fav.parent) .filter((fav: IFavorite) => !fav.parent)
.map((fav: IFavorite) => fav.id); .map((fav: IFavorite) => fav.id);
@ -17,19 +26,84 @@ export const getDestinationStateSequence = (
if (!destinationStateSequence) return defaultSequence; if (!destinationStateSequence) return defaultSequence;
if (edge === "top") {
let resultSequence = defaultSequence;
if (edge === "reorder-above") {
const prevStateSequence = favoriteMap[favoriteIds[destinationStateIndex - 1]]?.sequence || undefined; const prevStateSequence = favoriteMap[favoriteIds[destinationStateIndex - 1]]?.sequence || undefined;
if (prevStateSequence === undefined) { if (prevStateSequence === undefined) {
return destinationStateSequence + defaultSequence; resultSequence = destinationStateSequence + defaultSequence;
}else {
resultSequence = (destinationStateSequence + prevStateSequence) / 2
} }
return (destinationStateSequence + prevStateSequence) / 2; } else if (edge === "reorder-below") {
} else if (edge === "bottom") {
const nextStateSequence = favoriteMap[favoriteIds[destinationStateIndex + 1]]?.sequence || undefined; const nextStateSequence = favoriteMap[favoriteIds[destinationStateIndex + 1]]?.sequence || undefined;
if (nextStateSequence === undefined) { if (nextStateSequence === undefined) {
return destinationStateSequence - defaultSequence; resultSequence = destinationStateSequence - defaultSequence;
} else {
resultSequence = (destinationStateSequence + nextStateSequence) / 2;
} }
return (destinationStateSequence + nextStateSequence) / 2;
} }
resultSequence = Math.round(resultSequence)
return resultSequence;
};
/**
* 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 favorite 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 favorite,
// 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 favorite can be dropped onto the droptarget
* @param source
* @param favorite
* @param isCurrentChild if the dropTarget is a child
* @returns
*/
export const getCanDrop = (source: TDropTarget, favorite: IFavorite | undefined, isCurrentChild: boolean) => {
const sourceData = source?.data;
if (!sourceData) return false;
// a favorite cannot be dropped on to itself
if (sourceData.id === favorite?.id ) return false;
// if current dropTarget is a child and the favorite being dropped is a group then don't enable drop
if (isCurrentChild && sourceData.isGroup) return false;
return true;
}; };

View file

@ -298,12 +298,13 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
<> <>
<PublishProjectModal isOpen={publishModalOpen} project={project} onClose={() => setPublishModal(false)} /> <PublishProjectModal isOpen={publishModalOpen} project={project} onClose={() => setPublishModal(false)} />
<LeaveProjectModal project={project} isOpen={leaveProjectModalOpen} onClose={() => setLeaveProjectModal(false)} /> <LeaveProjectModal project={project} isOpen={leaveProjectModalOpen} onClose={() => setLeaveProjectModal(false)} />
<Disclosure key={`${project.id}_${URLProjectId}`} ref={projectRef} defaultOpen={isProjectListOpen} as="div"> <Disclosure key={`${project.id}_${URLProjectId}`} defaultOpen={isProjectListOpen} as="div">
<div <div
id={`sidebar-${projectId}-${projectListType}`} id={`sidebar-${projectId}-${projectListType}`}
className={cn("relative", { className={cn("relative", {
"bg-custom-sidebar-background-80 opacity-60": isDragging, "bg-custom-sidebar-background-80 opacity-60": isDragging,
})} })}
ref={projectRef}
> >
<DropIndicator classNames="absolute top-0" isVisible={instruction === "DRAG_OVER"} /> <DropIndicator classNames="absolute top-0" isVisible={instruction === "DRAG_OVER"} />
<div <div

View file

@ -26,10 +26,10 @@ export interface IFavoriteStore {
updateFavorite: (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => Promise<IFavorite>; updateFavorite: (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => Promise<IFavorite>;
deleteFavorite: (workspaceSlug: string, favoriteId: string) => Promise<void>; deleteFavorite: (workspaceSlug: string, favoriteId: string) => Promise<void>;
getGroupedFavorites: (workspaceSlug: string, favoriteId: string) => Promise<IFavorite[]>; getGroupedFavorites: (workspaceSlug: string, favoriteId: string) => Promise<IFavorite[]>;
moveFavorite: (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => Promise<void>; moveFavoriteToFolder: (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => Promise<void>;
removeFavoriteEntity: (workspaceSlug: string, entityId: string) => Promise<void>; removeFavoriteEntity: (workspaceSlug: string, entityId: string) => Promise<void>;
moveFavoriteFolder: (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => Promise<void>; reOrderFavorite: (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => Promise<void>;
removeFromFavoriteFolder: (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => Promise<void>; removeFromFavoriteFolder: (workspaceSlug: string, favoriteId: string) => Promise<void>;
removeFavoriteFromStore: (entity_identifier: string) => void; removeFavoriteFromStore: (entity_identifier: string) => void;
} }
@ -64,9 +64,9 @@ export class FavoriteStore implements IFavoriteStore {
// CRUD actions // CRUD actions
addFavorite: action, addFavorite: action,
getGroupedFavorites: action, getGroupedFavorites: action,
moveFavorite: action, moveFavoriteToFolder: action,
removeFavoriteEntity: action, removeFavoriteEntity: action,
moveFavoriteFolder: action, reOrderFavorite: action,
removeFavoriteEntityFromStore: action, removeFavoriteEntityFromStore: action,
removeFromFavoriteFolder: action, removeFromFavoriteFolder: action,
}); });
@ -168,7 +168,7 @@ export class FavoriteStore implements IFavoriteStore {
* @param data * @param data
* @returns Promise<void> * @returns Promise<void>
*/ */
moveFavorite = async (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => { moveFavoriteToFolder = async (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => {
const oldParent = this.favoriteMap[favoriteId].parent; const oldParent = this.favoriteMap[favoriteId].parent;
try { try {
runInAction(() => { runInAction(() => {
@ -190,7 +190,7 @@ export class FavoriteStore implements IFavoriteStore {
} }
}; };
moveFavoriteFolder = async (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => { reOrderFavorite = async (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => {
const initialSequence = this.favoriteMap[favoriteId].sequence; const initialSequence = this.favoriteMap[favoriteId].sequence;
try { try {
runInAction(() => { runInAction(() => {
@ -207,14 +207,15 @@ export class FavoriteStore implements IFavoriteStore {
} }
}; };
removeFromFavoriteFolder = async (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => { removeFromFavoriteFolder = async (workspaceSlug: string, favoriteId: string) => {
const parent = this.favoriteMap[favoriteId].parent; const parent = this.favoriteMap[favoriteId].parent;
try { try {
runInAction(() => { runInAction(() => {
//remove parent //remove parent
set(this.favoriteMap, [favoriteId, "parent"], null); set(this.favoriteMap, [favoriteId, "parent"], null);
}); });
await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, data); await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, { parent: null});
} catch (error) { } catch (error) {
console.error("Failed to move favorite"); console.error("Failed to move favorite");
runInAction(() => { runInAction(() => {