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