[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:
Akshita Goyal 2024-08-02 12:25:26 +05:30 committed by GitHub
parent f55c135052
commit f4f5e5a0d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1436 additions and 181 deletions

View file

@ -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>

View file

@ -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>
</>
);
};

View file

@ -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>
);
}
);

View file

@ -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>
);
});

View file

@ -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>
);
};

View file

@ -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"

View file

@ -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";

View 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;
};

View file

@ -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(() =>

View 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;
});
}
}

View file

@ -0,0 +1 @@
export * from "./favorite.service";

View file

@ -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(() => {

View 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;
}
};
}

View file

@ -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(() => {

View file

@ -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;
});

View file

@ -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(() => {

View file

@ -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) {

View file

@ -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() {