[WEB-1907] Fix: favorites (#5292)

* chore: workspace user favorites

* chore: added project id in entity type

* chore: removed the extra key

* chore: removed the project member filter

* chore: updated the project permission layer

* chore: updated the workspace group favorite filter

* fix: project favorite toggle

* chore: Fav feature

* fix: build errors + added navigation

* fix: added remove entity icon

* fix: nomenclature

* chore: hard delete favorites

* fix: review changes

* fix: added optimistic addition to the store

* chore: user favorite hard delete

* fix: linting fixed

* fix: favorite bugs

* fix: ts bugs

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
Akshita Goyal
2024-08-04 10:15:26 +05:30
committed by GitHub
parent 93e6c3b6e0
commit 34820eec7a
20 changed files with 498 additions and 238 deletions

View File

@@ -1154,7 +1154,7 @@ class CycleFavoriteViewSet(BaseViewSet):
workspace__slug=slug,
entity_identifier=cycle_id,
)
cycle_favorite.delete()
cycle_favorite.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -840,7 +840,7 @@ class ModuleFavoriteViewSet(BaseViewSet):
entity_type="module",
entity_identifier=module_id,
)
module_favorite.delete()
module_favorite.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -386,7 +386,7 @@ class PageFavoriteViewSet(BaseViewSet):
entity_identifier=pk,
entity_type="page",
)
page_favorite.delete()
page_favorite.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -599,7 +599,7 @@ class ProjectFavoritesViewSet(BaseViewSet):
user=request.user,
workspace__slug=slug,
)
project_favorite.delete()
project_favorite.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -474,5 +474,5 @@ class IssueViewFavoriteViewSet(BaseViewSet):
entity_type="view",
entity_identifier=view_id,
)
view_favorite.delete()
view_favorite.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -1,14 +1,15 @@
export type IFavorite = {
id: string;
name: string;
entity_type: string;
entity_data: {
name: string;
};
is_folder: boolean;
sort_order: number;
parent: string | null;
entity_identifier?: string | null;
children: IFavorite[];
project_id: string | null;
id: string;
name: string;
entity_type: string;
entity_data: {
name: string;
};
is_folder: boolean;
sort_order: number;
parent: string | null;
entity_identifier?: string | null;
children: IFavorite[];
project_id: string | null;
sequence: number;
};

View File

@@ -66,25 +66,23 @@ export const AppSidebar: FC<IAppSidebar> = observer(() => {
"opacity-0": !sidebarCollapsed,
})}
/>
<SidebarUserMenu />
<hr
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
"opacity-0": !sidebarCollapsed,
<div
className={cn("overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto px-2.5", {
"vertical-scrollbar": !sidebarCollapsed,
})}
/>
<SidebarWorkspaceMenu />
<hr
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
"opacity-0": !sidebarCollapsed,
})}
/>
<SidebarFavoritesMenu />
<hr
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
"opacity-0": !sidebarCollapsed,
})}
/>
<SidebarProjectsList />
>
<SidebarUserMenu />
<SidebarWorkspaceMenu />
<hr
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
"opacity-0": !sidebarCollapsed,
})}
/>
<SidebarFavoritesMenu />
<SidebarProjectsList />
</div>
<SidebarHelpSection />
</div>
</div>

View File

@@ -26,7 +26,7 @@ export const BlockItemAction: FC<Props> = observer((props) => {
const page = usePage(pageId);
const { getUserDetails } = useMember();
// derived values
const { access, created_at, is_favorite, owned_by, addToFavorites, removeFromFavorites } = page;
const { access, created_at, is_favorite, owned_by, addToFavorites, removePageFromFavorites } = page;
// derived values
const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined;
@@ -34,7 +34,7 @@ export const BlockItemAction: FC<Props> = observer((props) => {
// handlers
const handleFavorites = () => {
if (is_favorite)
removeFromFavorites().then(() =>
removePageFromFavorites().then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",

View File

@@ -2,14 +2,15 @@
import { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { useParams } from "next/navigation";
import { PenSquare, Star, MoreHorizontal, ChevronRight } from "lucide-react";
import { PenSquare, Star, MoreHorizontal, ChevronRight, GripVertical } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// ui
import { IFavorite } from "@plane/types";
import { CustomMenu, Tooltip, DropIndicator, setToast, TOAST_TYPE, FavoriteFolderIcon } from "@plane/ui";
import { CustomMenu, Tooltip, DropIndicator, setToast, TOAST_TYPE, FavoriteFolderIcon, DragHandle } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
@@ -20,6 +21,7 @@ import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os";
// constants
import { FavoriteItem } from "./favorite-item";
import { getDestinationStateSequence } from "./favorites.helpers";
import { NewFavoriteFolder } from "./new-fav-folder";
type Props = {
@@ -29,18 +31,20 @@ type Props = {
};
export const FavoriteFolder: React.FC<Props> = (props) => {
const { isLastChild, favorite, handleRemoveFromFavorites } = props;
const { favorite, handleRemoveFromFavorites } = props;
// store hooks
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { isMobile } = usePlatformOS();
const { moveFavorite, getGroupedFavorites } = useFavorite();
const { moveFavorite, getGroupedFavorites, favoriteMap, moveFavoriteFolder } = useFavorite();
const { workspaceSlug } = useParams();
// states
const [isMenuActive, setIsMenuActive] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined);
const [folderToRename, setFolderToRename] = useState<string | boolean | null>(null);
const [isDraggedOver, setIsDraggedOver] = useState(false);
const [closestEdge, setClosestEdge] = useState<string | null>(null);
// refs
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const elementRef = useRef<HTMLDivElement | null>(null);
@@ -51,16 +55,14 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
moveFavorite(workspaceSlug.toString(), source, {
parent: destination,
})
.then((res) => {
console.log(res, "res");
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Favorite moved successfully.",
});
})
.catch((err) => {
console.log(err, "err");
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
@@ -69,33 +71,81 @@ 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 };
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: getDestinationStateSequence(favoriteMap, destinationData.id as string, edge),
};
handleOnDropFolder(payload);
}
}, // canDrag: () => isDraggable,
}),
dropTargetForElements({
element,
getData: () => ({ type: "PARENT", id: favorite.id }),
onDragEnter: () => {
getData: ({ input, element }) =>
attachClosestEdge(initialData, {
input,
element,
allowedEdges: ["top", "bottom"],
}),
onDragEnter: (args) => {
setIsDragging(true);
setIsDraggedOver(true);
setClosestEdge(extractClosestEdge(args.self.data));
},
onDragLeave: () => {
setIsDragging(false);
setIsDraggedOver(false);
setClosestEdge(null);
},
onDragStart: () => {
setIsDragging(true);
},
onDrop: ({ self, source }) => {
setInstruction(undefined);
setIsDragging(false);
setIsDraggedOver(false);
const sourceId = source?.data?.id as string | undefined;
const destinationId = self?.data?.id as string | undefined;
if (sourceId === destinationId) return;
if (!sourceId || !destinationId) return;
if (favoriteMap[sourceId].parent === destinationId) return;
handleOnDrop(sourceId, destinationId);
},
})
@@ -122,7 +172,8 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
"bg-custom-sidebar-background-80 opacity-60": isDragging,
})}
>
<DropIndicator classNames="absolute top-0" isVisible={instruction === "DRAG_OVER"} />
{/* draggable drop top indicator */}
<DropIndicator isVisible={isDraggedOver && closestEdge === "top"} />
<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",
@@ -132,6 +183,12 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
}
)}
>
{/* draggable indicator */}
<div className="flex-shrink-0 w-3 h-3 rounded-sm absolute left-0 hidden group-hover:flex justify-center items-center transition-colors bg-custom-background-90 cursor-pointer text-custom-text-200 hover:text-custom-text-100">
<GripVertical className="w-3 h-3" />
</div>
{isSidebarCollapsed ? (
<div
className={cn("flex-grow flex items-center gap-1.5 truncate text-left select-none", {
@@ -160,6 +217,28 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
"justify-center": isSidebarCollapsed,
})}
>
<Tooltip
isMobile={isMobile}
tooltipContent={
favorite.sort_order === null ? "Join the project to rearrange" : "Drag to rearrange"
}
position="top-right"
disabled={isDragging}
>
<button
type="button"
className={cn(
"hidden group-hover/project-item:flex items-center justify-center absolute top-1/2 -left-3 -translate-y-1/2 rounded text-custom-sidebar-text-400 cursor-grab",
{
"cursor-not-allowed opacity-60": favorite.sort_order === null,
"cursor-grabbing": isDragging,
"!hidden": isSidebarCollapsed,
}
)}
>
<DragHandle className="bg-transparent" />
</button>
</Tooltip>
<div className="size-4 grid place-items-center flex-shrink-0">
<FavoriteFolderIcon />
</div>
@@ -238,7 +317,8 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
</Disclosure.Panel>
</Transition>
)}
{isLastChild && <DropIndicator isVisible={instruction === "DRAG_BELOW"} />}
{/* draggable drop bottom indicator */}
<DropIndicator isVisible={isDraggedOver && closestEdge === "bottom"} />{" "}
</div>
)}
</Disclosure>

View File

@@ -45,7 +45,7 @@ export const FavoriteItem = observer(
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const getIcon = () => {
const className = `flex-shrink-0 size-4 stroke-[1.5]`;
const className = `flex-shrink-0 size-4 stroke-[1.5] m-auto`;
switch (favorite.entity_type) {
case "page":
@@ -92,7 +92,7 @@ export const FavoriteItem = observer(
return combine(
draggable({
element,
dragHandle: element,
// dragHandle: element,
canDrag: () => true,
getInitialData: () => ({ id: favorite.id, type: "CHILD" }),
onDragStart: () => {
@@ -144,32 +144,34 @@ export const FavoriteItem = observer(
</Link>
)}
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="size-4" />
</span>
}
className={cn(
"opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto",
{
"opacity-100 pointer-events-auto": isMenuActive,
{!sidebarCollapsed && (
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="size-4" />
</span>
}
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
>
<CustomMenu.MenuItem onClick={() => handleRemoveFromFavorites(favorite)}>
<span className="flex items-center justify-start gap-2">
<Star className="h-3.5 w-3.5 fill-yellow-500 stroke-yellow-500" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
className={cn(
"opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto",
{
"opacity-100 pointer-events-auto": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
>
<CustomMenu.MenuItem onClick={() => handleRemoveFromFavorites(favorite)}>
<span className="flex items-center justify-start gap-2">
<Star className="h-3.5 w-3.5 fill-yellow-500 stroke-yellow-500" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
</SidebarNavItem>
</div>

View File

@@ -1,6 +1,9 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { orderBy } from "lodash";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ChevronRight, FolderPlus } from "lucide-react";
@@ -21,14 +24,16 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
import { FavoriteFolder } from "./favorite-folder";
import { FavoriteItem } from "./favorite-item";
import { NewFavoriteFolder } from "./new-fav-folder";
export const SidebarFavoritesMenu = observer(() => {
//state
const [createNewFolder, setCreateNewFolder] = useState<boolean | string | null>(null);
const [isScrolled, setIsScrolled] = useState(false); // scroll animation state
const [isDragging, setIsDragging] = useState(false);
// store hooks
const { sidebarCollapsed } = useAppTheme();
const { favoriteIds, favoriteMap, deleteFavorite } = useFavorite();
const { favoriteIds, favoriteMap, deleteFavorite, removeFromFavoriteFolder } = useFavorite();
const { workspaceSlug } = useParams();
const { isMobile } = usePlatformOS();
@@ -39,6 +44,7 @@ export const SidebarFavoritesMenu = observer(() => {
const isFavoriteMenuOpen = !!storedValue;
// refs
const containerRef = useRef<HTMLDivElement | null>(null);
const elementRef = useRef(null);
const handleRemoveFromFavorites = (favorite: IFavorite) => {
deleteFavorite(workspaceSlug.toString(), favorite.id)
@@ -57,43 +63,72 @@ export const SidebarFavoritesMenu = observer(() => {
});
});
};
const handleRemoveFromFavoritesFolder = (favoriteId: string) => {
removeFromFavoriteFolder(workspaceSlug.toString(), favoriteId, {
id: favoriteId,
parent: null,
})
.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.",
});
});
};
useEffect(() => {
if (sidebarCollapsed) toggleFavoriteMenu(true);
}, [sidebarCollapsed, toggleFavoriteMenu]);
/**
* Implementing scroll animation styles based on the scroll length of the container
*/
useEffect(() => {
const handleScroll = () => {
if (containerRef.current) {
const scrollTop = containerRef.current.scrollTop;
setIsScrolled(scrollTop > 0);
}
};
const currentContainerRef = containerRef.current;
if (currentContainerRef) {
currentContainerRef.addEventListener("scroll", handleScroll);
}
return () => {
if (currentContainerRef) {
currentContainerRef.removeEventListener("scroll", handleScroll);
}
};
}, [containerRef]);
const element = elementRef.current;
if (!element) return;
return combine(
dropTargetForElements({
element,
onDragEnter: () => {
setIsDragging(true);
},
onDragLeave: () => {
setIsDragging(false);
},
onDragStart: () => {
setIsDragging(true);
},
onDrop: ({ source }) => {
setIsDragging(false);
const sourceId = source?.data?.id as string | undefined;
console.log({ sourceId });
if (!sourceId || !favoriteMap[sourceId].parent) return;
handleRemoveFromFavoritesFolder(sourceId);
},
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elementRef.current, isDragging]);
return (
<div
ref={containerRef}
className={cn("-mr-3 -ml-4 pl-4", {
"border-t border-custom-sidebar-border-300": isScrolled,
"vertical-scrollbar h-full !overflow-y-scroll scrollbar-sm": isFavoriteMenuOpen,
})}
>
<Disclosure as="div" defaultOpen>
<>
<Disclosure as="div" defaultOpen ref={containerRef}>
{!sidebarCollapsed && (
<Disclosure.Button
ref={elementRef}
as="button"
className="group/workspace-button w-full px-2 py-1.5 flex items-center justify-between gap-1 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90 rounded text-xs font-semibold"
className={cn(
"sticky top-0 bg-custom-sidebar-background-100 z-10 group/workspace-button w-full px-2 py-1.5 flex items-center justify-between gap-1 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90 rounded text-xs font-semibold",
{
"bg-custom-sidebar-background-80 opacity-60": isDragging,
}
)}
>
<span onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)} className="flex-1 text-start">
MY FAVORITES
@@ -133,27 +168,25 @@ export const SidebarFavoritesMenu = observer(() => {
static
>
{createNewFolder && <NewFavoriteFolder setCreateNewFolder={setCreateNewFolder} actionType="create" />}
{favoriteIds
.filter((id) => !favoriteMap[id].parent)
.map((id, index) => (
{orderBy(Object.values(favoriteMap), "sequence", "desc")
.filter((fav) => !fav.parent)
.map((fav, index) => (
<Tooltip
key={favoriteMap[id].id}
tooltipContent={
favoriteMap[id].entity_data ? favoriteMap[id].entity_data.name : favoriteMap[id].name
}
key={fav.id}
tooltipContent={fav.entity_data ? fav.entity_data.name : fav.name}
position="right"
className="ml-2"
disabled={!sidebarCollapsed}
isMobile={isMobile}
>
{favoriteMap[id].is_folder ? (
{fav.is_folder ? (
<FavoriteFolder
favorite={favoriteMap[id]}
favorite={fav}
isLastChild={index === favoriteIds.length - 1}
handleRemoveFromFavorites={handleRemoveFromFavorites}
/>
) : (
<FavoriteItem favorite={favoriteMap[id]} handleRemoveFromFavorites={handleRemoveFromFavorites} />
<FavoriteItem favorite={fav} handleRemoveFromFavorites={handleRemoveFromFavorites} />
)}
</Tooltip>
))}
@@ -161,6 +194,12 @@ export const SidebarFavoritesMenu = observer(() => {
)}
</Transition>
</Disclosure>
</div>
<hr
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
"opacity-0": !sidebarCollapsed || favoriteIds.length === 0,
})}
/>
</>
);
});

View File

@@ -0,0 +1,35 @@
import orderBy from "lodash/orderBy";
import { IFavorite } from "@plane/types";
export const getDestinationStateSequence = (
favoriteMap: Record<string, IFavorite>,
destinationId: string,
edge: string | undefined
) => {
const defaultSequence = 65535;
if (!edge) return defaultSequence;
const favoriteIds = orderBy(Object.values(favoriteMap), "sequence", "desc")
.filter((fav: IFavorite) => !fav.parent)
.map((fav: IFavorite) => fav.id);
const destinationStateIndex = favoriteIds.findIndex((id) => id === destinationId);
const destinationStateSequence = favoriteMap[destinationId]?.sequence || undefined;
if (!destinationStateSequence) return defaultSequence;
if (edge === "top") {
const prevStateSequence = favoriteMap[favoriteIds[destinationStateIndex - 1]]?.sequence || undefined;
if (prevStateSequence === undefined) {
return destinationStateSequence + defaultSequence;
}
return (destinationStateSequence + prevStateSequence) / 2;
} else if (edge === "bottom") {
const nextStateSequence = favoriteMap[favoriteIds[destinationStateIndex + 1]]?.sequence || undefined;
if (nextStateSequence === undefined) {
return destinationStateSequence - defaultSequence;
}
return (destinationStateSequence + nextStateSequence) / 2;
}
};

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, SubmitHandler, useForm } from "react-hook-form";
import { FavoriteFolderIcon, Input, setToast, TOAST_TYPE } from "@plane/ui";
@@ -18,10 +19,10 @@ type TProps = {
defaultName?: string;
favoriteId?: string;
};
export const NewFavoriteFolder = (props: TProps) => {
export const NewFavoriteFolder = observer((props: TProps) => {
const { setCreateNewFolder, actionType, defaultName, favoriteId } = props;
const { workspaceSlug } = useParams();
const { addFavorite, updateFavorite } = useFavorite();
const { addFavorite, updateFavorite, existingFolders } = useFavorite();
// ref
const ref = useRef(null);
@@ -35,6 +36,12 @@ export const NewFavoriteFolder = (props: TProps) => {
});
const handleAddNewFolder: SubmitHandler<TForm> = (formData) => {
if (existingFolders.includes(formData.name))
return setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Folder already exists",
});
formData = {
entity_type: "folder",
is_folder: true,
@@ -63,6 +70,12 @@ export const NewFavoriteFolder = (props: TProps) => {
const handleRenameFolder: SubmitHandler<TForm> = (formData) => {
if (!favoriteId) return;
if (existingFolders.includes(formData.name))
return setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Folder already exists",
});
const payload = {
name: formData.name,
};
@@ -86,14 +99,15 @@ export const NewFavoriteFolder = (props: TProps) => {
});
return (
<div className="flex items-center gap-1.5 py-[1px] px-2" ref={ref}>
<FavoriteFolderIcon />
<FavoriteFolderIcon className="w-[16px]" />
<form onSubmit={handleSubmit(actionType === "create" ? handleAddNewFolder : handleRenameFolder)}>
<Controller
name="name"
control={control}
render={({ field }) => <Input placeholder="New folder" {...field} />}
rules={{ required: true }}
render={({ field }) => <Input className="w-full" placeholder="New folder" {...field} />}
/>
</form>
</div>
);
};
});

View File

@@ -367,22 +367,19 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
customButtonClassName="grid place-items-center"
placement="bottom-start"
>
{!project.is_favorite && (
<CustomMenu.MenuItem onClick={handleAddToFavorites}>
<span className="flex items-center justify-start gap-2">
<Star className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Add to favorites</span>
</span>
</CustomMenu.MenuItem>
)}
{project.is_favorite && (
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
<span className="flex items-center justify-start gap-2">
<Star className="h-3.5 w-3.5 fill-yellow-500 stroke-yellow-500" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={project.is_favorite ? handleRemoveFromFavorites : handleAddToFavorites}
>
<span className="flex items-center justify-start gap-2">
<Star
className={cn("h-3.5 w-3.5 ", {
"fill-yellow-500 stroke-yellow-500": project.is_favorite,
})}
/>
<span>{project.is_favorite ? "Remove from favorites" : "Add to favorites"}</span>
</span>
</CustomMenu.MenuItem>
{/* publish project settings */}
{isAdmin && (
<CustomMenu.MenuItem onClick={() => setPublishModal(true)}>

View File

@@ -140,107 +140,105 @@ export const SidebarProjectsList: FC = observer(() => {
)}
<div
ref={containerRef}
className={cn("vertical-scrollbar h-full !overflow-y-scroll scrollbar-sm -mr-3 -ml-4 pl-4", {
className={cn({
"border-t border-custom-sidebar-border-300": isScrolled,
})}
>
<>
<Disclosure as="div" className="flex flex-col" defaultOpen={isAllProjectsListOpen}>
<>
<div
<div
className={cn(
"group w-full flex items-center justify-between px-2 py-1.5 rounded text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90",
{
"p-0 justify-center w-fit mx-auto bg-custom-sidebar-background-90 hover:bg-custom-sidebar-background-80":
isCollapsed,
}
)}
>
<Disclosure.Button
as="button"
type="button"
className={cn(
"group w-full flex items-center justify-between px-2 py-1.5 rounded text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90",
"group w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400",
{
"p-0 justify-center w-fit mx-auto bg-custom-sidebar-background-90 hover:bg-custom-sidebar-background-80":
isCollapsed,
"!text-center w-8 px-2 py-1.5 justify-center": isCollapsed,
}
)}
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
>
<Disclosure.Button
as="button"
type="button"
className={cn(
"group w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400",
{
"!text-center w-8 px-2 py-1.5 justify-center": isCollapsed,
}
)}
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
>
<Tooltip tooltipHeading="YOUR PROJECTS" tooltipContent="" position="right" disabled={!isCollapsed}>
<>
{isCollapsed ? (
<Briefcase className="flex-shrink-0 size-3" />
) : (
<span className="text-xs font-semibold">YOUR PROJECTS</span>
)}
</>
</Tooltip>
</Disclosure.Button>
{!isCollapsed && (
<div className="flex items-center opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
{isAuthorizedUser && (
<Tooltip tooltipHeading="Create project" tooltipContent="">
<button
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
onClick={() => {
setTrackElement(`APP_SIDEBAR_JOINED_BLOCK`);
setIsProjectModalOpen(true);
}}
>
<Plus className="size-3" />
</button>
</Tooltip>
<Tooltip tooltipHeading="YOUR PROJECTS" tooltipContent="" position="right" disabled={!isCollapsed}>
<>
{isCollapsed ? (
<Briefcase className="flex-shrink-0 size-3" />
) : (
<span className="text-xs font-semibold">YOUR PROJECTS</span>
)}
<Disclosure.Button
as="button"
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
>
<ChevronRight
className={cn("flex-shrink-0 size-4 transition-all", {
"rotate-90": isAllProjectsListOpen,
})}
/>
</Disclosure.Button>
</div>
)}
</div>
<Transition
show={isAllProjectsListOpen}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
{isAllProjectsListOpen && (
<Disclosure.Panel
as="div"
className={cn("space-y-1", {
"space-y-0 ml-0": isCollapsed,
})}
static
</>
</Tooltip>
</Disclosure.Button>
{!isCollapsed && (
<div className="flex items-center opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
{isAuthorizedUser && (
<Tooltip tooltipHeading="Create project" tooltipContent="">
<button
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
onClick={() => {
setTrackElement(`APP_SIDEBAR_JOINED_BLOCK`);
setIsProjectModalOpen(true);
}}
>
<Plus className="size-3" />
</button>
</Tooltip>
)}
<Disclosure.Button
as="button"
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
>
{joinedProjects.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
handleCopyText={() => handleCopyText(projectId)}
projectListType={"JOINED"}
disableDrag={false}
disableDrop={false}
isLastChild={index === joinedProjects.length - 1}
handleOnProjectDrop={handleOnProjectDrop}
/>
))}
</Disclosure.Panel>
)}
</Transition>
</>
<ChevronRight
className={cn("flex-shrink-0 size-4 transition-all", {
"rotate-90": isAllProjectsListOpen,
})}
/>
</Disclosure.Button>
</div>
)}
</div>
<Transition
show={isAllProjectsListOpen}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
{isAllProjectsListOpen && (
<Disclosure.Panel
as="div"
className={cn("space-y-1", {
"space-y-0 ml-0": isCollapsed,
})}
static
>
{joinedProjects.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
handleCopyText={() => handleCopyText(projectId)}
projectListType={"JOINED"}
disableDrag={false}
disableDrop={false}
isLastChild={index === joinedProjects.length - 1}
handleOnProjectDrop={handleOnProjectDrop}
/>
))}
</Disclosure.Panel>
)}
</Transition>
</Disclosure>
</>

View File

@@ -66,7 +66,7 @@ export const SidebarWorkspaceMenu = observer(() => {
{!sidebarCollapsed && (
<Disclosure.Button
as="button"
className="group/workspace-button w-full px-2 py-1.5 flex items-center justify-between gap-1 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90 rounded text-xs font-semibold"
className="sticky top-0 bg-custom-sidebar-background-100 z-10 group/workspace-button w-full px-2 py-1.5 flex items-center justify-between gap-1 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90 rounded text-xs font-semibold"
onClick={() => toggleWorkspaceMenu(!isWorkspaceMenuOpen)}
>
<span>WORKSPACE</span>

View File

@@ -1,9 +1,10 @@
import { uniqBy } from "lodash";
import set from "lodash/set";
import { action, observable, makeObservable, runInAction } from "mobx";
import { action, observable, makeObservable, runInAction, computed } from "mobx";
import { v4 as uuidv4 } from "uuid";
import { IFavorite } from "@plane/types";
import { FavoriteService } from "@/services/favorite";
import { CoreRootStore } from "./root.store";
export interface IFavoriteStore {
// observables
@@ -16,6 +17,7 @@ export interface IFavoriteStore {
[entityId: string]: IFavorite;
};
// computed actions
existingFolders: string[];
// actions
fetchFavorite: (workspaceSlug: string) => Promise<IFavorite[]>;
// CRUD actions
@@ -25,6 +27,8 @@ export interface IFavoriteStore {
getGroupedFavorites: (workspaceSlug: string, favoriteId: string) => Promise<IFavorite[]>;
moveFavorite: (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => Promise<void>;
removeFavoriteEntity: (workspaceSlug: string, entityId: string) => Promise<void>;
moveFavoriteFolder: (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => Promise<void>;
removeFromFavoriteFolder: (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => Promise<void>;
}
export class FavoriteStore implements IFavoriteStore {
@@ -38,13 +42,20 @@ export class FavoriteStore implements IFavoriteStore {
} = {};
// service
favoriteService;
viewStore;
projectStore;
pageStore;
cycleStore;
moduleStore;
constructor() {
constructor(_rootStore: CoreRootStore) {
makeObservable(this, {
// observable
favoriteMap: observable,
entityMap: observable,
favoriteIds: observable,
//computed
existingFolders: computed,
// action
fetchFavorite: action,
// CRUD actions
@@ -52,8 +63,20 @@ export class FavoriteStore implements IFavoriteStore {
getGroupedFavorites: action,
moveFavorite: action,
removeFavoriteEntity: action,
moveFavoriteFolder: action,
removeFavoriteEntityFromStore: action,
removeFromFavoriteFolder: action,
});
this.favoriteService = new FavoriteService();
this.viewStore = _rootStore.projectView;
this.projectStore = _rootStore.projectRoot.project;
this.moduleStore = _rootStore.module;
this.cycleStore = _rootStore.cycle;
this.pageStore = _rootStore.projectPages;
}
get existingFolders() {
return Object.values(this.favoriteMap).map((fav) => fav.name);
}
/**
@@ -147,6 +170,65 @@ export class FavoriteStore implements IFavoriteStore {
}
};
moveFavoriteFolder = async (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => {
const initialSequence = this.favoriteMap[favoriteId].sequence;
try {
runInAction(() => {
set(this.favoriteMap, [favoriteId, "sequence"], data.sequence);
});
console.log(JSON.parse(JSON.stringify(this.favoriteMap)), "getDestinationStateSequence");
await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, data);
} catch (error) {
runInAction(() => {
set(this.favoriteMap, [favoriteId, "sequence"], initialSequence);
console.error("Failed to move favorite folder");
throw error;
});
}
};
removeFromFavoriteFolder = async (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => {
try {
await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, data);
runInAction(() => {
const parent = this.favoriteMap[favoriteId].parent;
//remove parent
set(this.favoriteMap, [favoriteId, "parent"], null);
//remove children from parent
if (parent) {
set(
this.favoriteMap,
[parent, "children"],
this.favoriteMap[parent].children.filter((child) => child.id !== favoriteId)
);
}
});
} catch (error) {
console.error("Failed to move favorite");
throw error;
}
};
removeFavoriteEntityFromStore = (entity_identifier: string, entity_type: string) => {
switch (entity_type) {
case "view":
return (this.viewStore.viewMap[entity_identifier].is_favorite = false);
case "module":
return (this.moduleStore.moduleMap[entity_identifier].is_favorite = false);
case "page":
return (this.pageStore.data[entity_identifier].is_favorite = false);
case "cycle":
return (this.cycleStore.cycleMap[entity_identifier].is_favorite = false);
case "project":
return (this.projectStore.projectMap[entity_identifier].is_favorite = false);
default:
return;
}
};
/**
* Deletes a favorite from the workspace and updates the store
* @param workspaceSlug
@@ -158,6 +240,10 @@ export class FavoriteStore implements IFavoriteStore {
await this.favoriteService.deleteFavorite(workspaceSlug, favoriteId);
runInAction(() => {
const parent = this.favoriteMap[favoriteId].parent;
const children = this.favoriteMap[favoriteId].children;
const entity_identifier = this.favoriteMap[favoriteId].entity_identifier;
entity_identifier &&
this.removeFavoriteEntityFromStore(entity_identifier, this.favoriteMap[favoriteId].entity_type);
if (parent) {
set(
this.favoriteMap,
@@ -165,7 +251,16 @@ export class FavoriteStore implements IFavoriteStore {
this.favoriteMap[parent].children.filter((child) => child.id !== favoriteId)
);
}
if (children) {
children.forEach((child) => {
console.log(child.entity_type);
if (!child.entity_identifier) return;
this.removeFavoriteEntityFromStore(child.entity_identifier, child.entity_type);
});
}
delete this.favoriteMap[favoriteId];
entity_identifier && delete this.entityMap[entity_identifier];
this.favoriteIds = this.favoriteIds.filter((id) => id !== favoriteId);
});
} catch (error) {

View File

@@ -41,7 +41,7 @@ export interface IPage extends TPage {
restore: () => Promise<void>;
updatePageLogo: (logo_props: TLogoProps) => Promise<void>;
addToFavorites: () => Promise<void>;
removeFromFavorites: () => Promise<void>;
removePageFromFavorites: () => Promise<void>;
}
export class Page implements IPage {
@@ -146,7 +146,7 @@ export class Page implements IPage {
restore: action,
updatePageLogo: action,
addToFavorites: action,
removeFromFavorites: action,
removePageFromFavorites: action,
});
this.pageService = new ProjectPageService();
@@ -497,7 +497,7 @@ export class Page implements IPage {
/**
* @description remove the page from favorites
*/
removeFromFavorites = async () => {
removePageFromFavorites = async () => {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !this.id) return undefined;

View File

@@ -285,6 +285,7 @@ export class ProjectStore implements IProjectStore {
project_id: projectId,
entity_data: { name: this.projectMap[projectId].name || "" },
});
await this.fetchProjects(workspaceSlug);
return response;
} catch (error) {
console.log("Failed to add project to favorite");

View File

@@ -80,7 +80,7 @@ export class CoreRootStore {
this.projectPages = new ProjectPageStore(this);
this.projectEstimate = new ProjectEstimateStore(this);
this.workspaceNotification = new WorkspaceNotificationStore(this);
this.favorite = new FavoriteStore();
this.favorite = new FavoriteStore(this);
}
resetOnSignOut() {