[WEB-1907] feat: Favorites Enhancements (#5262)
* 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 --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
parent
f55c135052
commit
f4f5e5a0d3
29 changed files with 1436 additions and 181 deletions
|
|
@ -10,6 +10,7 @@ import {
|
|||
SidebarWorkspaceMenu,
|
||||
} from "@/components/workspace";
|
||||
// helpers
|
||||
import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store";
|
||||
|
|
@ -41,7 +42,6 @@ export const AppSidebar: FC<IAppSidebar> = observer(() => {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [windowSize]);
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -78,6 +78,12 @@ export const AppSidebar: FC<IAppSidebar> = observer(() => {
|
|||
"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 />
|
||||
<SidebarHelpSection />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,247 @@
|
|||
"use client";
|
||||
|
||||
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 { useParams } from "next/navigation";
|
||||
import { PenSquare, Star, MoreHorizontal, ChevronRight } 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";
|
||||
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store";
|
||||
import { useFavorite } from "@/hooks/store/use-favorite";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// constants
|
||||
import { FavoriteItem } from "./favorite-item";
|
||||
import { NewFavoriteFolder } from "./new-fav-folder";
|
||||
|
||||
type Props = {
|
||||
isLastChild: boolean;
|
||||
favorite: IFavorite;
|
||||
handleRemoveFromFavorites: (favorite: IFavorite) => void;
|
||||
};
|
||||
|
||||
export const FavoriteFolder: React.FC<Props> = (props) => {
|
||||
const { isLastChild, favorite, handleRemoveFromFavorites } = props;
|
||||
// store hooks
|
||||
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
|
||||
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { moveFavorite, getGroupedFavorites } = 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);
|
||||
// refs
|
||||
const actionSectionRef = 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((res) => {
|
||||
console.log(res, "res");
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Favorite moved successfully.",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err, "err");
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Failed to move favorite.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
return combine(
|
||||
dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({ type: "PARENT", id: favorite.id }),
|
||||
onDragEnter: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: ({ self, source }) => {
|
||||
setInstruction(undefined);
|
||||
setIsDragging(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;
|
||||
|
||||
handleOnDrop(sourceId, destinationId);
|
||||
},
|
||||
})
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [elementRef.current, isDragging, favorite.id, handleOnDrop]);
|
||||
|
||||
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
|
||||
|
||||
return folderToRename ? (
|
||||
<NewFavoriteFolder
|
||||
setCreateNewFolder={setFolderToRename}
|
||||
actionType="rename"
|
||||
defaultName={favorite.name}
|
||||
favoriteId={favorite.id}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Disclosure key={`${favorite.id}`} ref={elementRef} defaultOpen={false}>
|
||||
{({ open }) => (
|
||||
<div
|
||||
// id={`sidebar-${projectId}-${projectListType}`}
|
||||
className={cn("relative", {
|
||||
"bg-custom-sidebar-background-80 opacity-60": isDragging,
|
||||
})}
|
||||
>
|
||||
<DropIndicator classNames="absolute top-0" isVisible={instruction === "DRAG_OVER"} />
|
||||
<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",
|
||||
{
|
||||
"bg-custom-sidebar-background-90": isMenuActive,
|
||||
"p-0 size-8 aspect-square justify-center mx-auto": isSidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<div
|
||||
className={cn("flex-grow flex items-center gap-1.5 truncate text-left select-none", {
|
||||
"justify-center": isSidebarCollapsed,
|
||||
})}
|
||||
>
|
||||
<Disclosure.Button as="button" className="size-8 aspect-square flex-shrink-0 grid place-items-center">
|
||||
<div className="size-4 grid place-items-center flex-shrink-0">
|
||||
<FavoriteFolderIcon />
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip
|
||||
tooltipContent={`${favorite.name}`}
|
||||
position="right"
|
||||
disabled={!isSidebarCollapsed}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<div className="flex-grow flex truncate">
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full", {
|
||||
"justify-center": isSidebarCollapsed,
|
||||
})}
|
||||
>
|
||||
<div className="size-4 grid place-items-center flex-shrink-0">
|
||||
<FavoriteFolderIcon />
|
||||
</div>
|
||||
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{favorite.name}</p>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<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,
|
||||
}
|
||||
)}
|
||||
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.MenuItem onClick={() => setFolderToRename(favorite.id)}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<PenSquare className="h-3.5 w-3.5 stroke-[1.5] text-custom-text-300" />
|
||||
<span>Rename Folder</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={cn(
|
||||
"hidden group-hover/project-item:inline-block p-0.5 rounded hover:bg-custom-sidebar-background-80",
|
||||
{
|
||||
"inline-block": isMenuActive,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
|
||||
"rotate-90": open,
|
||||
})}
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{favorite.children && favorite.children.length > 0 && (
|
||||
<Transition
|
||||
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"
|
||||
>
|
||||
<Disclosure.Panel as="div" className="flex flex-col gap-0.5 mt-1 px-2">
|
||||
{favorite.children.map((child) => (
|
||||
<FavoriteItem
|
||||
key={child.id}
|
||||
favorite={child}
|
||||
handleRemoveFromFavorites={handleRemoveFromFavorites}
|
||||
/>
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
)}
|
||||
{isLastChild && <DropIndicator isVisible={instruction === "DRAG_BELOW"} />}
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Briefcase, FileText, Layers, MoreHorizontal, Star } from "lucide-react";
|
||||
// ui
|
||||
import { IFavorite } from "@plane/types";
|
||||
import { ContrastIcon, CustomMenu, DiceIcon, DragHandle, FavoriteFolderIcon, LayersIcon, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar";
|
||||
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
export const FavoriteItem = observer(
|
||||
({
|
||||
favorite,
|
||||
handleRemoveFromFavorites,
|
||||
}: {
|
||||
favorite: IFavorite;
|
||||
handleRemoveFromFavorites: (favorite: IFavorite) => void;
|
||||
}) => {
|
||||
// store hooks
|
||||
const { sidebarCollapsed } = useAppTheme();
|
||||
const { isMobile } = usePlatformOS();
|
||||
//state
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
// derived values
|
||||
|
||||
//ref
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const dragHandleRef = useRef<HTMLButtonElement | null>(null);
|
||||
const actionSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const getIcon = () => {
|
||||
const className = `flex-shrink-0 size-4 stroke-[1.5]`;
|
||||
|
||||
switch (favorite.entity_type) {
|
||||
case "page":
|
||||
return <FileText className={className} />;
|
||||
case "project":
|
||||
return <Briefcase className={className} />;
|
||||
case "view":
|
||||
return <Layers className={className} />;
|
||||
case "module":
|
||||
return <DiceIcon className={className} />;
|
||||
case "cycle":
|
||||
return <ContrastIcon className={className} />;
|
||||
case "issue":
|
||||
return <LayersIcon className={className} />;
|
||||
case "folder":
|
||||
return <FavoriteFolderIcon className={className} />;
|
||||
default:
|
||||
return <FileText />;
|
||||
}
|
||||
};
|
||||
|
||||
const getLink = () => {
|
||||
switch (favorite.entity_type) {
|
||||
case "project":
|
||||
return `/${workspaceSlug}/projects/${favorite.project_id}/issues`;
|
||||
case "cycle":
|
||||
return `/${workspaceSlug}/projects/${favorite.project_id}/cycles/${favorite.entity_identifier}`;
|
||||
case "module":
|
||||
return `/${workspaceSlug}/projects/${favorite.project_id}/modules/${favorite.entity_identifier}`;
|
||||
case "view":
|
||||
return `/${workspaceSlug}/projects/${favorite.project_id}/views/${favorite.entity_identifier}`;
|
||||
case "page":
|
||||
return `/${workspaceSlug}/projects/${favorite.project_id}/pages/${favorite.entity_identifier}`;
|
||||
default:
|
||||
return `/${workspaceSlug}`;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
dragHandle: element,
|
||||
canDrag: () => true,
|
||||
getInitialData: () => ({ id: favorite.id, type: "CHILD" }),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
})
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [elementRef?.current, isDragging]);
|
||||
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
|
||||
|
||||
return (
|
||||
<div ref={elementRef} className="group/project-item">
|
||||
<SidebarNavItem
|
||||
key={favorite.id}
|
||||
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
|
||||
>
|
||||
<div className="flex flex-between items-center gap-1.5 py-[1px] w-full">
|
||||
<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": sidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
ref={dragHandleRef}
|
||||
>
|
||||
<DragHandle className="bg-transparent" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{getIcon()}
|
||||
|
||||
{!sidebarCollapsed && (
|
||||
<Link href={getLink()} className="text-sm leading-5 font-medium flex-1">
|
||||
{favorite.entity_data ? favorite.entity_data.name : favorite.name}
|
||||
</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,
|
||||
}
|
||||
)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ChevronRight, FolderPlus } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { IFavorite } from "@plane/types";
|
||||
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
|
||||
// constants
|
||||
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store";
|
||||
import { useFavorite } from "@/hooks/store/use-favorite";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
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
|
||||
|
||||
// store hooks
|
||||
const { sidebarCollapsed } = useAppTheme();
|
||||
const { favoriteIds, favoriteMap, deleteFavorite } = useFavorite();
|
||||
const { workspaceSlug } = useParams();
|
||||
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
// local storage
|
||||
const { setValue: toggleFavoriteMenu, storedValue } = useLocalStorage<boolean>("is_favorite_menu_open", false);
|
||||
// derived values
|
||||
const isFavoriteMenuOpen = !!storedValue;
|
||||
// refs
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleRemoveFromFavorites = (favorite: IFavorite) => {
|
||||
deleteFavorite(workspaceSlug.toString(), favorite.id)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Favorite removed successfully.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Something went wrong!",
|
||||
});
|
||||
});
|
||||
};
|
||||
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]);
|
||||
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>
|
||||
{!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"
|
||||
>
|
||||
<span onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)} className="flex-1 text-start">
|
||||
MY FAVORITES
|
||||
</span>
|
||||
<span className="flex gap-2 flex-shrink-0 opacity-0 pointer-events-none group-hover/workspace-button:opacity-100 group-hover/workspace-button:pointer-events-auto rounded p-0.5 ">
|
||||
<FolderPlus
|
||||
onClick={() => {
|
||||
setCreateNewFolder(true);
|
||||
!isFavoriteMenuOpen && toggleFavoriteMenu(!isFavoriteMenuOpen);
|
||||
}}
|
||||
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform")}
|
||||
/>
|
||||
<ChevronRight
|
||||
onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)}
|
||||
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
|
||||
"rotate-90": isFavoriteMenuOpen,
|
||||
})}
|
||||
/>
|
||||
</span>
|
||||
</Disclosure.Button>
|
||||
)}
|
||||
<Transition
|
||||
show={isFavoriteMenuOpen}
|
||||
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"
|
||||
>
|
||||
{isFavoriteMenuOpen && (
|
||||
<Disclosure.Panel
|
||||
as="div"
|
||||
className={cn("flex flex-col mt-0.5 gap-0.5", {
|
||||
"space-y-0 mt-0 ml-0": sidebarCollapsed,
|
||||
})}
|
||||
static
|
||||
>
|
||||
{createNewFolder && <NewFavoriteFolder setCreateNewFolder={setCreateNewFolder} actionType="create" />}
|
||||
{favoriteIds
|
||||
.filter((id) => !favoriteMap[id].parent)
|
||||
.map((id, index) => (
|
||||
<Tooltip
|
||||
key={favoriteMap[id].id}
|
||||
tooltipContent={
|
||||
favoriteMap[id].entity_data ? favoriteMap[id].entity_data.name : favoriteMap[id].name
|
||||
}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!sidebarCollapsed}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
{favoriteMap[id].is_folder ? (
|
||||
<FavoriteFolder
|
||||
favorite={favoriteMap[id]}
|
||||
isLastChild={index === favoriteIds.length - 1}
|
||||
handleRemoveFromFavorites={handleRemoveFromFavorites}
|
||||
/>
|
||||
) : (
|
||||
<FavoriteItem favorite={favoriteMap[id]} handleRemoveFromFavorites={handleRemoveFromFavorites} />
|
||||
)}
|
||||
</Tooltip>
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
)}
|
||||
</Transition>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Controller, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { FavoriteFolderIcon, Input, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
import { useFavorite } from "@/hooks/store/use-favorite";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
|
||||
type TForm = {
|
||||
name: string;
|
||||
entity_type: string;
|
||||
parent: string | null;
|
||||
project_id: string | null;
|
||||
is_folder: boolean;
|
||||
};
|
||||
type TProps = {
|
||||
setCreateNewFolder: (value: boolean | string | null) => void;
|
||||
actionType: "create" | "rename";
|
||||
defaultName?: string;
|
||||
favoriteId?: string;
|
||||
};
|
||||
export const NewFavoriteFolder = (props: TProps) => {
|
||||
const { setCreateNewFolder, actionType, defaultName, favoriteId } = props;
|
||||
const { workspaceSlug } = useParams();
|
||||
const { addFavorite, updateFavorite } = useFavorite();
|
||||
|
||||
// ref
|
||||
const ref = useRef(null);
|
||||
|
||||
// form info
|
||||
const { handleSubmit, control, setValue, setFocus } = useForm<TForm>({
|
||||
reValidateMode: "onChange",
|
||||
defaultValues: {
|
||||
name: defaultName,
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddNewFolder: SubmitHandler<TForm> = (formData) => {
|
||||
formData = {
|
||||
entity_type: "folder",
|
||||
is_folder: true,
|
||||
name: formData.name,
|
||||
parent: null,
|
||||
project_id: null,
|
||||
};
|
||||
addFavorite(workspaceSlug.toString(), formData)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Favorite created successfully.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Something went wrong!",
|
||||
});
|
||||
});
|
||||
setCreateNewFolder(false);
|
||||
setValue("name", "");
|
||||
};
|
||||
|
||||
const handleRenameFolder: SubmitHandler<TForm> = (formData) => {
|
||||
if (!favoriteId) return;
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
};
|
||||
updateFavorite(workspaceSlug.toString(), favoriteId, payload).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Favorite updated successfully.",
|
||||
});
|
||||
});
|
||||
setCreateNewFolder(false);
|
||||
setValue("name", "");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFocus("name");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
useOutsideClickDetector(ref, () => {
|
||||
setCreateNewFolder(false);
|
||||
});
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 py-[1px] px-2" ref={ref}>
|
||||
<FavoriteFolderIcon />
|
||||
<form onSubmit={handleSubmit(actionType === "create" ? handleAddNewFolder : handleRenameFolder)}>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => <Input placeholder="New folder" {...field} />}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -5,7 +5,7 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
|||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Briefcase, ChevronRight, LucideIcon, Plus, Star } from "lucide-react";
|
||||
import { Briefcase, ChevronRight, Plus } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// types
|
||||
import { IProject } from "@plane/types";
|
||||
|
|
@ -25,14 +25,10 @@ import { useAppTheme, useCommandPalette, useEventTracker, useProject, useUser }
|
|||
|
||||
export const SidebarProjectsList: FC = observer(() => {
|
||||
// get local storage data for isFavoriteProjectsListOpen and isAllProjectsListOpen
|
||||
const isFavProjectsListOpenInLocalStorage = localStorage.getItem("isFavoriteProjectsListOpen");
|
||||
const isAllProjectsListOpenInLocalStorage = localStorage.getItem("isAllProjectsListOpen");
|
||||
// states
|
||||
const [isFavoriteProjectsListOpen, setIsFavoriteProjectsListOpen] = useState(
|
||||
isFavProjectsListOpenInLocalStorage === "true"
|
||||
);
|
||||
|
||||
const [isAllProjectsListOpen, setIsAllProjectsListOpen] = useState(isAllProjectsListOpenInLocalStorage === "true");
|
||||
const [isFavoriteProjectCreate, setIsFavoriteProjectCreate] = useState(false);
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
const [isScrolled, setIsScrolled] = useState(false); // scroll animation state
|
||||
// refs
|
||||
|
|
@ -44,12 +40,7 @@ export const SidebarProjectsList: FC = observer(() => {
|
|||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
const {
|
||||
getProjectById,
|
||||
joinedProjectIds: joinedProjects,
|
||||
favoriteProjectIds: favoriteProjects,
|
||||
updateProjectView,
|
||||
} = useProject();
|
||||
const { getProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject();
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
// auth
|
||||
|
|
@ -132,49 +123,18 @@ export const SidebarProjectsList: FC = observer(() => {
|
|||
);
|
||||
}, [containerRef]);
|
||||
|
||||
const toggleListDisclosure = (isOpen: boolean, type: "all" | "favorite") => {
|
||||
if (type === "all") {
|
||||
setIsAllProjectsListOpen(isOpen);
|
||||
localStorage.setItem("isAllProjectsListOpen", isOpen.toString());
|
||||
} else {
|
||||
setIsFavoriteProjectsListOpen(isOpen);
|
||||
localStorage.setItem("isFavoriteProjectsListOpen", isOpen.toString());
|
||||
}
|
||||
const toggleListDisclosure = (isOpen: boolean) => {
|
||||
setIsAllProjectsListOpen(isOpen);
|
||||
localStorage.setItem("isAllProjectsListOpen", isOpen.toString());
|
||||
};
|
||||
|
||||
const projectSections: {
|
||||
key: "all" | "favorite";
|
||||
type: "FAVORITES" | "JOINED";
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
projects: string[];
|
||||
isOpen: boolean;
|
||||
}[] = [
|
||||
{
|
||||
key: "favorite",
|
||||
type: "FAVORITES",
|
||||
title: "FAVORITES",
|
||||
icon: Star,
|
||||
projects: favoriteProjects,
|
||||
isOpen: isFavoriteProjectsListOpen,
|
||||
},
|
||||
{
|
||||
key: "all",
|
||||
type: "JOINED",
|
||||
title: "YOUR PROJECTS",
|
||||
icon: Briefcase,
|
||||
projects: joinedProjects,
|
||||
isOpen: isAllProjectsListOpen,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{workspaceSlug && (
|
||||
<CreateProjectModal
|
||||
isOpen={isProjectModalOpen}
|
||||
onClose={() => setIsProjectModalOpen(false)}
|
||||
setToFavorite={isFavoriteProjectCreate}
|
||||
setToFavorite={false}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -184,123 +144,106 @@ export const SidebarProjectsList: FC = observer(() => {
|
|||
"border-t border-custom-sidebar-border-300": isScrolled,
|
||||
})}
|
||||
>
|
||||
{projectSections.map((section, index) => {
|
||||
if (!section.projects || section.projects.length === 0) return;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Disclosure as="div" className="flex flex-col" defaultOpen={isAllProjectsListOpen}>
|
||||
<>
|
||||
<Disclosure key={section.title} as="div" className="flex flex-col" defaultOpen={section.isOpen}>
|
||||
<>
|
||||
<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,
|
||||
}
|
||||
<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 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>
|
||||
)}
|
||||
>
|
||||
<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(!section.isOpen, section.key)}
|
||||
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
|
||||
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
|
||||
>
|
||||
<Tooltip
|
||||
tooltipHeading={section.title}
|
||||
tooltipContent=""
|
||||
position="right"
|
||||
disabled={!isCollapsed}
|
||||
>
|
||||
<>
|
||||
{isCollapsed ? (
|
||||
<section.icon className="flex-shrink-0 size-3" />
|
||||
) : (
|
||||
<span className="text-xs font-semibold">{section.title}</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_${section.type}_BLOCK`);
|
||||
setIsFavoriteProjectCreate(section.key === "favorite");
|
||||
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(!section.isOpen, section.key)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("flex-shrink-0 size-4 transition-all", {
|
||||
"rotate-90": section.isOpen,
|
||||
})}
|
||||
/>
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Transition
|
||||
show={section.isOpen}
|
||||
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"
|
||||
>
|
||||
{section.isOpen && (
|
||||
<Disclosure.Panel
|
||||
as="div"
|
||||
className={cn("space-y-1", {
|
||||
"space-y-0 ml-0": isCollapsed,
|
||||
<ChevronRight
|
||||
className={cn("flex-shrink-0 size-4 transition-all", {
|
||||
"rotate-90": isAllProjectsListOpen,
|
||||
})}
|
||||
static
|
||||
>
|
||||
{section.projects.map((projectId, index) => (
|
||||
<SidebarProjectsListItem
|
||||
key={projectId}
|
||||
projectId={projectId}
|
||||
handleCopyText={() => handleCopyText(projectId)}
|
||||
projectListType={section.type}
|
||||
disableDrag={section.key === "favorite"}
|
||||
disableDrop={section.key === "favorite"}
|
||||
isLastChild={index === section.projects.length - 1}
|
||||
handleOnProjectDrop={handleOnProjectDrop}
|
||||
/>
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
)}
|
||||
</Transition>
|
||||
</>
|
||||
</Disclosure>
|
||||
<hr
|
||||
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-2", {
|
||||
"opacity-0": !sidebarCollapsed,
|
||||
hidden: index === projectSections.length - 1,
|
||||
})}
|
||||
/>
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
|
||||
{isAuthorizedUser && joinedProjects?.length === 0 && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -228,4 +228,6 @@ export const GROUP_WORKSPACE = "Workspace_metrics";
|
|||
//Elements
|
||||
export const E_ONBOARDING = "Onboarding";
|
||||
export const E_ONBOARDING_STEP_1 = "Onboarding step 1";
|
||||
export const E_ONBOARDING_STEP_2 = "Onboarding step 2";
|
||||
export const E_ONBOARDING_STEP_2 = "Onboarding step 2";
|
||||
// Favorites
|
||||
export const FAVORITE_ADDED = "Favorite added";
|
||||
|
|
|
|||
10
web/core/hooks/store/use-favorite.ts
Normal file
10
web/core/hooks/store/use-favorite.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { useContext } from "react";
|
||||
// mobx store
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
import { IFavoriteStore } from "@/store/favorite.store";
|
||||
|
||||
export const useFavorite = (): IFavoriteStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useFavorites must be used within StoreProvider");
|
||||
return context.favorite;
|
||||
};
|
||||
|
|
@ -12,6 +12,7 @@ import { LogOut } from "lucide-react";
|
|||
import { Button, TOAST_TYPE, setToast, Tooltip } from "@plane/ui";
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { useMember, useProject, useUser, useWorkspace } from "@/hooks/store";
|
||||
import { useFavorite } from "@/hooks/store/use-favorite";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// images
|
||||
import PlaneBlackLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png";
|
||||
|
|
@ -31,6 +32,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
|
|||
// store hooks
|
||||
const { membership, signOut, data: currentUser } = useUser();
|
||||
const { fetchProjects } = useProject();
|
||||
const { fetchFavorite } = useFavorite();
|
||||
const {
|
||||
workspace: { fetchWorkspaceMembers },
|
||||
} = useMember();
|
||||
|
|
@ -68,6 +70,12 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
|
|||
: null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetch workspace favorite
|
||||
useSWR(
|
||||
workspaceSlug && currentWorkspace ? `WORKSPACE_FAVORITE_${workspaceSlug}` : null,
|
||||
workspaceSlug && currentWorkspace ? () => fetchFavorite(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut().catch(() =>
|
||||
|
|
|
|||
56
web/core/services/favorite/favorite.service.ts
Normal file
56
web/core/services/favorite/favorite.service.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { IFavorite } from "@plane/types";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
// types
|
||||
|
||||
export class FavoriteService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async addFavorite(workspaceSlug: string, data: Partial<IFavorite>): Promise<IFavorite> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/user-favorites/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async updateFavorite(workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>): Promise<IFavorite> {
|
||||
return this.patch(`/api/workspaces/${workspaceSlug}/user-favorites/${favoriteId}/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFavorite(workspaceSlug: string, favoriteId: string): Promise<void> {
|
||||
return this.delete(`/api/workspaces/${workspaceSlug}/user-favorites/${favoriteId}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async getFavorites(workspaceSlug: string): Promise<IFavorite[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/user-favorites/`, {
|
||||
params: {
|
||||
all: true,
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getGroupedFavorites(workspaceSlug: string, favoriteId: string): Promise<IFavorite[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/user-favorites/${favoriteId}/group/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
1
web/core/services/favorite/index.ts
Normal file
1
web/core/services/favorite/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./favorite.service";
|
||||
|
|
@ -552,7 +552,12 @@ export class CycleStore implements ICycleStore {
|
|||
if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], true);
|
||||
});
|
||||
// updating through api.
|
||||
const response = await this.cycleService.addCycleToFavorites(workspaceSlug, projectId, { cycle: cycleId });
|
||||
const response = await this.rootStore.favorite.addFavorite(workspaceSlug.toString(), {
|
||||
entity_type: "cycle",
|
||||
entity_identifier: cycleId,
|
||||
project_id: projectId,
|
||||
entity_data: { name: this.cycleMap[cycleId].name || "" },
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
|
|
@ -575,7 +580,7 @@ export class CycleStore implements ICycleStore {
|
|||
runInAction(() => {
|
||||
if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], false);
|
||||
});
|
||||
const response = await this.cycleService.removeCycleFromFavorites(workspaceSlug, projectId, cycleId);
|
||||
const response = await this.rootStore.favorite.removeFavoriteEntity(workspaceSlug, cycleId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
|
|
|
|||
245
web/core/store/favorite.store.ts
Normal file
245
web/core/store/favorite.store.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { uniqBy } from "lodash";
|
||||
import set from "lodash/set";
|
||||
import { action, observable, makeObservable, runInAction } from "mobx";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { IFavorite } from "@plane/types";
|
||||
import { FavoriteService } from "@/services/favorite";
|
||||
|
||||
export interface IFavoriteStore {
|
||||
// observables
|
||||
|
||||
favoriteIds: string[];
|
||||
favoriteMap: {
|
||||
[favoriteId: string]: IFavorite;
|
||||
};
|
||||
entityMap: {
|
||||
[entityId: string]: IFavorite;
|
||||
};
|
||||
// computed actions
|
||||
// actions
|
||||
fetchFavorite: (workspaceSlug: string) => Promise<IFavorite[]>;
|
||||
// CRUD actions
|
||||
addFavorite: (workspaceSlug: string, data: Partial<IFavorite>) => Promise<IFavorite>;
|
||||
updateFavorite: (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => Promise<IFavorite>;
|
||||
deleteFavorite: (workspaceSlug: string, favoriteId: string) => Promise<void>;
|
||||
getGroupedFavorites: (workspaceSlug: string, favoriteId: string) => Promise<IFavorite[]>;
|
||||
moveFavorite: (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => Promise<void>;
|
||||
removeFavoriteEntity: (workspaceSlug: string, entityId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class FavoriteStore implements IFavoriteStore {
|
||||
// observables
|
||||
favoriteIds: string[] = [];
|
||||
favoriteMap: {
|
||||
[favoriteId: string]: IFavorite;
|
||||
} = {};
|
||||
entityMap: {
|
||||
[entityId: string]: IFavorite;
|
||||
} = {};
|
||||
// service
|
||||
favoriteService;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
favoriteMap: observable,
|
||||
entityMap: observable,
|
||||
favoriteIds: observable,
|
||||
// action
|
||||
fetchFavorite: action,
|
||||
// CRUD actions
|
||||
addFavorite: action,
|
||||
getGroupedFavorites: action,
|
||||
moveFavorite: action,
|
||||
removeFavoriteEntity: action,
|
||||
});
|
||||
this.favoriteService = new FavoriteService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a favorite in the workspace and adds it to the store
|
||||
* @param workspaceSlug
|
||||
* @param data
|
||||
* @returns Promise<IFavorite>
|
||||
*/
|
||||
addFavorite = async (workspaceSlug: string, data: Partial<IFavorite>) => {
|
||||
const id = uuidv4();
|
||||
data = { ...data, parent: null, is_folder: data.entity_type === "folder" };
|
||||
|
||||
try {
|
||||
// optimistic addition
|
||||
runInAction(() => {
|
||||
set(this.favoriteMap, [id], data);
|
||||
data.entity_identifier && set(this.entityMap, [data.entity_identifier], data);
|
||||
this.favoriteIds = [id, ...this.favoriteIds];
|
||||
});
|
||||
const response = await this.favoriteService.addFavorite(workspaceSlug, data);
|
||||
|
||||
// overwrite the temp id
|
||||
runInAction(() => {
|
||||
delete this.favoriteMap[id];
|
||||
set(this.favoriteMap, [response.id], response);
|
||||
response.entity_identifier && set(this.entityMap, [response.entity_identifier], response);
|
||||
this.favoriteIds = [response.id, ...this.favoriteIds.filter((favId) => favId !== id)];
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
delete this.favoriteMap[id];
|
||||
data.entity_identifier && delete this.entityMap[data.entity_identifier];
|
||||
this.favoriteIds = this.favoriteIds.filter((favId) => favId !== id);
|
||||
|
||||
console.error("Failed to create favorite from favorite store");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a favorite in the workspace and updates the store
|
||||
* @param workspaceSlug
|
||||
* @param favoriteId
|
||||
* @param data
|
||||
* @returns Promise<IFavorite>
|
||||
*/
|
||||
updateFavorite = async (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => {
|
||||
try {
|
||||
const response = await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, data);
|
||||
runInAction(() => {
|
||||
set(this.favoriteMap, [response.id], response);
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to update favorite from favorite store");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves a favorite in the workspace and updates the store
|
||||
* @param workspaceSlug
|
||||
* @param favoriteId
|
||||
* @param data
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
moveFavorite = async (workspaceSlug: string, favoriteId: string, data: Partial<IFavorite>) => {
|
||||
try {
|
||||
const response = await this.favoriteService.updateFavorite(workspaceSlug, favoriteId, data);
|
||||
runInAction(() => {
|
||||
// add the favorite to the new parent
|
||||
if (!data.parent) return;
|
||||
set(this.favoriteMap, [data.parent, "children"], [response, ...this.favoriteMap[data.parent].children]);
|
||||
|
||||
// remove the favorite from the old parent
|
||||
const oldParent = this.favoriteMap[favoriteId].parent;
|
||||
if (oldParent) {
|
||||
set(
|
||||
this.favoriteMap,
|
||||
[oldParent, "children"],
|
||||
this.favoriteMap[oldParent].children.filter((child) => child.id !== favoriteId)
|
||||
);
|
||||
}
|
||||
|
||||
// add parent of the favorite
|
||||
set(this.favoriteMap, [favoriteId, "parent"], data.parent);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to move favorite from favorite store");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a favorite from the workspace and updates the store
|
||||
* @param workspaceSlug
|
||||
* @param favoriteId
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
deleteFavorite = async (workspaceSlug: string, favoriteId: string) => {
|
||||
try {
|
||||
await this.favoriteService.deleteFavorite(workspaceSlug, favoriteId);
|
||||
runInAction(() => {
|
||||
const parent = this.favoriteMap[favoriteId].parent;
|
||||
if (parent) {
|
||||
set(
|
||||
this.favoriteMap,
|
||||
[parent, "children"],
|
||||
this.favoriteMap[parent].children.filter((child) => child.id !== favoriteId)
|
||||
);
|
||||
}
|
||||
delete this.favoriteMap[favoriteId];
|
||||
this.favoriteIds = this.favoriteIds.filter((id) => id !== favoriteId);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete favorite from favorite store");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a favorite entity from the workspace and updates the store
|
||||
* @param workspaceSlug
|
||||
* @param entityId
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
removeFavoriteEntity = async (workspaceSlug: string, entityId: string) => {
|
||||
try {
|
||||
const favoriteId = this.entityMap[entityId].id;
|
||||
await this.deleteFavorite(workspaceSlug, favoriteId);
|
||||
runInAction(() => {
|
||||
delete this.entityMap[entityId];
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to remove favorite entity from favorite store");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* get Grouped Favorites
|
||||
* @param workspaceSlug
|
||||
* @param favoriteId
|
||||
* @returns Promise<IFavorite[]>
|
||||
*/
|
||||
getGroupedFavorites = async (workspaceSlug: string, favoriteId: string) => {
|
||||
try {
|
||||
const response = await this.favoriteService.getGroupedFavorites(workspaceSlug, favoriteId);
|
||||
runInAction(() => {
|
||||
// add children to the favorite
|
||||
set(this.favoriteMap, [favoriteId, "children"], response);
|
||||
// add the favorites to the map
|
||||
response.forEach((favorite) => {
|
||||
set(this.favoriteMap, [favorite.id], favorite);
|
||||
this.favoriteIds.push(favorite.id);
|
||||
this.favoriteIds = uniqBy(this.favoriteIds, (id) => id);
|
||||
favorite.entity_identifier && set(this.entityMap, [favorite.entity_identifier], favorite);
|
||||
});
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to get grouped favorites from favorite store");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* get Workspace favorite using workspace slug
|
||||
* @param workspaceSlug
|
||||
* @returns Promise<IFavorite[]>
|
||||
*
|
||||
*/
|
||||
fetchFavorite = async (workspaceSlug: string) => {
|
||||
try {
|
||||
const favorites = await this.favoriteService.getFavorites(workspaceSlug);
|
||||
runInAction(() => {
|
||||
favorites.forEach((favorite) => {
|
||||
set(this.favoriteMap, [favorite.id], favorite);
|
||||
this.favoriteIds.push(favorite.id);
|
||||
favorite.entity_identifier && set(this.entityMap, [favorite.entity_identifier], favorite);
|
||||
});
|
||||
});
|
||||
return favorites;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch favorites from workspace store");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -486,8 +486,11 @@ export class ModulesStore implements IModuleStore {
|
|||
runInAction(() => {
|
||||
set(this.moduleMap, [moduleId, "is_favorite"], true);
|
||||
});
|
||||
await this.moduleService.addModuleToFavorites(workspaceSlug, projectId, {
|
||||
module: moduleId,
|
||||
await this.rootStore.favorite.addFavorite(workspaceSlug.toString(), {
|
||||
entity_type: "module",
|
||||
entity_identifier: moduleId,
|
||||
project_id: projectId,
|
||||
entity_data: { name: this.moduleMap[moduleId].name || "" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to add module to favorites in module store", error);
|
||||
|
|
@ -511,7 +514,7 @@ export class ModulesStore implements IModuleStore {
|
|||
runInAction(() => {
|
||||
set(this.moduleMap, [moduleId, "is_favorite"], false);
|
||||
});
|
||||
await this.moduleService.removeModuleFromFavorites(workspaceSlug, projectId, moduleId);
|
||||
await this.rootStore.favorite.removeFavoriteEntity(workspaceSlug, moduleId);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove module from favorites in module store", error);
|
||||
runInAction(() => {
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ export class Page implements IPage {
|
|||
disposers: Array<() => void> = [];
|
||||
// services
|
||||
pageService: ProjectPageService;
|
||||
|
||||
// root store
|
||||
rootStore: CoreRootStore;
|
||||
constructor(
|
||||
private store: CoreRootStore,
|
||||
page: TPage
|
||||
|
|
@ -149,7 +150,7 @@ export class Page implements IPage {
|
|||
});
|
||||
|
||||
this.pageService = new ProjectPageService();
|
||||
|
||||
this.rootStore = store;
|
||||
const titleDisposer = reaction(
|
||||
() => this.name,
|
||||
(name) => {
|
||||
|
|
@ -385,7 +386,7 @@ export class Page implements IPage {
|
|||
runInAction(() => (this.access = EPageAccess.PRIVATE));
|
||||
|
||||
try {
|
||||
await this.pageService.updateAccess (workspaceSlug, projectId, this.id, {
|
||||
await this.pageService.updateAccess(workspaceSlug, projectId, this.id, {
|
||||
access: EPageAccess.PRIVATE,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -478,13 +479,19 @@ export class Page implements IPage {
|
|||
runInAction(() => {
|
||||
this.is_favorite = true;
|
||||
});
|
||||
|
||||
await this.pageService.addToFavorites(workspaceSlug, projectId, this.id).catch((error) => {
|
||||
runInAction(() => {
|
||||
this.is_favorite = pageIsFavorite;
|
||||
await this.rootStore.favorite
|
||||
.addFavorite(workspaceSlug.toString(), {
|
||||
entity_type: "page",
|
||||
entity_identifier: this.id,
|
||||
project_id: projectId,
|
||||
entity_data: { name: this.name || "" },
|
||||
})
|
||||
.catch((error) => {
|
||||
runInAction(() => {
|
||||
this.is_favorite = pageIsFavorite;
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -499,7 +506,7 @@ export class Page implements IPage {
|
|||
this.is_favorite = false;
|
||||
});
|
||||
|
||||
await this.pageService.removeFromFavorites(workspaceSlug, projectId, this.id).catch((error) => {
|
||||
await this.rootStore.favorite.removeFavoriteEntity(workspaceSlug, this.id).catch((error) => {
|
||||
runInAction(() => {
|
||||
this.is_favorite = pageIsFavorite;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -358,8 +358,11 @@ export class ProjectViewStore implements IProjectViewStore {
|
|||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "is_favorite"], true);
|
||||
});
|
||||
await this.viewService.addViewToFavorites(workspaceSlug, projectId, {
|
||||
view: viewId,
|
||||
await this.rootStore.favorite.addFavorite(workspaceSlug.toString(), {
|
||||
entity_type: "view",
|
||||
entity_identifier: viewId,
|
||||
project_id: projectId,
|
||||
entity_data: { name: this.viewMap[viewId].name || "" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to add view to favorites in view store", error);
|
||||
|
|
@ -383,7 +386,7 @@ export class ProjectViewStore implements IProjectViewStore {
|
|||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "is_favorite"], false);
|
||||
});
|
||||
await this.viewService.removeViewFromFavorites(workspaceSlug, projectId, viewId);
|
||||
await this.rootStore.favorite.removeFavoriteEntity(workspaceSlug, viewId);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove view from favorites in view store", error);
|
||||
runInAction(() => {
|
||||
|
|
|
|||
|
|
@ -279,7 +279,12 @@ export class ProjectStore implements IProjectStore {
|
|||
runInAction(() => {
|
||||
set(this.projectMap, [projectId, "is_favorite"], true);
|
||||
});
|
||||
const response = await this.projectService.addProjectToFavorites(workspaceSlug, projectId);
|
||||
const response = await this.rootStore.favorite.addFavorite(workspaceSlug.toString(), {
|
||||
entity_type: "project",
|
||||
entity_identifier: projectId,
|
||||
project_id: projectId,
|
||||
entity_data: { name: this.projectMap[projectId].name || "" },
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log("Failed to add project to favorite");
|
||||
|
|
@ -300,10 +305,11 @@ export class ProjectStore implements IProjectStore {
|
|||
try {
|
||||
const currentProject = this.getProjectById(projectId);
|
||||
if (!currentProject.is_favorite) return;
|
||||
const response = await this.rootStore.favorite.removeFavoriteEntity(workspaceSlug.toString(), projectId);
|
||||
|
||||
runInAction(() => {
|
||||
set(this.projectMap, [projectId, "is_favorite"], false);
|
||||
});
|
||||
const response = await this.projectService.removeProjectFromFavorites(workspaceSlug, projectId);
|
||||
await this.fetchProjects(workspaceSlug);
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store";
|
|||
import { DashboardStore, IDashboardStore } from "./dashboard.store";
|
||||
import { IProjectEstimateStore, ProjectEstimateStore } from "./estimates/project-estimate.store";
|
||||
import { EventTrackerStore, IEventTrackerStore } from "./event-tracker.store";
|
||||
import { FavoriteStore, IFavoriteStore } from "./favorite.store";
|
||||
import { GlobalViewStore, IGlobalViewStore } from "./global-view.store";
|
||||
import { IProjectInboxStore, ProjectInboxStore } from "./inbox/project-inbox.store";
|
||||
import { InstanceStore, IInstanceStore } from "./instance.store";
|
||||
|
|
@ -52,6 +53,7 @@ export class CoreRootStore {
|
|||
projectEstimate: IProjectEstimateStore;
|
||||
multipleSelect: IMultipleSelectStore;
|
||||
workspaceNotification: IWorkspaceNotificationStore;
|
||||
favorite: IFavoriteStore;
|
||||
|
||||
constructor() {
|
||||
this.router = new RouterStore();
|
||||
|
|
@ -78,6 +80,7 @@ export class CoreRootStore {
|
|||
this.projectPages = new ProjectPageStore(this);
|
||||
this.projectEstimate = new ProjectEstimateStore(this);
|
||||
this.workspaceNotification = new WorkspaceNotificationStore(this);
|
||||
this.favorite = new FavoriteStore();
|
||||
}
|
||||
|
||||
resetOnSignOut() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue