Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apiserver/plane/app/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,5 @@
from .webhook import WebhookSerializer, WebhookLogSerializer

from .dashboard import DashboardSerializer, WidgetSerializer

from .favorite import UserFavoriteSerializer
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address the unused import.

The UserFavoriteSerializer is imported but not used in this file. If it is meant to be re-exported, consider adding it to __all__.

+ __all__ = [
+     ...  # other serializers
+     'UserFavoriteSerializer',
+ ]

Committable suggestion was skipped due to low confidence.

Tools
Ruff

125-125: .favorite.UserFavoriteSerializer imported but unused; consider removing, adding to __all__, or using a redundant alias

(F401)

GitHub Check: Codacy Static Code Analysis

[notice] 125-125: apiserver/plane/app/serializers/init.py#L125
'.favorite.UserFavoriteSerializer' imported but unused (F401)

101 changes: 101 additions & 0 deletions apiserver/plane/app/serializers/favorite.py
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions apiserver/plane/app/urls/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
ExportWorkspaceUserActivityEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
)


Expand Down Expand Up @@ -237,4 +239,19 @@
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",
),
]
5 changes: 5 additions & 0 deletions apiserver/plane/app/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
ExportWorkspaceUserActivityEndpoint,
)

from .workspace.favorite import (
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
Comment on lines +44 to +45
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove unused imports for WorkspaceFavoriteEndpoint and WorkspaceFavoriteGroupEndpoint.

The static analysis tool indicates that these imports are unused. Consider removing them to clean up the code.

-  WorkspaceFavoriteEndpoint,
-  WorkspaceFavoriteGroupEndpoint,
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
Tools
Ruff

44-44: .workspace.favorite.WorkspaceFavoriteEndpoint imported but unused; consider removing, adding to __all__, or using a redundant alias

(F401)


45-45: .workspace.favorite.WorkspaceFavoriteGroupEndpoint imported but unused; consider removing, adding to __all__, or using a redundant alias

(F401)

)

from .workspace.member import (
WorkSpaceMemberViewSet,
TeamMemberViewSet,
Expand Down
2 changes: 1 addition & 1 deletion apiserver/plane/app/views/cycle/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
2 changes: 1 addition & 1 deletion apiserver/plane/app/views/module/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
2 changes: 1 addition & 1 deletion apiserver/plane/app/views/page/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
2 changes: 1 addition & 1 deletion apiserver/plane/app/views/project/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
2 changes: 1 addition & 1 deletion apiserver/plane/app/views/view/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
88 changes: 88 additions & 0 deletions apiserver/plane/app/views/workspace/favorite.py
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +20 to +35
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplify filter conditions in the get method.

The nested filter conditions can be simplified for better readability and performance.

favorites = UserFavorite.objects.filter(
    user=request.user,
    workspace__slug=slug,
    parent__isnull=True,
).filter(
    Q(project__isnull=True) |
    Q(
        project__isnull=False,
        project__project_projectmember__member=request.user,
        project__project_projectmember__is_active=True
    )
)
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 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,
project__project_projectmember__member=request.user,
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)
Comment on lines +37 to +47
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add error handling for Workspace.objects.get.

The Workspace.objects.get call can raise a DoesNotExist exception. Add error handling to manage this scenario.

-  workspace = Workspace.objects.get(slug=slug)
+  try:
+      workspace = Workspace.objects.get(slug=slug)
+  except Workspace.DoesNotExist:
+      return Response({"detail": "Workspace not found."}, status=status.HTTP_404_NOT_FOUND)
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 post(self, request, slug):
try:
workspace = Workspace.objects.get(slug=slug)
except Workspace.DoesNotExist:
return Response({"detail": "Workspace not found."}, status=status.HTTP_404_NOT_FOUND)
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)
Comment on lines +49 to +59
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add error handling for UserFavorite.objects.get.

The UserFavorite.objects.get call can raise a DoesNotExist exception. Add error handling to manage this scenario.

-  favorite = UserFavorite.objects.get(
-      user=request.user, workspace__slug=slug, pk=favorite_id
-  )
+  try:
+      favorite = UserFavorite.objects.get(
+          user=request.user, workspace__slug=slug, pk=favorite_id
+      )
+  except UserFavorite.DoesNotExist:
+      return Response({"detail": "Favorite not found."}, status=status.HTTP_404_NOT_FOUND)
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 patch(self, request, slug, favorite_id):
try:
favorite = UserFavorite.objects.get(
user=request.user, workspace__slug=slug, pk=favorite_id
)
except UserFavorite.DoesNotExist:
return Response({"detail": "Favorite not found."}, status=status.HTTP_404_NOT_FOUND)
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)
Comment on lines +74 to +88
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplify filter conditions in the get method.

The nested filter conditions can be simplified for better readability and performance.

favorites = UserFavorite.objects.filter(
    user=request.user,
    workspace__slug=slug,
    parent_id=favorite_id,
).filter(
    Q(project__isnull=True) |
    Q(
        project__isnull=False,
        project__project_projectmember__member=request.user,
        project__project_projectmember__is_active=True
    )
)
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
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,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
)
)
serializer = UserFavoriteSerializer(favorites, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)

11 changes: 8 additions & 3 deletions apiserver/plane/db/models/favorite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions packages/types/src/favorite/favorite.d.ts
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions packages/types/src/favorite/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./favorite";
1 change: 1 addition & 0 deletions packages/types/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ export * from "./common";
export * from "./pragmatic";
export * from "./publish";
export * from "./workspace-notifications";
export * from "./favorite";
32 changes: 32 additions & 0 deletions packages/ui/src/icons/favorite-folder-icon.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
1 change: 1 addition & 0 deletions packages/ui/src/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
8 changes: 7 additions & 1 deletion web/app/[workspaceSlug]/(projects)/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -41,7 +42,6 @@ export const AppSidebar: FC<IAppSidebar> = observer(() => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [windowSize]);


return (
<div
className={cn(
Expand Down Expand Up @@ -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>
Expand Down
Loading