diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 0d87acde74d..83981ab53c6 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -121,3 +121,5 @@ from .webhook import WebhookSerializer, WebhookLogSerializer from .dashboard import DashboardSerializer, WidgetSerializer + +from .favorite import UserFavoriteSerializer diff --git a/apiserver/plane/app/serializers/favorite.py b/apiserver/plane/app/serializers/favorite.py new file mode 100644 index 00000000000..8e0beda1060 --- /dev/null +++ b/apiserver/plane/app/serializers/favorite.py @@ -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 diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index 8b21bb9e1b2..3f1e000e473 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -25,6 +25,8 @@ ExportWorkspaceUserActivityEndpoint, WorkspaceModulesEndpoint, WorkspaceCyclesEndpoint, + WorkspaceFavoriteEndpoint, + WorkspaceFavoriteGroupEndpoint, ) @@ -237,4 +239,19 @@ WorkspaceCyclesEndpoint.as_view(), name="workspace-cycles", ), + path( + "workspaces//user-favorites/", + WorkspaceFavoriteEndpoint.as_view(), + name="workspace-user-favorites", + ), + path( + "workspaces//user-favorites//", + WorkspaceFavoriteEndpoint.as_view(), + name="workspace-user-favorites", + ), + path( + "workspaces//user-favorites//group/", + WorkspaceFavoriteGroupEndpoint.as_view(), + name="workspace-user-favorites-groups", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index bb722de527b..0babdf5d849 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -40,6 +40,11 @@ ExportWorkspaceUserActivityEndpoint, ) +from .workspace.favorite import ( + WorkspaceFavoriteEndpoint, + WorkspaceFavoriteGroupEndpoint, +) + from .workspace.member import ( WorkSpaceMemberViewSet, TeamMemberViewSet, diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 1258aa6086d..db2a73d4c95 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -1154,7 +1154,7 @@ def destroy(self, request, slug, project_id, cycle_id): workspace__slug=slug, entity_identifier=cycle_id, ) - cycle_favorite.delete() + cycle_favorite.delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index 4d1203f077a..c6861fe4bf7 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -840,7 +840,7 @@ def destroy(self, request, slug, project_id, module_id): entity_type="module", entity_identifier=module_id, ) - module_favorite.delete() + module_favorite.delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index b0d56064972..9757b16acbc 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -401,7 +401,7 @@ def destroy(self, request, slug, project_id, pk): entity_identifier=pk, entity_type="page", ) - page_favorite.delete() + page_favorite.delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index bca8236a98b..22181dacff9 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -599,7 +599,7 @@ def destroy(self, request, slug, project_id): user=request.user, workspace__slug=slug, ) - project_favorite.delete() + project_favorite.delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index 236a051aaa9..9881088317d 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -474,5 +474,5 @@ def destroy(self, request, slug, project_id, view_id): entity_type="view", entity_identifier=view_id, ) - view_favorite.delete() + view_favorite.delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/workspace/favorite.py b/apiserver/plane/app/views/workspace/favorite.py new file mode 100644 index 00000000000..d4fe6a622fd --- /dev/null +++ b/apiserver/plane/app/views/workspace/favorite.py @@ -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) diff --git a/apiserver/plane/db/models/favorite.py b/apiserver/plane/db/models/favorite.py index 2ea1014bc82..ebfe67dfef0 100644 --- a/apiserver/plane/db/models/favorite.py +++ b/apiserver/plane/db/models/favorite.py @@ -39,9 +39,14 @@ class Meta: def save(self, *args, **kwargs): if self._state.adding: - largest_sequence = UserFavorite.objects.filter( - workspace=self.project.workspace - ).aggregate(largest=models.Max("sequence"))["largest"] + if self.project: + largest_sequence = UserFavorite.objects.filter( + 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: self.sequence = largest_sequence + 10000 diff --git a/packages/types/src/favorite/favorite.d.ts b/packages/types/src/favorite/favorite.d.ts new file mode 100644 index 00000000000..ae7bf2da887 --- /dev/null +++ b/packages/types/src/favorite/favorite.d.ts @@ -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; +}; diff --git a/packages/types/src/favorite/index.d.ts b/packages/types/src/favorite/index.d.ts new file mode 100644 index 00000000000..e11ce8f3c1f --- /dev/null +++ b/packages/types/src/favorite/index.d.ts @@ -0,0 +1 @@ +export * from "./favorite"; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 353aeaf08da..6dfddc6b638 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -28,3 +28,4 @@ export * from "./common"; export * from "./pragmatic"; export * from "./publish"; export * from "./workspace-notifications"; +export * from "./favorite"; diff --git a/packages/ui/src/icons/favorite-folder-icon.tsx b/packages/ui/src/icons/favorite-folder-icon.tsx new file mode 100644 index 00000000000..03ebba71b75 --- /dev/null +++ b/packages/ui/src/icons/favorite-folder-icon.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const FavoriteFolderIcon: React.FC = ({ className = "text-current", color = "#a3a3a3", ...rest }) => ( + + + + +); diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index b1861502950..a87d1914600 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -24,3 +24,4 @@ export * from "./info-icon"; export * from "./dropdown-icon"; export * from "./intake"; export * from "./user-activity-icon"; +export * from "./favorite-folder-icon"; diff --git a/web/app/[workspaceSlug]/(projects)/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/sidebar.tsx index b1a9e32fc7c..ea901333a27 100644 --- a/web/app/[workspaceSlug]/(projects)/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/sidebar.tsx @@ -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 = observer(() => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [windowSize]); - return (
= observer(() => { "opacity-0": !sidebarCollapsed, })} /> + +
diff --git a/web/core/components/workspace/sidebar/favorites/favorite-folder.tsx b/web/core/components/workspace/sidebar/favorites/favorite-folder.tsx new file mode 100644 index 00000000000..3096ae7099b --- /dev/null +++ b/web/core/components/workspace/sidebar/favorites/favorite-folder.tsx @@ -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) => { + 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(null); + // refs + const actionSectionRef = useRef(null); + const elementRef = useRef(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 ? ( + + ) : ( + <> + + {({ open }) => ( +
+ +
+ {isSidebarCollapsed ? ( +
+ +
+ +
+
+
+ ) : ( + <> + +
+ +
+ +
+

{favorite.name}

+
+
+
+ setIsMenuActive(!isMenuActive)} + > + + + } + 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" + > + handleRemoveFromFavorites(favorite)}> + + + Remove from favorites + + + setFolderToRename(favorite.id)}> +
+ + Rename Folder +
+
+
+ + + + + )} +
+ {favorite.children && favorite.children.length > 0 && ( + + + {favorite.children.map((child) => ( + + ))} + + + )} + {isLastChild && } +
+ )} +
+ + ); +}; diff --git a/web/core/components/workspace/sidebar/favorites/favorite-item.tsx b/web/core/components/workspace/sidebar/favorites/favorite-item.tsx new file mode 100644 index 00000000000..02467a5eab4 --- /dev/null +++ b/web/core/components/workspace/sidebar/favorites/favorite-item.tsx @@ -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(null); + const dragHandleRef = useRef(null); + const actionSectionRef = useRef(null); + + const getIcon = () => { + const className = `flex-shrink-0 size-4 stroke-[1.5]`; + + switch (favorite.entity_type) { + case "page": + return ; + case "project": + return ; + case "view": + return ; + case "module": + return ; + case "cycle": + return ; + case "issue": + return ; + case "folder": + return ; + default: + return ; + } + }; + + 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 ( +
+ +
+ + + + + {getIcon()} + + {!sidebarCollapsed && ( + + {favorite.entity_data ? favorite.entity_data.name : favorite.name} + + )} + + setIsMenuActive(!isMenuActive)} + > + + + } + 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" + > + handleRemoveFromFavorites(favorite)}> + + + Remove from favorites + + + +
+
+
+ ); + } +); diff --git a/web/core/components/workspace/sidebar/favorites/favorites-menu.tsx b/web/core/components/workspace/sidebar/favorites/favorites-menu.tsx new file mode 100644 index 00000000000..ced7de0f024 --- /dev/null +++ b/web/core/components/workspace/sidebar/favorites/favorites-menu.tsx @@ -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(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("is_favorite_menu_open", false); + // derived values + const isFavoriteMenuOpen = !!storedValue; + // refs + const containerRef = useRef(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 ( +
+ + {!sidebarCollapsed && ( + + toggleFavoriteMenu(!isFavoriteMenuOpen)} className="flex-1 text-start"> + MY FAVORITES + + + { + setCreateNewFolder(true); + !isFavoriteMenuOpen && toggleFavoriteMenu(!isFavoriteMenuOpen); + }} + className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform")} + /> + toggleFavoriteMenu(!isFavoriteMenuOpen)} + className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", { + "rotate-90": isFavoriteMenuOpen, + })} + /> + + + )} + + {isFavoriteMenuOpen && ( + + {createNewFolder && } + {favoriteIds + .filter((id) => !favoriteMap[id].parent) + .map((id, index) => ( + + {favoriteMap[id].is_folder ? ( + + ) : ( + + )} + + ))} + + )} + + +
+ ); +}); diff --git a/web/core/components/workspace/sidebar/favorites/new-fav-folder.tsx b/web/core/components/workspace/sidebar/favorites/new-fav-folder.tsx new file mode 100644 index 00000000000..e17a4c78777 --- /dev/null +++ b/web/core/components/workspace/sidebar/favorites/new-fav-folder.tsx @@ -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({ + reValidateMode: "onChange", + defaultValues: { + name: defaultName, + }, + }); + + const handleAddNewFolder: SubmitHandler = (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 = (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 ( +
+ +
+ } + /> + +
+ ); +}; diff --git a/web/core/components/workspace/sidebar/projects-list.tsx b/web/core/components/workspace/sidebar/projects-list.tsx index e1a067d8c09..5515f010583 100644 --- a/web/core/components/workspace/sidebar/projects-list.tsx +++ b/web/core/components/workspace/sidebar/projects-list.tsx @@ -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 && ( 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 ( + <> + <> - - <> -
+ toggleListDisclosure(!isAllProjectsListOpen)} + > + + <> + {isCollapsed ? ( + + ) : ( + YOUR PROJECTS + )} + + + + {!isCollapsed && ( +
+ {isAuthorizedUser && ( + + + )} - > toggleListDisclosure(!section.isOpen, section.key)} + className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0" + onClick={() => toggleListDisclosure(!isAllProjectsListOpen)} > - - <> - {isCollapsed ? ( - - ) : ( - {section.title} - )} - - + - {!isCollapsed && ( -
- {isAuthorizedUser && ( - - - - )} - toggleListDisclosure(!section.isOpen, section.key)} - > - - -
- )}
- + + {isAllProjectsListOpen && ( + - {section.isOpen && ( - - {section.projects.map((projectId, index) => ( - handleCopyText(projectId)} - projectListType={section.type} - disableDrag={section.key === "favorite"} - disableDrop={section.key === "favorite"} - isLastChild={index === section.projects.length - 1} - handleOnProjectDrop={handleOnProjectDrop} - /> - ))} - - )} - - - -
+ {joinedProjects.map((projectId, index) => ( + handleCopyText(projectId)} + projectListType={"JOINED"} + disableDrag={false} + disableDrop={false} + isLastChild={index === joinedProjects.length - 1} + handleOnProjectDrop={handleOnProjectDrop} + /> + ))} + + )} +
- ); - })} + + + {isAuthorizedUser && joinedProjects?.length === 0 && (