[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

@ -121,3 +121,5 @@ from .exporter import ExporterHistorySerializer
from .webhook import WebhookSerializer, WebhookLogSerializer from .webhook import WebhookSerializer, WebhookLogSerializer
from .dashboard import DashboardSerializer, WidgetSerializer from .dashboard import DashboardSerializer, WidgetSerializer
from .favorite import UserFavoriteSerializer

View file

@ -0,0 +1,101 @@
from rest_framework import serializers
from plane.db.models import (
UserFavorite,
Cycle,
Module,
Issue,
IssueView,
Page,
Project,
)
class ProjectFavoriteLiteSerializer(serializers.ModelSerializer):
class Meta:
model = Project
fields = ["id", "name", "logo_props"]
class PageFavoriteLiteSerializer(serializers.ModelSerializer):
project_id = serializers.SerializerMethodField()
class Meta:
model = Page
fields = ["id", "name", "logo_props", "project_id"]
def get_project_id(self, obj):
project = (
obj.projects.first()
) # This gets the first project related to the Page
return project.id if project else None
class CycleFavoriteLiteSerializer(serializers.ModelSerializer):
class Meta:
model = Cycle
fields = ["id", "name", "logo_props", "project_id"]
class ModuleFavoriteLiteSerializer(serializers.ModelSerializer):
class Meta:
model = Module
fields = ["id", "name", "logo_props", "project_id"]
class ViewFavoriteSerializer(serializers.ModelSerializer):
class Meta:
model = IssueView
fields = ["id", "name", "logo_props", "project_id"]
def get_entity_model_and_serializer(entity_type):
entity_map = {
"cycle": (Cycle, CycleFavoriteLiteSerializer),
"issue": (Issue, None),
"module": (Module, ModuleFavoriteLiteSerializer),
"view": (IssueView, ViewFavoriteSerializer),
"page": (Page, PageFavoriteLiteSerializer),
"project": (Project, ProjectFavoriteLiteSerializer),
"folder": (None, None),
}
return entity_map.get(entity_type, (None, None))
class UserFavoriteSerializer(serializers.ModelSerializer):
entity_data = serializers.SerializerMethodField()
class Meta:
model = UserFavorite
fields = [
"id",
"entity_type",
"entity_identifier",
"entity_data",
"name",
"is_folder",
"sequence",
"parent",
"workspace_id",
"project_id",
]
read_only_fields = ["workspace", "created_by", "updated_by"]
def get_entity_data(self, obj):
entity_type = obj.entity_type
entity_identifier = obj.entity_identifier
entity_model, entity_serializer = get_entity_model_and_serializer(
entity_type
)
if entity_model and entity_serializer:
try:
entity = entity_model.objects.get(pk=entity_identifier)
return entity_serializer(entity).data
except entity_model.DoesNotExist:
return None
return None

View file

@ -25,6 +25,8 @@ from plane.app.views import (
ExportWorkspaceUserActivityEndpoint, ExportWorkspaceUserActivityEndpoint,
WorkspaceModulesEndpoint, WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint, WorkspaceCyclesEndpoint,
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
) )
@ -237,4 +239,19 @@ urlpatterns = [
WorkspaceCyclesEndpoint.as_view(), WorkspaceCyclesEndpoint.as_view(),
name="workspace-cycles", name="workspace-cycles",
), ),
path(
"workspaces/<str:slug>/user-favorites/",
WorkspaceFavoriteEndpoint.as_view(),
name="workspace-user-favorites",
),
path(
"workspaces/<str:slug>/user-favorites/<uuid:favorite_id>/",
WorkspaceFavoriteEndpoint.as_view(),
name="workspace-user-favorites",
),
path(
"workspaces/<str:slug>/user-favorites/<uuid:favorite_id>/group/",
WorkspaceFavoriteGroupEndpoint.as_view(),
name="workspace-user-favorites-groups",
),
] ]

View file

@ -40,6 +40,11 @@ from .workspace.base import (
ExportWorkspaceUserActivityEndpoint, ExportWorkspaceUserActivityEndpoint,
) )
from .workspace.favorite import (
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
)
from .workspace.member import ( from .workspace.member import (
WorkSpaceMemberViewSet, WorkSpaceMemberViewSet,
TeamMemberViewSet, TeamMemberViewSet,

View file

@ -0,0 +1,88 @@
# Third party modules
from rest_framework import status
from rest_framework.response import Response
# Django modules
from django.db.models import Q
# Module imports
from plane.app.views.base import BaseAPIView
from plane.db.models import UserFavorite, Workspace
from plane.app.serializers import UserFavoriteSerializer
from plane.app.permissions import WorkspaceEntityPermission
class WorkspaceFavoriteEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug):
# the second filter is to check if the user is a member of the project
favorites = UserFavorite.objects.filter(
user=request.user,
workspace__slug=slug,
parent__isnull=True,
).filter(
Q(project__isnull=True)
| (
Q(project__isnull=False)
& Q(project__project_projectmember__member=request.user)
& Q(project__project_projectmember__is_active=True)
)
)
serializer = UserFavoriteSerializer(favorites, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = UserFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
user_id=request.user.id,
workspace=workspace,
project_id=request.data.get("project_id", None),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request, slug, favorite_id):
favorite = UserFavorite.objects.get(
user=request.user, workspace__slug=slug, pk=favorite_id
)
serializer = UserFavoriteSerializer(
favorite, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, favorite_id):
favorite = UserFavorite.objects.get(
user=request.user, workspace__slug=slug, pk=favorite_id
)
favorite.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)
class WorkspaceFavoriteGroupEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug, favorite_id):
favorites = UserFavorite.objects.filter(
user=request.user,
workspace__slug=slug,
parent_id=favorite_id,
).filter(
Q(project__isnull=True)
| (
Q(project__isnull=False)
& Q(project__project_projectmember__member=request.user)
& Q(project__project_projectmember__is_active=True)
)
)
serializer = UserFavoriteSerializer(favorites, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)

View file

@ -39,9 +39,14 @@ class UserFavorite(WorkspaceBaseModel):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self._state.adding: if self._state.adding:
largest_sequence = UserFavorite.objects.filter( if self.project:
workspace=self.project.workspace largest_sequence = UserFavorite.objects.filter(
).aggregate(largest=models.Max("sequence"))["largest"] workspace=self.project.workspace
).aggregate(largest=models.Max("sequence"))["largest"]
else:
largest_sequence = UserFavorite.objects.filter(
workspace=self.workspace,
).aggregate(largest=models.Max("sequence"))["largest"]
if largest_sequence is not None: if largest_sequence is not None:
self.sequence = largest_sequence + 10000 self.sequence = largest_sequence + 10000

View file

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

View file

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

View file

@ -28,3 +28,4 @@ export * from "./common";
export * from "./pragmatic"; export * from "./pragmatic";
export * from "./publish"; export * from "./publish";
export * from "./workspace-notifications"; export * from "./workspace-notifications";
export * from "./favorite";

View file

@ -0,0 +1,32 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const FavoriteFolderIcon: React.FC<ISvgIcons> = ({ className = "text-current", color = "#a3a3a3", ...rest }) => (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke={color}
className={`${className} stroke-2`}
{...rest}
>
<path
d="M7.33325 13.3334H2.66659C2.31296 13.3334 1.97382 13.1929 1.72378 12.9429C1.47373 12.6928 1.33325 12.3537 1.33325 12.0001V3.3334C1.33325 2.97978 1.47373 2.64064 1.72378 2.39059C1.97382 2.14054 2.31296 2.00006 2.66659 2.00006H5.26659C5.48958 1.99788 5.70955 2.05166 5.90638 2.15648C6.10322 2.2613 6.27061 2.41381 6.39325 2.60006L6.93325 3.40006C7.05466 3.58442 7.21994 3.73574 7.41425 3.84047C7.60857 3.94519 7.82585 4.00003 8.04658 4.00006H13.3333C13.6869 4.00006 14.026 4.14054 14.2761 4.39059C14.5261 4.64064 14.6666 4.97978 14.6666 5.3334V6.3334"
// stroke="#60646C"
stroke-width="1.25"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12.1373 8L13.0038 9.75535L14.9414 10.0386L13.5394 11.4041L13.8702 13.3333L12.1373 12.422L10.4044 13.3333L10.7353 11.4041L9.33325 10.0386L11.2709 9.75535L12.1373 8Z"
stroke-width="1.25"
// stroke="#60646C"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
);

View file

@ -24,3 +24,4 @@ export * from "./info-icon";
export * from "./dropdown-icon"; export * from "./dropdown-icon";
export * from "./intake"; export * from "./intake";
export * from "./user-activity-icon"; export * from "./user-activity-icon";
export * from "./favorite-folder-icon";

View file

@ -10,6 +10,7 @@ import {
SidebarWorkspaceMenu, SidebarWorkspaceMenu,
} from "@/components/workspace"; } from "@/components/workspace";
// helpers // helpers
import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu";
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { useAppTheme } from "@/hooks/store"; import { useAppTheme } from "@/hooks/store";
@ -41,7 +42,6 @@ export const AppSidebar: FC<IAppSidebar> = observer(() => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [windowSize]); }, [windowSize]);
return ( return (
<div <div
className={cn( className={cn(
@ -78,6 +78,12 @@ export const AppSidebar: FC<IAppSidebar> = observer(() => {
"opacity-0": !sidebarCollapsed, "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 /> <SidebarProjectsList />
<SidebarHelpSection /> <SidebarHelpSection />
</div> </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 { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; 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"; import { Disclosure, Transition } from "@headlessui/react";
// types // types
import { IProject } from "@plane/types"; import { IProject } from "@plane/types";
@ -25,14 +25,10 @@ import { useAppTheme, useCommandPalette, useEventTracker, useProject, useUser }
export const SidebarProjectsList: FC = observer(() => { export const SidebarProjectsList: FC = observer(() => {
// get local storage data for isFavoriteProjectsListOpen and isAllProjectsListOpen // get local storage data for isFavoriteProjectsListOpen and isAllProjectsListOpen
const isFavProjectsListOpenInLocalStorage = localStorage.getItem("isFavoriteProjectsListOpen");
const isAllProjectsListOpenInLocalStorage = localStorage.getItem("isAllProjectsListOpen"); const isAllProjectsListOpenInLocalStorage = localStorage.getItem("isAllProjectsListOpen");
// states // states
const [isFavoriteProjectsListOpen, setIsFavoriteProjectsListOpen] = useState(
isFavProjectsListOpenInLocalStorage === "true"
);
const [isAllProjectsListOpen, setIsAllProjectsListOpen] = useState(isAllProjectsListOpenInLocalStorage === "true"); const [isAllProjectsListOpen, setIsAllProjectsListOpen] = useState(isAllProjectsListOpenInLocalStorage === "true");
const [isFavoriteProjectCreate, setIsFavoriteProjectCreate] = useState(false);
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false); // scroll animation state const [isScrolled, setIsScrolled] = useState(false); // scroll animation state
// refs // refs
@ -44,12 +40,7 @@ export const SidebarProjectsList: FC = observer(() => {
const { const {
membership: { currentWorkspaceRole }, membership: { currentWorkspaceRole },
} = useUser(); } = useUser();
const { const { getProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject();
getProjectById,
joinedProjectIds: joinedProjects,
favoriteProjectIds: favoriteProjects,
updateProjectView,
} = useProject();
// router params // router params
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
// auth // auth
@ -132,49 +123,18 @@ export const SidebarProjectsList: FC = observer(() => {
); );
}, [containerRef]); }, [containerRef]);
const toggleListDisclosure = (isOpen: boolean, type: "all" | "favorite") => { const toggleListDisclosure = (isOpen: boolean) => {
if (type === "all") { setIsAllProjectsListOpen(isOpen);
setIsAllProjectsListOpen(isOpen); localStorage.setItem("isAllProjectsListOpen", isOpen.toString());
localStorage.setItem("isAllProjectsListOpen", isOpen.toString());
} else {
setIsFavoriteProjectsListOpen(isOpen);
localStorage.setItem("isFavoriteProjectsListOpen", 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 ( return (
<> <>
{workspaceSlug && ( {workspaceSlug && (
<CreateProjectModal <CreateProjectModal
isOpen={isProjectModalOpen} isOpen={isProjectModalOpen}
onClose={() => setIsProjectModalOpen(false)} onClose={() => setIsProjectModalOpen(false)}
setToFavorite={isFavoriteProjectCreate} setToFavorite={false}
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
/> />
)} )}
@ -184,123 +144,106 @@ export const SidebarProjectsList: FC = observer(() => {
"border-t border-custom-sidebar-border-300": isScrolled, "border-t border-custom-sidebar-border-300": isScrolled,
})} })}
> >
{projectSections.map((section, index) => { <>
if (!section.projects || section.projects.length === 0) return; <Disclosure as="div" className="flex flex-col" defaultOpen={isAllProjectsListOpen}>
return (
<> <>
<Disclosure key={section.title} as="div" className="flex flex-col" defaultOpen={section.isOpen}> <div
<> className={cn(
<div "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",
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,
"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 <Disclosure.Button
as="button" as="button"
type="button" type="button"
className={cn( className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
"group w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400", onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
{
"!text-center w-8 px-2 py-1.5 justify-center": isCollapsed,
}
)}
onClick={() => toggleListDisclosure(!section.isOpen, section.key)}
> >
<Tooltip <ChevronRight
tooltipHeading={section.title} className={cn("flex-shrink-0 size-4 transition-all", {
tooltipContent="" "rotate-90": isAllProjectsListOpen,
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,
})} })}
static />
> </Disclosure.Button>
{section.projects.map((projectId, index) => ( </div>
<SidebarProjectsListItem )}
key={projectId} </div>
projectId={projectId} <Transition
handleCopyText={() => handleCopyText(projectId)} show={isAllProjectsListOpen}
projectListType={section.type} enter="transition duration-100 ease-out"
disableDrag={section.key === "favorite"} enterFrom="transform scale-95 opacity-0"
disableDrop={section.key === "favorite"} enterTo="transform scale-100 opacity-100"
isLastChild={index === section.projects.length - 1} leave="transition duration-75 ease-out"
handleOnProjectDrop={handleOnProjectDrop} leaveFrom="transform scale-100 opacity-100"
/> leaveTo="transform scale-95 opacity-0"
))} >
</Disclosure.Panel> {isAllProjectsListOpen && (
)} <Disclosure.Panel
</Transition> as="div"
</> className={cn("space-y-1", {
</Disclosure> "space-y-0 ml-0": isCollapsed,
<hr })}
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-2", { static
"opacity-0": !sidebarCollapsed, >
hidden: index === projectSections.length - 1, {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 && ( {isAuthorizedUser && joinedProjects?.length === 0 && (
<button <button
type="button" type="button"

View file

@ -228,4 +228,6 @@ export const GROUP_WORKSPACE = "Workspace_metrics";
//Elements //Elements
export const E_ONBOARDING = "Onboarding"; export const E_ONBOARDING = "Onboarding";
export const E_ONBOARDING_STEP_1 = "Onboarding step 1"; 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 { Button, TOAST_TYPE, setToast, Tooltip } from "@plane/ui";
import { LogoSpinner } from "@/components/common"; import { LogoSpinner } from "@/components/common";
import { useMember, useProject, useUser, useWorkspace } from "@/hooks/store"; import { useMember, useProject, useUser, useWorkspace } from "@/hooks/store";
import { useFavorite } from "@/hooks/store/use-favorite";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// images // images
import PlaneBlackLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; 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 // store hooks
const { membership, signOut, data: currentUser } = useUser(); const { membership, signOut, data: currentUser } = useUser();
const { fetchProjects } = useProject(); const { fetchProjects } = useProject();
const { fetchFavorite } = useFavorite();
const { const {
workspace: { fetchWorkspaceMembers }, workspace: { fetchWorkspaceMembers },
} = useMember(); } = useMember();
@ -68,6 +70,12 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
: null, : null,
{ revalidateIfStale: false, revalidateOnFocus: false } { 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 () => { const handleSignOut = async () => {
await signOut().catch(() => 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); if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], true);
}); });
// updating through api. // 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; return response;
} catch (error) { } catch (error) {
runInAction(() => { runInAction(() => {
@ -575,7 +580,7 @@ export class CycleStore implements ICycleStore {
runInAction(() => { runInAction(() => {
if (currentCycle) set(this.cycleMap, [cycleId, "is_favorite"], false); 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; return response;
} catch (error) { } catch (error) {
runInAction(() => { 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(() => { runInAction(() => {
set(this.moduleMap, [moduleId, "is_favorite"], true); set(this.moduleMap, [moduleId, "is_favorite"], true);
}); });
await this.moduleService.addModuleToFavorites(workspaceSlug, projectId, { await this.rootStore.favorite.addFavorite(workspaceSlug.toString(), {
module: moduleId, entity_type: "module",
entity_identifier: moduleId,
project_id: projectId,
entity_data: { name: this.moduleMap[moduleId].name || "" },
}); });
} catch (error) { } catch (error) {
console.error("Failed to add module to favorites in module store", error); console.error("Failed to add module to favorites in module store", error);
@ -511,7 +514,7 @@ export class ModulesStore implements IModuleStore {
runInAction(() => { runInAction(() => {
set(this.moduleMap, [moduleId, "is_favorite"], false); set(this.moduleMap, [moduleId, "is_favorite"], false);
}); });
await this.moduleService.removeModuleFromFavorites(workspaceSlug, projectId, moduleId); await this.rootStore.favorite.removeFavoriteEntity(workspaceSlug, moduleId);
} catch (error) { } catch (error) {
console.error("Failed to remove module from favorites in module store", error); console.error("Failed to remove module from favorites in module store", error);
runInAction(() => { runInAction(() => {

View file

@ -72,7 +72,8 @@ export class Page implements IPage {
disposers: Array<() => void> = []; disposers: Array<() => void> = [];
// services // services
pageService: ProjectPageService; pageService: ProjectPageService;
// root store
rootStore: CoreRootStore;
constructor( constructor(
private store: CoreRootStore, private store: CoreRootStore,
page: TPage page: TPage
@ -149,7 +150,7 @@ export class Page implements IPage {
}); });
this.pageService = new ProjectPageService(); this.pageService = new ProjectPageService();
this.rootStore = store;
const titleDisposer = reaction( const titleDisposer = reaction(
() => this.name, () => this.name,
(name) => { (name) => {
@ -385,7 +386,7 @@ export class Page implements IPage {
runInAction(() => (this.access = EPageAccess.PRIVATE)); runInAction(() => (this.access = EPageAccess.PRIVATE));
try { try {
await this.pageService.updateAccess (workspaceSlug, projectId, this.id, { await this.pageService.updateAccess(workspaceSlug, projectId, this.id, {
access: EPageAccess.PRIVATE, access: EPageAccess.PRIVATE,
}); });
} catch (error) { } catch (error) {
@ -478,13 +479,19 @@ export class Page implements IPage {
runInAction(() => { runInAction(() => {
this.is_favorite = true; this.is_favorite = true;
}); });
await this.rootStore.favorite
await this.pageService.addToFavorites(workspaceSlug, projectId, this.id).catch((error) => { .addFavorite(workspaceSlug.toString(), {
runInAction(() => { entity_type: "page",
this.is_favorite = pageIsFavorite; 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; 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(() => { runInAction(() => {
this.is_favorite = pageIsFavorite; this.is_favorite = pageIsFavorite;
}); });

View file

@ -358,8 +358,11 @@ export class ProjectViewStore implements IProjectViewStore {
runInAction(() => { runInAction(() => {
set(this.viewMap, [viewId, "is_favorite"], true); set(this.viewMap, [viewId, "is_favorite"], true);
}); });
await this.viewService.addViewToFavorites(workspaceSlug, projectId, { await this.rootStore.favorite.addFavorite(workspaceSlug.toString(), {
view: viewId, entity_type: "view",
entity_identifier: viewId,
project_id: projectId,
entity_data: { name: this.viewMap[viewId].name || "" },
}); });
} catch (error) { } catch (error) {
console.error("Failed to add view to favorites in view store", error); console.error("Failed to add view to favorites in view store", error);
@ -383,7 +386,7 @@ export class ProjectViewStore implements IProjectViewStore {
runInAction(() => { runInAction(() => {
set(this.viewMap, [viewId, "is_favorite"], false); set(this.viewMap, [viewId, "is_favorite"], false);
}); });
await this.viewService.removeViewFromFavorites(workspaceSlug, projectId, viewId); await this.rootStore.favorite.removeFavoriteEntity(workspaceSlug, viewId);
} catch (error) { } catch (error) {
console.error("Failed to remove view from favorites in view store", error); console.error("Failed to remove view from favorites in view store", error);
runInAction(() => { runInAction(() => {

View file

@ -279,7 +279,12 @@ export class ProjectStore implements IProjectStore {
runInAction(() => { runInAction(() => {
set(this.projectMap, [projectId, "is_favorite"], true); 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; return response;
} catch (error) { } catch (error) {
console.log("Failed to add project to favorite"); console.log("Failed to add project to favorite");
@ -300,10 +305,11 @@ export class ProjectStore implements IProjectStore {
try { try {
const currentProject = this.getProjectById(projectId); const currentProject = this.getProjectById(projectId);
if (!currentProject.is_favorite) return; if (!currentProject.is_favorite) return;
const response = await this.rootStore.favorite.removeFavoriteEntity(workspaceSlug.toString(), projectId);
runInAction(() => { runInAction(() => {
set(this.projectMap, [projectId, "is_favorite"], false); set(this.projectMap, [projectId, "is_favorite"], false);
}); });
const response = await this.projectService.removeProjectFromFavorites(workspaceSlug, projectId);
await this.fetchProjects(workspaceSlug); await this.fetchProjects(workspaceSlug);
return response; return response;
} catch (error) { } catch (error) {

View file

@ -6,6 +6,7 @@ import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store";
import { DashboardStore, IDashboardStore } from "./dashboard.store"; import { DashboardStore, IDashboardStore } from "./dashboard.store";
import { IProjectEstimateStore, ProjectEstimateStore } from "./estimates/project-estimate.store"; import { IProjectEstimateStore, ProjectEstimateStore } from "./estimates/project-estimate.store";
import { EventTrackerStore, IEventTrackerStore } from "./event-tracker.store"; import { EventTrackerStore, IEventTrackerStore } from "./event-tracker.store";
import { FavoriteStore, IFavoriteStore } from "./favorite.store";
import { GlobalViewStore, IGlobalViewStore } from "./global-view.store"; import { GlobalViewStore, IGlobalViewStore } from "./global-view.store";
import { IProjectInboxStore, ProjectInboxStore } from "./inbox/project-inbox.store"; import { IProjectInboxStore, ProjectInboxStore } from "./inbox/project-inbox.store";
import { InstanceStore, IInstanceStore } from "./instance.store"; import { InstanceStore, IInstanceStore } from "./instance.store";
@ -52,6 +53,7 @@ export class CoreRootStore {
projectEstimate: IProjectEstimateStore; projectEstimate: IProjectEstimateStore;
multipleSelect: IMultipleSelectStore; multipleSelect: IMultipleSelectStore;
workspaceNotification: IWorkspaceNotificationStore; workspaceNotification: IWorkspaceNotificationStore;
favorite: IFavoriteStore;
constructor() { constructor() {
this.router = new RouterStore(); this.router = new RouterStore();
@ -78,6 +80,7 @@ export class CoreRootStore {
this.projectPages = new ProjectPageStore(this); this.projectPages = new ProjectPageStore(this);
this.projectEstimate = new ProjectEstimateStore(this); this.projectEstimate = new ProjectEstimateStore(this);
this.workspaceNotification = new WorkspaceNotificationStore(this); this.workspaceNotification = new WorkspaceNotificationStore(this);
this.favorite = new FavoriteStore();
} }
resetOnSignOut() { resetOnSignOut() {