diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 57539f24c..2b64e22ef 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -75,13 +75,13 @@ class IssueCreateSerializer(BaseSerializer): project_detail = ProjectLiteSerializer(read_only=True, source="project") workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - assignees_list = serializers.ListField( + assignees = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), write_only=True, required=False, ) - labels_list = serializers.ListField( + labels = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), write_only=True, required=False, @@ -99,6 +99,12 @@ class IssueCreateSerializer(BaseSerializer): "updated_at", ] + def to_representation(self, instance): + data = super().to_representation(instance) + data['assignees'] = [str(assignee.id) for assignee in instance.assignees.all()] + data['labels'] = [str(label.id) for label in instance.labels.all()] + return data + def validate(self, data): if ( data.get("start_date", None) is not None @@ -109,8 +115,8 @@ class IssueCreateSerializer(BaseSerializer): return data def create(self, validated_data): - assignees = validated_data.pop("assignees_list", None) - labels = validated_data.pop("labels_list", None) + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) project_id = self.context["project_id"] workspace_id = self.context["workspace_id"] @@ -168,8 +174,8 @@ class IssueCreateSerializer(BaseSerializer): return issue def update(self, instance, validated_data): - assignees = validated_data.pop("assignees_list", None) - labels = validated_data.pop("labels_list", None) + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) # Related models project_id = instance.project_id diff --git a/apiserver/plane/api/urls/issue.py b/apiserver/plane/api/urls/issue.py index b484fe113..f1ef7c176 100644 --- a/apiserver/plane/api/urls/issue.py +++ b/apiserver/plane/api/urls/issue.py @@ -17,7 +17,7 @@ from plane.api.views import ( IssueSubscriberViewSet, IssueReactionViewSet, CommentReactionViewSet, - IssuePropertyViewSet, + IssueUserDisplayPropertyEndpoint, IssueArchiveViewSet, IssueRelationViewSet, IssueDraftViewSet, @@ -235,28 +235,11 @@ urlpatterns = [ ## End Comment Reactions ## IssueProperty path( - "workspaces//projects//issue-properties/", - IssuePropertyViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-roadmap", + "workspaces//projects//issue-display-properties/", + IssueUserDisplayPropertyEndpoint.as_view(), + name="project-issue-display-properties", ), - path( - "workspaces//projects//issue-properties//", - IssuePropertyViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-roadmap", - ), - ## IssueProperty Ebd + ## IssueProperty End ## Issue Archives path( "workspaces//projects//archived-issues/", diff --git a/apiserver/plane/api/urls_deprecated.py b/apiserver/plane/api/urls_deprecated.py index 0dc1b3a08..c108257b3 100644 --- a/apiserver/plane/api/urls_deprecated.py +++ b/apiserver/plane/api/urls_deprecated.py @@ -82,7 +82,7 @@ from plane.api.views import ( BulkDeleteIssuesEndpoint, BulkImportIssuesEndpoint, ProjectUserViewsEndpoint, - IssuePropertyViewSet, + IssueUserDisplayPropertyEndpoint, LabelViewSet, SubIssuesEndpoint, IssueLinkViewSet, @@ -1008,26 +1008,9 @@ urlpatterns = [ ## End Comment Reactions ## IssueProperty path( - "workspaces//projects//issue-properties/", - IssuePropertyViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-roadmap", - ), - path( - "workspaces//projects//issue-properties//", - IssuePropertyViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-roadmap", + "workspaces//projects//issue-display-properties/", + IssueUserDisplayPropertyEndpoint.as_view(), + name="project-issue-display-properties", ), ## IssueProperty Ebd ## Issue Archives diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 8a974f868..e17550050 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -71,7 +71,7 @@ from .issue import ( WorkSpaceIssuesEndpoint, IssueActivityEndpoint, IssueCommentViewSet, - IssuePropertyViewSet, + IssueUserDisplayPropertyEndpoint, LabelViewSet, BulkDeleteIssuesEndpoint, UserWorkSpaceIssues, diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index 538c8e484..7ab660e81 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -84,6 +84,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): capture_exception(e) return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) + print(e) if settings.DEBUG else print("Server Error") capture_exception(e) return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -161,6 +162,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): if isinstance(e, KeyError): return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) + print(e) if settings.DEBUG else print("Server Error") capture_exception(e) return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 7b14af4a2..b18c42d86 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -588,14 +588,14 @@ class CycleIssueViewSet(BaseViewSet): ) if group_by: + grouped_results = group_results(issues_data, group_by, sub_group_by) return Response( - group_results(issues_data, group_by, sub_group_by), + grouped_results, status=status.HTTP_200_OK, ) return Response( - issues_data, - status=status.HTTP_200_OK, + issues_data, status=status.HTTP_200_OK ) def create(self, request, slug, project_id, cycle_id): diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index be95c304e..99f2de2c2 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -130,7 +130,7 @@ class IssueViewSet(BaseViewSet): queryset=IssueReaction.objects.select_related("actor"), ) ) - ) + ).distinct() @method_decorator(gzip_page) def list(self, request, slug, project_id): @@ -229,12 +229,16 @@ class IssueViewSet(BaseViewSet): ) if group_by: + grouped_results = group_results(issues, group_by, sub_group_by) return Response( - group_results(issues, group_by, sub_group_by), + grouped_results, status=status.HTTP_200_OK, ) - return Response(issues, status=status.HTTP_200_OK) + return Response( + issues, status=status.HTTP_200_OK + ) + def create(self, request, slug, project_id): project = Project.objects.get(pk=project_id) @@ -433,12 +437,15 @@ class UserWorkSpaceIssues(BaseAPIView): ) if group_by: + grouped_results = group_results(issues, group_by, sub_group_by) return Response( - group_results(issues, group_by, sub_group_by), + grouped_results, status=status.HTTP_200_OK, ) - return Response(issues, status=status.HTTP_200_OK) + return Response( + issues, status=status.HTTP_200_OK + ) class WorkSpaceIssuesEndpoint(BaseAPIView): @@ -597,41 +604,12 @@ class IssueCommentViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class IssuePropertyViewSet(BaseViewSet): - serializer_class = IssuePropertySerializer - model = IssueProperty +class IssueUserDisplayPropertyEndpoint(BaseAPIView): permission_classes = [ - ProjectEntityPermission, + ProjectLitePermission, ] - filterset_fields = [] - - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), user=self.request.user - ) - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(user=self.request.user) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - ) - - def list(self, request, slug, project_id): - queryset = self.get_queryset() - serializer = IssuePropertySerializer(queryset, many=True) - return Response( - serializer.data[0] if len(serializer.data) > 0 else [], - status=status.HTTP_200_OK, - ) - - def create(self, request, slug, project_id): + def post(self, request, slug, project_id): issue_property, created = IssueProperty.objects.get_or_create( user=request.user, project_id=project_id, @@ -640,16 +618,20 @@ class IssuePropertyViewSet(BaseViewSet): if not created: issue_property.properties = request.data.get("properties", {}) issue_property.save() - - serializer = IssuePropertySerializer(issue_property) - return Response(serializer.data, status=status.HTTP_200_OK) - issue_property.properties = request.data.get("properties", {}) issue_property.save() serializer = IssuePropertySerializer(issue_property) return Response(serializer.data, status=status.HTTP_201_CREATED) + def get(self, request, slug, project_id): + issue_property, _ = IssueProperty.objects.get_or_create( + user=request.user, project_id=project_id + ) + serializer = IssuePropertySerializer(issue_property) + return Response(serializer.data, status=status.HTTP_200_OK) + + class LabelViewSet(BaseViewSet): serializer_class = LabelSerializer model = Label @@ -963,8 +945,8 @@ class IssueAttachmentEndpoint(BaseAPIView): issue_attachments = IssueAttachment.objects.filter( issue_id=issue_id, workspace__slug=slug, project_id=project_id ) - serilaizer = IssueAttachmentSerializer(issue_attachments, many=True) - return Response(serilaizer.data, status=status.HTTP_200_OK) + serializer = IssueAttachmentSerializer(issue_attachments, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) class IssueArchiveViewSet(BaseViewSet): @@ -1165,9 +1147,7 @@ class IssueSubscriberViewSet(BaseViewSet): def list(self, request, slug, project_id, issue_id): members = ( - ProjectMember.objects.filter( - workspace__slug=slug, project_id=project_id - ) + ProjectMember.objects.filter(workspace__slug=slug, project_id=project_id) .annotate( is_subscribed=Exists( IssueSubscriber.objects.filter( @@ -2174,9 +2154,15 @@ class IssueDraftViewSet(BaseViewSet): ## Grouping the results group_by = request.GET.get("group_by", False) if group_by: - return Response(group_results(issues, group_by), status=status.HTTP_200_OK) + grouped_results = group_results(issues, group_by) + return Response( + grouped_results, + status=status.HTTP_200_OK, + ) - return Response(issues, status=status.HTTP_200_OK) + return Response( + issues, status=status.HTTP_200_OK + ) def create(self, request, slug, project_id): project = Project.objects.get(pk=project_id) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index ba088ea9c..48f892764 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -149,6 +149,9 @@ class ModuleViewSet(BaseViewSet): if serializer.is_valid(): serializer.save() + + module = Module.objects.get(pk=serializer.data["id"]) + serializer = ModuleSerializer(module) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -361,7 +364,6 @@ class ModuleIssueViewSet(BaseViewSet): .values("count") ) ) - issues_data = IssueStateSerializer(issues, many=True).data if sub_group_by and sub_group_by == group_by: @@ -371,14 +373,14 @@ class ModuleIssueViewSet(BaseViewSet): ) if group_by: + grouped_results = group_results(issues_data, group_by, sub_group_by) return Response( - group_results(issues_data, group_by, sub_group_by), + grouped_results, status=status.HTTP_200_OK, ) return Response( - issues_data, - status=status.HTTP_200_OK, + issues_data, status=status.HTTP_200_OK ) def create(self, request, slug, project_id, module_id): diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 1058ac593..632a5bf53 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -69,6 +69,7 @@ from plane.db.models import ( ModuleMember, Inbox, ProjectDeployBoard, + IssueProperty, ) from plane.bgtasks.project_invitation_task import project_invitation @@ -201,6 +202,11 @@ class ProjectViewSet(BaseViewSet): project_member = ProjectMember.objects.create( project_id=serializer.data["id"], member=request.user, role=20 ) + # Also create the issue property for the user + _ = IssueProperty.objects.create( + project_id=serializer.data["id"], + user=request.user, + ) if serializer.data["project_lead"] is not None and str( serializer.data["project_lead"] @@ -210,6 +216,11 @@ class ProjectViewSet(BaseViewSet): member_id=serializer.data["project_lead"], role=20, ) + # Also create the issue property for the user + IssueProperty.objects.create( + project_id=serializer.data["id"], + user_id=serializer.data["project_lead"], + ) # Default states states = [ @@ -262,12 +273,9 @@ class ProjectViewSet(BaseViewSet): ] ) - data = serializer.data - # Additional fields of the member - data["sort_order"] = project_member.sort_order - data["member_role"] = project_member.role - data["is_member"] = True - return Response(data, status=status.HTTP_201_CREATED) + project = self.get_queryset().filter(pk=serializer.data["id"]).first() + serializer = ProjectListSerializer(project) + return Response(serializer.data, status=status.HTTP_201_CREATED) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST, @@ -317,6 +325,8 @@ class ProjectViewSet(BaseViewSet): color="#ff7700", ) + project = self.get_queryset().filter(pk=serializer.data["id"]).first() + serializer = ProjectListSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -393,6 +403,8 @@ class InviteProjectEndpoint(BaseAPIView): member=user, project_id=project_id, role=role ) + _ = IssueProperty.objects.create(user=user, project_id=project_id) + return Response( ProjectMemberSerializer(project_member).data, status=status.HTTP_200_OK ) @@ -428,6 +440,18 @@ class UserProjectInvitationsViewset(BaseViewSet): ] ) + IssueProperty.objects.bulk_create( + [ + ProjectMember( + project=invitation.project, + workspace=invitation.project.workspace, + user=request.user, + created_by=request.user, + ) + for invitation in project_invitations + ] + ) + # Delete joined project invites project_invitations.delete() @@ -560,6 +584,7 @@ class AddMemberToProjectEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) bulk_project_members = [] + bulk_issue_props = [] project_members = ( ProjectMember.objects.filter( @@ -574,7 +599,8 @@ class AddMemberToProjectEndpoint(BaseAPIView): sort_order = [ project_member.get("sort_order") for project_member in project_members - if str(project_member.get("member_id")) == str(member.get("member_id")) + if str(project_member.get("member_id")) + == str(member.get("member_id")) ] bulk_project_members.append( ProjectMember( @@ -585,6 +611,13 @@ class AddMemberToProjectEndpoint(BaseAPIView): sort_order=sort_order[0] - 10000 if len(sort_order) else 65535, ) ) + bulk_issue_props.append( + IssueProperty( + user_id=member.get("member_id"), + project_id=project_id, + workspace_id=project.workspace_id, + ) + ) project_members = ProjectMember.objects.bulk_create( bulk_project_members, @@ -592,7 +625,12 @@ class AddMemberToProjectEndpoint(BaseAPIView): ignore_conflicts=True, ) + _ = IssueProperty.objects.bulk_create( + bulk_issue_props, batch_size=10, ignore_conflicts=True + ) + serializer = ProjectMemberSerializer(project_members, many=True) + return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -614,6 +652,7 @@ class AddTeamToProjectEndpoint(BaseAPIView): workspace = Workspace.objects.get(slug=slug) project_members = [] + issue_props = [] for member in team_members: project_members.append( ProjectMember( @@ -623,11 +662,23 @@ class AddTeamToProjectEndpoint(BaseAPIView): created_by=request.user, ) ) + issue_props.append( + IssueProperty( + project_id=project_id, + user_id=member, + workspace=workspace, + created_by=request.user, + ) + ) ProjectMember.objects.bulk_create( project_members, batch_size=10, ignore_conflicts=True ) + _ = IssueProperty.objects.bulk_create( + issue_props, batch_size=10, ignore_conflicts=True + ) + serializer = ProjectMemberSerializer(project_members, many=True) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -743,6 +794,19 @@ class ProjectJoinEndpoint(BaseAPIView): ignore_conflicts=True, ) + IssueProperty.objects.bulk_create( + [ + IssueProperty( + project_id=project_id, + user=request.user, + workspace=workspace, + created_by=request.user, + ) + for project_id in project_ids + ], + ignore_conflicts=True, + ) + return Response( {"message": "Projects joined successfully"}, status=status.HTTP_201_CREATED, diff --git a/apiserver/plane/api/views/view.py b/apiserver/plane/api/views/view.py index 0e4b074c6..f58f320b7 100644 --- a/apiserver/plane/api/views/view.py +++ b/apiserver/plane/api/views/view.py @@ -93,7 +93,6 @@ class GlobalViewIssuesViewSet(BaseViewSet): ) ) - @method_decorator(gzip_page) def list(self, request, slug): filters = issue_filters(request.query_params, "GET") @@ -117,9 +116,7 @@ class GlobalViewIssuesViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") - ) + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -129,9 +126,7 @@ class GlobalViewIssuesViewSet(BaseViewSet): # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] + priority_order if order_by_param == "priority" else priority_order[::-1] ) issue_queryset = issue_queryset.annotate( priority_order=Case( @@ -183,7 +178,6 @@ class GlobalViewIssuesViewSet(BaseViewSet): ) else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueLiteSerializer(issue_queryset, many=True).data ## Grouping the results @@ -194,10 +188,12 @@ class GlobalViewIssuesViewSet(BaseViewSet): {"error": "Group by and sub group by cannot be same"}, status=status.HTTP_400_BAD_REQUEST, ) - + if group_by: + grouped_results = group_results(issues, group_by, sub_group_by) return Response( - group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK + grouped_results, + status=status.HTTP_200_OK, ) return Response(issues, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 9aa0ebcd9..165a96179 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -1228,9 +1228,15 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): ## Grouping the results group_by = request.GET.get("group_by", False) if group_by: - return Response(group_results(issues, group_by), status=status.HTTP_200_OK) + grouped_results = group_results(issues, group_by) + return Response( + grouped_results, + status=status.HTTP_200_OK, + ) - return Response(issues, status=status.HTTP_200_OK) + return Response( + issues, status=status.HTTP_200_OK + ) class WorkspaceLabelsEndpoint(BaseAPIView): diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index 757ef601b..20dc65e51 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -25,6 +25,7 @@ from plane.db.models import ( WorkspaceIntegration, Label, User, + IssueProperty, ) from .workspace_invitation_task import workspace_invitation from plane.bgtasks.user_welcome_task import send_welcome_slack @@ -103,6 +104,20 @@ def service_importer(service, importer_id): ignore_conflicts=True, ) + IssueProperty.objects.bulk_create( + [ + IssueProperty( + project_id=importer.project_id, + workspace_id=importer.workspace_id, + user=user, + created_by=importer.created_by, + ) + for user in workspace_users + ], + batch_size=100, + ignore_conflicts=True, + ) + # Check if sync config is on for github importers if service == "github" and importer.config.get("sync", False): name = importer.metadata.get("name", False) diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 1ad54e3a8..1dbaf8bc9 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -29,117 +29,32 @@ from plane.db.models import ( IssueComment, ) from plane.api.serializers import IssueActivitySerializer +from plane.bgtasks.notification_task import notifications -# Track Chnages in name +# Track Changes in name def track_name( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): if current_instance.get("name") != requested_data.get("name"): issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="updated", old_value=current_instance.get("name"), new_value=requested_data.get("name"), field="name", - project=project, - workspace=project.workspace, - comment=f"updated the name to {requested_data.get('name')}", - epoch=epoch, - ) - ) - - -# Track changes in parent issue -def track_parent( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch -): - if current_instance.get("parent") != requested_data.get("parent"): - if requested_data.get("parent") == None: - old_parent = Issue.objects.get(pk=current_instance.get("parent")) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}", - new_value=None, - field="parent", - project=project, - workspace=project.workspace, - comment=f"updated the parent issue to None", - old_identifier=old_parent.id, - new_identifier=None, - epoch=epoch, - ) - ) - else: - new_parent = Issue.objects.get(pk=requested_data.get("parent")) - old_parent = Issue.objects.filter(pk=current_instance.get("parent")).first() - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}" - if old_parent is not None - else None, - new_value=f"{new_parent.project.identifier}-{new_parent.sequence_id}", - field="parent", - project=project, - workspace=project.workspace, - comment=f"updated the parent issue to {new_parent.name}", - old_identifier=old_parent.id if old_parent is not None else None, - new_identifier=new_parent.id, - epoch=epoch, - ) - ) - - -# Track changes in priority -def track_priority( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch -): - if current_instance.get("priority") != requested_data.get("priority"): - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("priority"), - new_value=requested_data.get("priority"), - field="priority", - project=project, - workspace=project.workspace, - comment=f"updated the priority to {requested_data.get('priority')}", - epoch=epoch, - ) - ) - - -# Track chnages in state of the issue -def track_state( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch -): - if current_instance.get("state") != requested_data.get("state"): - new_state = State.objects.get(pk=requested_data.get("state", None)) - old_state = State.objects.get(pk=current_instance.get("state", None)) - - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=old_state.name, - new_value=new_state.name, - field="state", - project=project, - workspace=project.workspace, - comment=f"updated the state to {new_state.name}", - old_identifier=old_state.id, - new_identifier=new_state.id, + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated the name to", epoch=epoch, ) ) @@ -147,7 +62,14 @@ def track_state( # Track issue description def track_description( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): if current_instance.get("description_html") != requested_data.get( "description_html" @@ -160,7 +82,7 @@ def track_description( if ( last_activity is not None and last_activity.field == "description" - and actor.id == last_activity.actor_id + and actor_id == last_activity.actor_id ): last_activity.created_at = timezone.now() last_activity.save(update_fields=["created_at"]) @@ -168,257 +90,343 @@ def track_description( issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="updated", old_value=current_instance.get("description_html"), new_value=requested_data.get("description_html"), field="description", - project=project, - workspace=project.workspace, - comment=f"updated the description to {requested_data.get('description_html')}", + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated the description to", epoch=epoch, ) ) +# Track changes in parent issue +def track_parent( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + if current_instance.get("parent") != requested_data.get("parent"): + old_parent = Issue.objects.filter(pk=current_instance.get("parent")).first() + new_parent = Issue.objects.filter(pk=requested_data.get("parent")).first() + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}" + if old_parent is not None + else "", + new_value=f"{new_parent.project.identifier}-{new_parent.sequence_id}" + if new_parent is not None + else "", + field="parent", + project_id=project_id, + workspace=workspace_id, + comment=f"updated the parent issue to", + old_identifier=old_parent.id if old_parent is not None else None, + new_identifier=new_parent.id if new_parent is not None else None, + epoch=epoch, + ) + ) + + +# Track changes in priority +def track_priority( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + if current_instance.get("priority") != requested_data.get("priority"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=current_instance.get("priority"), + new_value=requested_data.get("priority"), + field="priority", + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated the priority to", + epoch=epoch, + ) + ) + + +# Track changes in state of the issue +def track_state( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + if current_instance.get("state") != requested_data.get("state"): + new_state = State.objects.get(pk=requested_data.get("state", None)) + old_state = State.objects.get(pk=current_instance.get("state", None)) + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=old_state.name, + new_value=new_state.name, + field="state", + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated the state to", + old_identifier=old_state.id, + new_identifier=new_state.id, + epoch=epoch, + ) + ) + + # Track changes in issue target date def track_target_date( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): if current_instance.get("target_date") != requested_data.get("target_date"): - if requested_data.get("target_date") == None: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("target_date"), - new_value=requested_data.get("target_date"), - field="target_date", - project=project, - workspace=project.workspace, - comment=f"updated the target date to None", - epoch=epoch, - ) - ) - else: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("target_date"), - new_value=requested_data.get("target_date"), - field="target_date", - project=project, - workspace=project.workspace, - comment=f"updated the target date to {requested_data.get('target_date')}", - epoch=epoch, - ) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=current_instance.get("target_date") + if current_instance.get("target_date") is not None + else "", + new_value=requested_data.get("target_date") + if requested_data.get("target_date") is not None + else "", + field="target_date", + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated the target date to", + epoch=epoch, ) + ) # Track changes in issue start date def track_start_date( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): if current_instance.get("start_date") != requested_data.get("start_date"): - if requested_data.get("start_date") == None: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("start_date"), - new_value=requested_data.get("start_date"), - field="start_date", - project=project, - workspace=project.workspace, - comment=f"updated the start date to None", - epoch=epoch, - ) - ) - else: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("start_date"), - new_value=requested_data.get("start_date"), - field="start_date", - project=project, - workspace=project.workspace, - comment=f"updated the start date to {requested_data.get('start_date')}", - epoch=epoch, - ) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=current_instance.get("start_date") + if current_instance.get("start_date") is not None + else "", + new_value=requested_data.get("start_date") + if requested_data.get("start_date") is not None + else "", + field="start_date", + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated the start date to ", + epoch=epoch, ) + ) # Track changes in issue labels def track_labels( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): - # Label Addition - if len(requested_data.get("labels_list")) > len(current_instance.get("labels")): - for label in requested_data.get("labels_list"): - if label not in current_instance.get("labels"): - label = Label.objects.get(pk=label) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value="", - new_value=label.name, - field="labels", - project=project, - workspace=project.workspace, - comment=f"added label {label.name}", - new_identifier=label.id, - old_identifier=None, - epoch=epoch, - ) - ) + requested_labels = set([str(lab) for lab in requested_data.get("labels_list", [])]) + current_labels = set([str(lab) for lab in current_instance.get("labels", [])]) - # Label Removal - if len(requested_data.get("labels_list")) < len(current_instance.get("labels")): - for label in current_instance.get("labels"): - if label not in requested_data.get("labels_list"): - label = Label.objects.get(pk=label) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=label.name, - new_value="", - field="labels", - project=project, - workspace=project.workspace, - comment=f"removed label {label.name}", - old_identifier=label.id, - new_identifier=None, - epoch=epoch, - ) - ) + added_labels = requested_labels - current_labels + dropped_labels = current_labels - requested_labels + + # Set of newly added labels + for added_label in added_labels: + label = Label.objects.get(pk=added_label) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + project_id=project_id, + workspace_id=workspace_id, + verb="updated", + field="labels", + comment="added label ", + old_value="", + new_value=label.name, + new_identifier=label.id, + old_identifier=None, + epoch=epoch, + ) + ) + + # Set of dropped labels + for dropped_label in dropped_labels: + label = Label.objects.get(pk=dropped_label) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=label.name, + new_value="", + field="labels", + project_id=project_id, + workspace_id=workspace_id, + comment=f"removed label ", + old_identifier=label.id, + new_identifier=None, + epoch=epoch, + ) + ) # Track changes in issue assignees def track_assignees( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): - # Assignee Addition - if len(requested_data.get("assignees_list")) > len( - current_instance.get("assignees") - ): - for assignee in requested_data.get("assignees_list"): - if assignee not in current_instance.get("assignees"): - assignee = User.objects.get(pk=assignee) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value="", - new_value=assignee.display_name, - field="assignees", - project=project, - workspace=project.workspace, - comment=f"added assignee {assignee.display_name}", - new_identifier=assignee.id, - epoch=epoch, - ) - ) + requested_assignees = set( + [str(asg) for asg in requested_data.get("assignees_list", [])] + ) + current_assignees = set([str(asg) for asg in current_instance.get("assignees", [])]) - # Assignee Removal - if len(requested_data.get("assignees_list")) < len( - current_instance.get("assignees") - ): - for assignee in current_instance.get("assignees"): - if assignee not in requested_data.get("assignees_list"): - assignee = User.objects.get(pk=assignee) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=assignee.display_name, - new_value="", - field="assignees", - project=project, - workspace=project.workspace, - comment=f"removed assignee {assignee.display_name}", - old_identifier=assignee.id, - epoch=epoch, - ) - ) + added_assignees = requested_assignees - current_assignees + dropped_assginees = current_assignees - requested_assignees + for added_asignee in added_assignees: + assignee = User.objects.get(pk=added_asignee) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value="", + new_value=assignee.display_name, + field="assignees", + project_id=project_id, + workspace_id=workspace_id, + comment=f"added assignee ", + new_identifier=assignee.id, + epoch=epoch, + ) + ) -def create_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch -): - issue_activities.append( - IssueActivity( + for dropped_assignee in dropped_assginees: + assignee = User.objects.get(pk=dropped_assignee) + issue_activities.append( issue_id=issue_id, - project=project, - workspace=project.workspace, - comment=f"created the issue", - verb="created", - actor=actor, + actor_id=actor_id, + verb="updated", + old_value=assignee.display_name, + new_value="", + field="assignees", + project_id=project_id, + workspace_id=workspace_id, + comment=f"removed assignee ", + old_identifier=assignee.id, epoch=epoch, ) - ) def track_estimate_points( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): if current_instance.get("estimate_point") != requested_data.get("estimate_point"): - if requested_data.get("estimate_point") == None: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("estimate_point"), - new_value=requested_data.get("estimate_point"), - field="estimate_point", - project=project, - workspace=project.workspace, - comment=f"updated the estimate point to None", - epoch=epoch, - ) - ) - else: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("estimate_point"), - new_value=requested_data.get("estimate_point"), - field="estimate_point", - project=project, - workspace=project.workspace, - comment=f"updated the estimate point to {requested_data.get('estimate_point')}", - epoch=epoch, - ) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=current_instance.get("estimate_point") + if current_instance.get("estimate_point") is not None + else "", + new_value=requested_data.get("estimate_point") + if requested_data.get("estimate_point") is not None + else "", + field="estimate_point", + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated the estimate point to ", + epoch=epoch, ) + ) def track_archive_at( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): if requested_data.get("archived_at") is None: issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"has restored the issue", verb="updated", - actor=actor, + actor_id=actor_id, field="archived_at", old_value="archive", new_value="restore", @@ -429,11 +437,11 @@ def track_archive_at( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"Plane has archived the issue", verb="updated", - actor=actor, + actor_id=actor_id, field="archived_at", old_value=None, new_value="archive", @@ -443,24 +451,30 @@ def track_archive_at( def track_closed_to( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): if requested_data.get("closed_to") is not None: updated_state = State.objects.get( - pk=requested_data.get("closed_to"), project=project + pk=requested_data.get("closed_to"), project_id=project_id ) - issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="updated", old_value=None, new_value=updated_state.name, field="state", - project=project, - workspace=project.workspace, - comment=f"Plane updated the state to {updated_state.name}", + project_id=project_id, + workspace_id=workspace_id, + comment=f"Plane updated the state to ", old_identifier=None, new_identifier=updated_state.id, epoch=epoch, @@ -468,8 +482,38 @@ def track_closed_to( ) +def create_issue_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment=f"created the issue", + verb="created", + actor_id=actor_id, + epoch=epoch, + ) + ) + + def update_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): ISSUE_ACTIVITY_MAPPER = { "name": track_name, @@ -495,26 +539,34 @@ def update_issue_activity( func = ISSUE_ACTIVITY_MAPPER.get(key, None) if func is not None: func( - requested_data, - current_instance, - issue_id, - project, - actor, - issue_activities, - epoch, + requested_data=requested_data, + current_instance=current_instance, + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + actor_id=actor_id, + issue_activities=issue_activities, + epoch=epoch, ) def delete_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): issue_activities.append( IssueActivity( - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"deleted the issue", verb="deleted", - actor=actor, + actor_id=actor_id, field="issue", epoch=epoch, ) @@ -522,7 +574,14 @@ def delete_issue_activity( def create_comment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -532,11 +591,11 @@ def create_comment_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"created a comment", verb="created", - actor=actor, + actor_id=actor_id, field="comment", new_value=requested_data.get("comment_html", ""), new_identifier=requested_data.get("id", None), @@ -547,7 +606,14 @@ def create_comment_activity( def update_comment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -558,11 +624,11 @@ def update_comment_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"updated a comment", verb="updated", - actor=actor, + actor_id=actor_id, field="comment", old_value=current_instance.get("comment_html", ""), old_identifier=current_instance.get("id"), @@ -575,16 +641,23 @@ def update_comment_activity( def delete_comment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"deleted the comment", verb="deleted", - actor=actor, + actor_id=actor_id, field="comment", epoch=epoch, ) @@ -592,7 +665,14 @@ def delete_comment_activity( def create_cycle_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -614,13 +694,13 @@ def create_cycle_issue_activity( issue_activities.append( IssueActivity( issue_id=updated_record.get("issue_id"), - actor=actor, + actor_id=actor_id, verb="updated", old_value=old_cycle.name, new_value=new_cycle.name, field="cycles", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}", old_identifier=old_cycle.id, new_identifier=new_cycle.id, @@ -636,13 +716,13 @@ def create_cycle_issue_activity( issue_activities.append( IssueActivity( issue_id=created_record.get("fields").get("issue"), - actor=actor, + actor_id=actor_id, verb="created", old_value="", new_value=cycle.name, field="cycles", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"added cycle {cycle.name}", new_identifier=cycle.id, epoch=epoch, @@ -651,7 +731,14 @@ def create_cycle_issue_activity( def delete_cycle_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -666,13 +753,13 @@ def delete_cycle_issue_activity( issue_activities.append( IssueActivity( issue_id=issue, - actor=actor, + actor_id=actor_id, verb="deleted", old_value=cycle.name if cycle is not None else "", new_value="", field="cycles", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"removed this issue from {cycle.name if cycle is not None else None}", old_identifier=cycle.id if cycle is not None else None, epoch=epoch, @@ -681,7 +768,14 @@ def delete_cycle_issue_activity( def create_module_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -703,14 +797,14 @@ def create_module_issue_activity( issue_activities.append( IssueActivity( issue_id=updated_record.get("issue_id"), - actor=actor, + actor_id=actor_id, verb="updated", old_value=old_module.name, new_value=new_module.name, field="modules", - project=project, - workspace=project.workspace, - comment=f"updated module from {old_module.name} to {new_module.name}", + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated module to ", old_identifier=old_module.id, new_identifier=new_module.id, epoch=epoch, @@ -724,13 +818,13 @@ def create_module_issue_activity( issue_activities.append( IssueActivity( issue_id=created_record.get("fields").get("issue"), - actor=actor, + actor_id=actor_id, verb="created", old_value="", new_value=module.name, field="modules", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"added module {module.name}", new_identifier=module.id, epoch=epoch, @@ -739,7 +833,14 @@ def create_module_issue_activity( def delete_module_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -754,14 +855,14 @@ def delete_module_issue_activity( issue_activities.append( IssueActivity( issue_id=issue, - actor=actor, + actor_id=actor_id, verb="deleted", old_value=module.name if module is not None else "", new_value="", field="modules", - project=project, - workspace=project.workspace, - comment=f"removed this issue from {module.name if module is not None else None}", + project_id=project_id, + workspace_id=workspace_id, + comment=f"removed this issue from ", old_identifier=module.id if module is not None else None, epoch=epoch, ) @@ -769,7 +870,14 @@ def delete_module_issue_activity( def create_link_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + actor_id, + workspace_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -779,11 +887,11 @@ def create_link_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"created a link", verb="created", - actor=actor, + actor_id=actor_id, field="link", new_value=requested_data.get("url", ""), new_identifier=requested_data.get("id", None), @@ -793,7 +901,14 @@ def create_link_activity( def update_link_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -804,11 +919,11 @@ def update_link_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"updated a link", verb="updated", - actor=actor, + actor_id=actor_id, field="link", old_value=current_instance.get("url", ""), old_identifier=current_instance.get("id"), @@ -820,7 +935,14 @@ def update_link_activity( def delete_link_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): current_instance = ( json.loads(current_instance) if current_instance is not None else None @@ -829,11 +951,11 @@ def delete_link_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"deleted the link", verb="deleted", - actor=actor, + actor_id=actor_id, field="link", old_value=current_instance.get("url", ""), new_value="", @@ -843,7 +965,14 @@ def delete_link_activity( def create_attachment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + actor_id, + workspace_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -853,11 +982,11 @@ def create_attachment_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"created an attachment", verb="created", - actor=actor, + actor_id=actor_id, field="attachment", new_value=current_instance.get("asset", ""), new_identifier=current_instance.get("id", None), @@ -867,16 +996,23 @@ def create_attachment_activity( def delete_attachment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"deleted the attachment", verb="deleted", - actor=actor, + actor_id=actor_id, field="attachment", epoch=epoch, ) @@ -884,13 +1020,22 @@ def delete_attachment_activity( def create_issue_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data and requested_data.get("reaction") is not None: issue_reaction = ( IssueReaction.objects.filter( - reaction=requested_data.get("reaction"), project=project, actor=actor + reaction=requested_data.get("reaction"), + project_id=project_id, + actor_id=actor_id, ) .values_list("id", flat=True) .first() @@ -899,13 +1044,13 @@ def create_issue_reaction_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="created", old_value=None, new_value=requested_data.get("reaction"), field="reaction", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment="added the reaction", old_identifier=None, new_identifier=issue_reaction, @@ -915,7 +1060,14 @@ def create_issue_reaction_activity( def delete_issue_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): current_instance = ( json.loads(current_instance) if current_instance is not None else None @@ -924,13 +1076,13 @@ def delete_issue_reaction_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="deleted", old_value=current_instance.get("reaction"), new_value=None, field="reaction", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment="removed the reaction", old_identifier=current_instance.get("identifier"), new_identifier=None, @@ -940,18 +1092,27 @@ def delete_issue_reaction_activity( def create_comment_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data and requested_data.get("reaction") is not None: comment_reaction_id, comment_id = ( CommentReaction.objects.filter( - reaction=requested_data.get("reaction"), project=project, actor=actor + reaction=requested_data.get("reaction"), + project_id=project_id, + actor_id=actor_id, ) .values_list("id", "comment__id") .first() ) - comment = IssueComment.objects.get(pk=comment_id, project=project) + comment = IssueComment.objects.get(pk=comment_id, project_id=project_id) if ( comment is not None and comment_reaction_id is not None @@ -960,13 +1121,13 @@ def create_comment_reaction_activity( issue_activities.append( IssueActivity( issue_id=comment.issue_id, - actor=actor, + actor_id=actor_id, verb="created", old_value=None, new_value=requested_data.get("reaction"), field="reaction", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment="added the reaction", old_identifier=None, new_identifier=comment_reaction_id, @@ -976,7 +1137,14 @@ def create_comment_reaction_activity( def delete_comment_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): current_instance = ( json.loads(current_instance) if current_instance is not None else None @@ -984,7 +1152,7 @@ def delete_comment_reaction_activity( if current_instance and current_instance.get("reaction") is not None: issue_id = ( IssueComment.objects.filter( - pk=current_instance.get("comment_id"), project=project + pk=current_instance.get("comment_id"), project_id=project_id ) .values_list("issue_id", flat=True) .first() @@ -993,13 +1161,13 @@ def delete_comment_reaction_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="deleted", old_value=current_instance.get("reaction"), new_value=None, field="reaction", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment="removed the reaction", old_identifier=current_instance.get("identifier"), new_identifier=None, @@ -1009,20 +1177,27 @@ def delete_comment_reaction_activity( def create_issue_vote_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data and requested_data.get("vote") is not None: issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="created", old_value=None, new_value=requested_data.get("vote"), field="vote", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment="added the vote", old_identifier=None, new_identifier=None, @@ -1032,7 +1207,14 @@ def create_issue_vote_activity( def delete_issue_vote_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): current_instance = ( json.loads(current_instance) if current_instance is not None else None @@ -1041,13 +1223,13 @@ def delete_issue_vote_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="deleted", old_value=current_instance.get("vote"), new_value=None, field="vote", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment="removed the vote", old_identifier=current_instance.get("identifier"), new_identifier=None, @@ -1057,7 +1239,14 @@ def delete_issue_vote_activity( def create_issue_relation_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -1073,13 +1262,13 @@ def create_issue_relation_activity( issue_activities.append( IssueActivity( issue_id=issue_relation.get("related_issue"), - actor=actor, + actor_id=actor_id, verb="created", old_value="", - new_value=f"{project.identifier}-{issue.sequence_id}", + new_value=f"{issue.project.identifier}-{issue.sequence_id}", field=relation_type, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"added {relation_type} relation", old_identifier=issue_relation.get("issue"), ) @@ -1088,13 +1277,13 @@ def create_issue_relation_activity( issue_activities.append( IssueActivity( issue_id=issue_relation.get("issue"), - actor=actor, + actor_id=actor_id, verb="created", old_value="", - new_value=f"{project.identifier}-{issue.sequence_id}", + new_value=f"{issue.project.identifier}-{issue.sequence_id}", field=f'{issue_relation.get("relation_type")}', - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f'added {issue_relation.get("relation_type")} relation', old_identifier=issue_relation.get("related_issue"), epoch=epoch, @@ -1103,7 +1292,14 @@ def create_issue_relation_activity( def delete_issue_relation_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -1118,13 +1314,13 @@ def delete_issue_relation_activity( issue_activities.append( IssueActivity( issue_id=current_instance.get("related_issue"), - actor=actor, + actor_id=actor_id, verb="deleted", - old_value=f"{project.identifier}-{issue.sequence_id}", + old_value=f"{issue.project.identifier}-{issue.sequence_id}", new_value="", field=relation_type, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"deleted {relation_type} relation", old_identifier=current_instance.get("issue"), epoch=epoch, @@ -1134,13 +1330,13 @@ def delete_issue_relation_activity( issue_activities.append( IssueActivity( issue_id=current_instance.get("issue"), - actor=actor, + actor_id=actor_id, verb="deleted", - old_value=f"{project.identifier}-{issue.sequence_id}", + old_value=f"{issue.project.identifier}-{issue.sequence_id}", new_value="", field=f'{current_instance.get("relation_type")}', - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f'deleted {current_instance.get("relation_type")} relation', old_identifier=current_instance.get("related_issue"), epoch=epoch, @@ -1149,24 +1345,38 @@ def delete_issue_relation_activity( def create_draft_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"drafted the issue", field="draft", verb="created", - actor=actor, + actor_id=actor_id, epoch=epoch, ) ) def update_draft_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -1179,11 +1389,11 @@ def update_draft_issue_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"created the issue", verb="updated", - actor=actor, + actor_id=actor_id, epoch=epoch, ) ) @@ -1191,28 +1401,35 @@ def update_draft_issue_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"updated the draft issue", field="draft", verb="updated", - actor=actor, + actor_id=actor_id, epoch=epoch, ) ) def delete_draft_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): issue_activities.append( IssueActivity( - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"deleted the draft issue", field="draft", verb="deleted", - actor=actor, + actor_id=actor_id, epoch=epoch, ) ) @@ -1231,44 +1448,18 @@ def issue_activity( subscriber=True, ): try: - issue_activities = [] - actor = User.objects.get(pk=actor_id) project = Project.objects.get(pk=project_id) + issue = Issue.objects.filter(pk=issue_id).first() + workspace_id = project.workspace_id - if type not in [ - "cycle.activity.created", - "cycle.activity.deleted", - "module.activity.created", - "module.activity.deleted", - "issue_reaction.activity.created", - "issue_reaction.activity.deleted", - "comment_reaction.activity.created", - "comment_reaction.activity.deleted", - "issue_vote.activity.created", - "issue_vote.activity.deleted", - "issue_draft.activity.created", - "issue_draft.activity.updated", - "issue_draft.activity.deleted", - ]: - issue = Issue.objects.filter(pk=issue_id).first() - - if issue is not None: - try: - issue.updated_at = timezone.now() - issue.save(update_fields=["updated_at"]) - except Exception as e: - pass - - if subscriber: - # add the user to issue subscriber - try: - _ = IssueSubscriber.objects.get_or_create( - issue_id=issue_id, subscriber=actor - ) - except Exception as e: - pass + if issue is not None: + try: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) + except Exception as e: + pass ACTIVITY_MAPPER = { "issue.activity.created": create_issue_activity, @@ -1302,13 +1493,14 @@ def issue_activity( func = ACTIVITY_MAPPER.get(type) if func is not None: func( - requested_data, - current_instance, - issue_id, - project, - actor, - issue_activities, - epoch, + requested_data=requested_data, + current_instance=current_instance, + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + actor_id=actor_id, + issue_activities=issue_activities, + epoch=epoch, ) # Save all the values to database @@ -1332,89 +1524,17 @@ def issue_activity( except Exception as e: capture_exception(e) - if type not in [ - "cycle.activity.created", - "cycle.activity.deleted", - "module.activity.created", - "module.activity.deleted", - "issue_reaction.activity.created", - "issue_reaction.activity.deleted", - "comment_reaction.activity.created", - "comment_reaction.activity.deleted", - "issue_vote.activity.created", - "issue_vote.activity.deleted", - "issue_draft.activity.created", - "issue_draft.activity.updated", - "issue_draft.activity.deleted", - ]: - # Create Notifications - bulk_notifications = [] - - issue_subscribers = list( - IssueSubscriber.objects.filter(project=project, issue_id=issue_id) - .exclude(subscriber_id=actor_id) - .values_list("subscriber", flat=True) - ) - - issue_assignees = list( - IssueAssignee.objects.filter(project=project, issue_id=issue_id) - .exclude(assignee_id=actor_id) - .values_list("assignee", flat=True) - ) - - issue_subscribers = issue_subscribers + issue_assignees - - issue = Issue.objects.filter(pk=issue_id).first() - - # Add bot filtering - if ( - issue is not None - and issue.created_by_id is not None - and not issue.created_by.is_bot - and str(issue.created_by_id) != str(actor_id) - ): - issue_subscribers = issue_subscribers + [issue.created_by_id] - - for subscriber in list(set(issue_subscribers)): - for issue_activity in issue_activities_created: - bulk_notifications.append( - Notification( - workspace=project.workspace, - sender="in_app:issue_activities", - triggered_by_id=actor_id, - receiver_id=subscriber, - entity_identifier=issue_id, - entity_name="issue", - project=project, - title=issue_activity.comment, - data={ - "issue": { - "id": str(issue_id), - "name": str(issue.name), - "identifier": str(issue.project.identifier), - "sequence_id": issue.sequence_id, - "state_name": issue.state.name, - "state_group": issue.state.group, - }, - "issue_activity": { - "id": str(issue_activity.id), - "verb": str(issue_activity.verb), - "field": str(issue_activity.field), - "actor": str(issue_activity.actor_id), - "new_value": str(issue_activity.new_value), - "old_value": str(issue_activity.old_value), - "issue_comment": str( - issue_activity.issue_comment.comment_stripped - if issue_activity.issue_comment is not None - else "" - ), - }, - }, - ) - ) - - # Bulk create notifications - Notification.objects.bulk_create(bulk_notifications, batch_size=100) + notifications.delay( + type=type, + issue_id=issue_id, + actor_id=actor_id, + project_id=project_id, + subscriber=subscriber, + issue_activities_created=json.dumps( + IssueActivitySerializer(issue_activities_created, many=True).data, + cls=DjangoJSONEncoder, + ), + ) return except Exception as e: diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py new file mode 100644 index 000000000..f290a38c0 --- /dev/null +++ b/apiserver/plane/bgtasks/notification_task.py @@ -0,0 +1,102 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone + +# Module imports +from plane.db.models import IssueSubscriber, Project, IssueAssignee, Issue, Notification + +# Third Party imports +from celery import shared_task + + +@shared_task +def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created): + issue_activities_created = ( + json.loads(issue_activities_created) if issue_activities_created is not None else None + ) + if type not in [ + "cycle.activity.created", + "cycle.activity.deleted", + "module.activity.created", + "module.activity.deleted", + "issue_reaction.activity.created", + "issue_reaction.activity.deleted", + "comment_reaction.activity.created", + "comment_reaction.activity.deleted", + "issue_vote.activity.created", + "issue_vote.activity.deleted", + "issue_draft.activity.created", + "issue_draft.activity.updated", + "issue_draft.activity.deleted", + ]: + # Create Notifications + bulk_notifications = [] + issue_subscribers = list( + IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id) + .exclude(subscriber_id=actor_id) + .values_list("subscriber", flat=True) + ) + + issue_assignees = list( + IssueAssignee.objects.filter(project_id=project_id, issue_id=issue_id) + .exclude(assignee_id=actor_id) + .values_list("assignee", flat=True) + ) + + issue_subscribers = issue_subscribers + issue_assignees + + issue = Issue.objects.filter(pk=issue_id).first() + + if subscriber: + # add the user to issue subscriber + try: + _ = IssueSubscriber.objects.get_or_create( + issue_id=issue_id, subscriber_id=actor_id + ) + except Exception as e: + pass + + project = Project.objects.get(pk=project_id) + + for subscriber in list(set(issue_subscribers)): + for issue_activity in issue_activities_created: + bulk_notifications.append( + Notification( + workspace=project.workspace, + sender="in_app:issue_activities", + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + project=project, + title=issue_activity.get("comment"), + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str(issue.project.identifier), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": { + "id": str(issue_activity.get("id")), + "verb": str(issue_activity.get("verb")), + "field": str(issue_activity.get("field")), + "actor": str(issue_activity.get("actor_id")), + "new_value": str(issue_activity.get("new_value")), + "old_value": str(issue_activity.get("old_value")), + "issue_comment": str( + issue_activity.get("issue_comment").comment_stripped + if issue_activity.get("issue_comment") is not None + else "" + ), + }, + }, + ) + ) + + # Bulk create notifications + Notification.objects.bulk_create(bulk_notifications, batch_size=100) diff --git a/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py new file mode 100644 index 000000000..4890ec9d5 --- /dev/null +++ b/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.5 on 2023-10-18 12:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.issue + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0045_issueactivity_epoch_workspacemember_issue_props_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='issueproperty', + name='properties', + field=models.JSONField(default=plane.db.models.issue.get_default_properties), + ), + ] diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 3ba054d49..9ba73fd43 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -16,6 +16,24 @@ from . import ProjectBaseModel from plane.utils.html_processor import strip_tags +def get_default_properties(): + return { + "assignee": True, + "start_date": True, + "due_date": True, + "labels": True, + "key": True, + "priority": True, + "state": True, + "sub_issue_count": True, + "link": True, + "attachment_count": True, + "estimate": True, + "created_on": True, + "updated_on": True, + } + + # TODO: Handle identifiers for Bulk Inserts - nk class IssueManager(models.Manager): def get_queryset(self): @@ -39,7 +57,7 @@ class Issue(ProjectBaseModel): ("high", "High"), ("medium", "Medium"), ("low", "Low"), - ("none", "None") + ("none", "None"), ) parent = models.ForeignKey( "self", @@ -186,7 +204,7 @@ class IssueRelation(ProjectBaseModel): ("relates_to", "Relates To"), ("blocked_by", "Blocked By"), ) - + issue = models.ForeignKey( Issue, related_name="issue_relation", on_delete=models.CASCADE ) @@ -208,7 +226,7 @@ class IssueRelation(ProjectBaseModel): ordering = ("-created_at",) def __str__(self): - return f"{self.issue.name} {self.related_issue.name}" + return f"{self.issue.name} {self.related_issue.name}" class IssueAssignee(ProjectBaseModel): @@ -327,7 +345,9 @@ class IssueComment(ProjectBaseModel): comment_json = models.JSONField(blank=True, default=dict) comment_html = models.TextField(blank=True, default="

") attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) - issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_comments") + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_comments" + ) # System can also create comment actor = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -367,7 +387,7 @@ class IssueProperty(ProjectBaseModel): on_delete=models.CASCADE, related_name="issue_property_user", ) - properties = models.JSONField(default=dict) + properties = models.JSONField(default=get_default_properties) class Meta: verbose_name = "Issue Property" @@ -515,7 +535,10 @@ class IssueVote(ProjectBaseModel): ) class Meta: - unique_together = ["issue", "actor",] + unique_together = [ + "issue", + "actor", + ] verbose_name = "Issue Vote" verbose_name_plural = "Issue Votes" db_table = "issue_votes" diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 9c6bd95a9..541a0cfd4 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -14,19 +14,21 @@ from .common import * # noqa # Database DEBUG = int(os.environ.get("DEBUG", 0)) == 1 -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": "plane", - "USER": os.environ.get("PGUSER", ""), - "PASSWORD": os.environ.get("PGPASSWORD", ""), - "HOST": os.environ.get("PGHOST", ""), +if bool(os.environ.get("DATABASE_URL")): + # Parse database configuration from $DATABASE_URL + DATABASES["default"] = dj_database_url.config() +else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_DB"), + "USER": os.environ.get("POSTGRES_USER"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD"), + "HOST": os.environ.get("POSTGRES_HOST"), + } } -} -# Parse database configuration from $DATABASE_URL -DATABASES["default"] = dj_database_url.config() SITE_ID = 1 # Set the variable true if running in docker environment @@ -278,4 +280,3 @@ SCOUT_NAME = "Plane" # Unsplash Access key UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") - diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 0b8f39e14..52c181622 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -1,10 +1,24 @@ import re +import uuid from datetime import timedelta from django.utils import timezone + # The date from pattern pattern = re.compile(r"\d+_(weeks|months)$") +# check the valid uuids +def filter_valid_uuids(uuid_list): + valid_uuids = [] + for uuid_str in uuid_list: + try: + uuid_obj = uuid.UUID(uuid_str) + valid_uuids.append(uuid_obj) + except ValueError: + # ignore the invalid uuids + pass + return valid_uuids + # Get the 2_weeks, 3_months def string_date_filter(filter, duration, subsequent, term, date_filter, offset): @@ -61,40 +75,41 @@ def date_filter(filter, date_term, queries): def filter_state(params, filter, method): if method == "GET": - states = params.get("state").split(",") + states = [item for item in params.get("state").split(",") if item != 'null'] + states = filter_valid_uuids(states) if len(states) and "" not in states: filter["state__in"] = states else: - if params.get("state", None) and len(params.get("state")): + if params.get("state", None) and len(params.get("state")) and params.get("state") != 'null': filter["state__in"] = params.get("state") return filter def filter_state_group(params, filter, method): if method == "GET": - state_group = params.get("state_group").split(",") + state_group = [item for item in params.get("state_group").split(",") if item != 'null'] if len(state_group) and "" not in state_group: filter["state__group__in"] = state_group else: - if params.get("state_group", None) and len(params.get("state_group")): + if params.get("state_group", None) and len(params.get("state_group")) and params.get("state_group") != 'null': filter["state__group__in"] = params.get("state_group") return filter def filter_estimate_point(params, filter, method): if method == "GET": - estimate_points = params.get("estimate_point").split(",") + estimate_points = [item for item in params.get("estimate_point").split(",") if item != 'null'] if len(estimate_points) and "" not in estimate_points: filter["estimate_point__in"] = estimate_points else: - if params.get("estimate_point", None) and len(params.get("estimate_point")): + if params.get("estimate_point", None) and len(params.get("estimate_point")) and params.get("estimate_point") != 'null': filter["estimate_point__in"] = params.get("estimate_point") return filter def filter_priority(params, filter, method): if method == "GET": - priorities = params.get("priority").split(",") + priorities = [item for item in params.get("priority").split(",") if item != 'null'] if len(priorities) and "" not in priorities: filter["priority__in"] = priorities return filter @@ -102,44 +117,48 @@ def filter_priority(params, filter, method): def filter_parent(params, filter, method): if method == "GET": - parents = params.get("parent").split(",") + parents = [item for item in params.get("parent").split(",") if item != 'null'] + parents = filter_valid_uuids(parents) if len(parents) and "" not in parents: filter["parent__in"] = parents else: - if params.get("parent", None) and len(params.get("parent")): + if params.get("parent", None) and len(params.get("parent")) and params.get("parent") != 'null': filter["parent__in"] = params.get("parent") return filter def filter_labels(params, filter, method): if method == "GET": - labels = params.get("labels").split(",") + labels = [item for item in params.get("labels").split(",") if item != 'null'] + labels = filter_valid_uuids(labels) if len(labels) and "" not in labels: filter["labels__in"] = labels else: - if params.get("labels", None) and len(params.get("labels")): + if params.get("labels", None) and len(params.get("labels")) and params.get("labels") != 'null': filter["labels__in"] = params.get("labels") return filter def filter_assignees(params, filter, method): if method == "GET": - assignees = params.get("assignees").split(",") + assignees = [item for item in params.get("assignees").split(",") if item != 'null'] + assignees = filter_valid_uuids(assignees) if len(assignees) and "" not in assignees: filter["assignees__in"] = assignees else: - if params.get("assignees", None) and len(params.get("assignees")): + if params.get("assignees", None) and len(params.get("assignees")) and params.get("assignees") != 'null': filter["assignees__in"] = params.get("assignees") return filter def filter_created_by(params, filter, method): if method == "GET": - created_bys = params.get("created_by").split(",") + created_bys = [item for item in params.get("created_by").split(",") if item != 'null'] + created_bys = filter_valid_uuids(created_bys) if len(created_bys) and "" not in created_bys: filter["created_by__in"] = created_bys else: - if params.get("created_by", None) and len(params.get("created_by")): + if params.get("created_by", None) and len(params.get("created_by")) and params.get("created_by") != 'null': filter["created_by__in"] = params.get("created_by") return filter @@ -219,44 +238,47 @@ def filter_issue_state_type(params, filter, method): def filter_project(params, filter, method): if method == "GET": - projects = params.get("project").split(",") + projects = [item for item in params.get("project").split(",") if item != 'null'] + projects = filter_valid_uuids(projects) if len(projects) and "" not in projects: filter["project__in"] = projects else: - if params.get("project", None) and len(params.get("project")): + if params.get("project", None) and len(params.get("project")) and params.get("project") != 'null': filter["project__in"] = params.get("project") return filter def filter_cycle(params, filter, method): if method == "GET": - cycles = params.get("cycle").split(",") + cycles = [item for item in params.get("cycle").split(",") if item != 'null'] + cycles = filter_valid_uuids(cycles) if len(cycles) and "" not in cycles: filter["issue_cycle__cycle_id__in"] = cycles else: - if params.get("cycle", None) and len(params.get("cycle")): + if params.get("cycle", None) and len(params.get("cycle")) and params.get("cycle") != 'null': filter["issue_cycle__cycle_id__in"] = params.get("cycle") return filter def filter_module(params, filter, method): if method == "GET": - modules = params.get("module").split(",") + modules = [item for item in params.get("module").split(",") if item != 'null'] + modules = filter_valid_uuids(modules) if len(modules) and "" not in modules: filter["issue_module__module_id__in"] = modules else: - if params.get("module", None) and len(params.get("module")): + if params.get("module", None) and len(params.get("module")) and params.get("module") != 'null': filter["issue_module__module_id__in"] = params.get("module") return filter def filter_inbox_status(params, filter, method): if method == "GET": - status = params.get("inbox_status").split(",") + status = [item for item in params.get("inbox_status").split(",") if item != 'null'] if len(status) and "" not in status: filter["issue_inbox__status__in"] = status else: - if params.get("inbox_status", None) and len(params.get("inbox_status")): + if params.get("inbox_status", None) and len(params.get("inbox_status")) and params.get("inbox_status") != 'null': filter["issue_inbox__status__in"] = params.get("inbox_status") return filter @@ -275,11 +297,12 @@ def filter_sub_issue_toggle(params, filter, method): def filter_subscribed_issues(params, filter, method): if method == "GET": - subscribers = params.get("subscriber").split(",") + subscribers = [item for item in params.get("subscriber").split(",") if item != 'null'] + subscribers = filter_valid_uuids(subscribers) if len(subscribers) and "" not in subscribers: filter["issue_subscribers__subscriber_id__in"] = subscribers else: - if params.get("subscriber", None) and len(params.get("subscriber")): + if params.get("subscriber", None) and len(params.get("subscriber")) and params.get("subscriber") != 'null': filter["issue_subscribers__subscriber_id__in"] = params.get("subscriber") return filter diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js index 8a03462c0..b877dc7c0 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -213,7 +213,9 @@ module.exports = { }, }, }), - + screens: { + "3xl": "1792px", + }, // scale down font sizes to 90% of default fontSize: { xs: "0.675rem", diff --git a/packages/ui/src/progress/circular-progress-indicator.tsx b/packages/ui/src/progress/circular-progress-indicator.tsx new file mode 100644 index 000000000..8ef74ea52 --- /dev/null +++ b/packages/ui/src/progress/circular-progress-indicator.tsx @@ -0,0 +1,102 @@ +import React, { Children } from "react"; + +interface ICircularProgressIndicator { + size: number; + percentage: number; + strokeWidth?: number; + strokeColor?: string; + children?: React.ReactNode; +} + +export const CircularProgressIndicator: React.FC = ( + props +) => { + const { size = 40, percentage = 25, strokeWidth = 6, children } = props; + + const sqSize = size; + const radius = (size - strokeWidth) / 2; + const viewBox = `0 0 ${sqSize} ${sqSize}`; + const dashArray = radius * Math.PI * 2; + const dashOffset = dashArray - (dashArray * percentage) / 100; + return ( +
+ + + + + + + + + + + + + + + + + + +
+ {children} +
+
+ ); +}; diff --git a/packages/ui/src/progress/index.tsx b/packages/ui/src/progress/index.tsx index ad5a371c1..56d28cee7 100644 --- a/packages/ui/src/progress/index.tsx +++ b/packages/ui/src/progress/index.tsx @@ -1,3 +1,4 @@ export * from "./radial-progress"; export * from "./progress-bar"; export * from "./linear-progress-indicator"; +export * from "./circular-progress-indicator"; diff --git a/web/components/core/sidebar/links-list.tsx b/web/components/core/sidebar/links-list.tsx index 6de940296..ff8d30927 100644 --- a/web/components/core/sidebar/links-list.tsx +++ b/web/components/core/sidebar/links-list.tsx @@ -15,58 +15,63 @@ type Props = { export const LinksList: React.FC = ({ links, handleDeleteLink, handleEditLink, userAuth }) => { const isNotAllowed = userAuth.isGuest || userAuth.isViewer; - return ( <> {links.map((link) => ( -
- {!isNotAllowed && ( -
- - - - - +
+
+
+ + + + {link.title && link.title !== "" ? link.title : link.url}
- )} - -
- -
-
-
{link.title ?? link.url}
-

- Added {timeAgo(link.created_at)} -
- by{" "} - {link.created_by_detail.is_bot - ? link.created_by_detail.first_name + " Bot" - : link.created_by_detail.display_name} -

-
-
+ + {!isNotAllowed && ( +
+ + + + + +
+ )} +
+
+

+ Added {timeAgo(link.created_at)} +
+ by{" "} + {link.created_by_detail.is_bot + ? link.created_by_detail.first_name + " Bot" + : link.created_by_detail.display_name} +

+
))} diff --git a/web/components/core/sidebar/sidebar-progress-stats.tsx b/web/components/core/sidebar/sidebar-progress-stats.tsx index fed61b59f..bb6690172 100644 --- a/web/components/core/sidebar/sidebar-progress-stats.tsx +++ b/web/components/core/sidebar/sidebar-progress-stats.tsx @@ -1,11 +1,16 @@ import React from "react"; +import Image from "next/image"; // headless ui import { Tab } from "@headlessui/react"; // hooks import useLocalStorage from "hooks/use-local-storage"; import useIssuesView from "hooks/use-issues-view"; +// images +import emptyLabel from "public/empty-state/empty_label.svg"; +import emptyMembers from "public/empty-state/empty_members.svg"; // components +import { StateGroupIcon } from "@plane/ui"; import { SingleProgressStats } from "components/core"; // ui import { Avatar } from "components/ui"; @@ -17,9 +22,7 @@ import { TLabelsDistribution, TStateGroups, } from "types"; -// constants -import { STATE_GROUP_COLORS } from "constants/state"; -// types + type Props = { distribution: { assignees: TAssigneesDistribution[]; @@ -33,6 +36,7 @@ type Props = { module?: IModule; roundedTab?: boolean; noBackground?: boolean; + isPeekModuleDetails?: boolean; }; export const SidebarProgressStats: React.FC = ({ @@ -42,6 +46,7 @@ export const SidebarProgressStats: React.FC = ({ module, roundedTab, noBackground, + isPeekModuleDetails = false, }) => { const { filters, setFilters } = useIssuesView(); @@ -55,7 +60,6 @@ export const SidebarProgressStats: React.FC = ({ return 1; case "States": return 2; - default: return 0; } @@ -72,7 +76,6 @@ export const SidebarProgressStats: React.FC = ({ return setTab("Labels"); case 2: return setTab("States"); - default: return setTab("Assignees"); } @@ -82,15 +85,17 @@ export const SidebarProgressStats: React.FC = ({ as="div" className={`flex w-full items-center gap-2 justify-between rounded-md ${ noBackground ? "" : "bg-custom-background-90" - } px-1 py-1.5 - ${module ? "text-xs" : "text-sm"} `} + } p-0.5 + ${module ? "text-xs" : "text-sm"}`} > `w-full ${ roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded" } px-3 py-1 text-custom-text-100 ${ - selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80" + selected + ? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs" + : "text-custom-text-400 hover:text-custom-text-300" }` } > @@ -101,7 +106,9 @@ export const SidebarProgressStats: React.FC = ({ `w-full ${ roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded" } px-3 py-1 text-custom-text-100 ${ - selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80" + selected + ? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs" + : "text-custom-text-400 hover:text-custom-text-300" }` } > @@ -112,113 +119,128 @@ export const SidebarProgressStats: React.FC = ({ `w-full ${ roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded" } px-3 py-1 text-custom-text-100 ${ - selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80" + selected + ? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs" + : "text-custom-text-400 hover:text-custom-text-300" }` } > States - - - {distribution.assignees.map((assignee, index) => { - if (assignee.assignee_id) - return ( - - - {assignee.display_name} -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - onClick={() => { - if (filters?.assignees?.includes(assignee.assignee_id ?? "")) - setFilters({ - assignees: filters?.assignees?.filter((a) => a !== assignee.assignee_id), - }); - else - setFilters({ - assignees: [...(filters?.assignees ?? []), assignee.assignee_id ?? ""], - }); - }} - selected={filters?.assignees?.includes(assignee.assignee_id ?? "")} - /> - ); - else - return ( - -
- User + + {distribution.assignees.length > 0 ? ( + distribution.assignees.map((assignee, index) => { + if (assignee.assignee_id) + return ( + + + {assignee.display_name}
- No assignee -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - })} - - - {distribution.labels.map((label, index) => ( - - { + if (filters?.assignees?.includes(assignee.assignee_id ?? "")) + setFilters({ + assignees: filters?.assignees?.filter((a) => a !== assignee.assignee_id), + }); + else + setFilters({ + assignees: [...(filters?.assignees ?? []), assignee.assignee_id ?? ""], + }); + }, + selected: filters?.assignees?.includes(assignee.assignee_id ?? ""), + })} /> - {label.label_name ?? "No labels"} - - } - completed={label.completed_issues} - total={label.total_issues} - onClick={() => { - if (filters.labels?.includes(label.label_id ?? "")) - setFilters({ - labels: filters?.labels?.filter((l) => l !== label.label_id), - }); - else setFilters({ labels: [...(filters?.labels ?? []), label.label_id ?? ""] }); - }} - selected={filters?.labels?.includes(label.label_id ?? "")} - /> - ))} + ); + else + return ( + +
+ User +
+ No assignee + + } + completed={assignee.completed_issues} + total={assignee.total_issues} + /> + ); + }) + ) : ( +
+
+ empty members +
+
No assignees yet
+
+ )}
- + + {distribution.labels.length > 0 ? ( + distribution.labels.map((label, index) => ( + + + {label.label_name ?? "No labels"} + + } + completed={label.completed_issues} + total={label.total_issues} + {...(!isPeekModuleDetails && { + onClick: () => { + if (filters.labels?.includes(label.label_id ?? "")) + setFilters({ + labels: filters?.labels?.filter((l) => l !== label.label_id), + }); + else setFilters({ labels: [...(filters?.labels ?? []), label.label_id ?? ""] }); + }, + selected: filters?.labels?.includes(label.label_id ?? ""), + })} + /> + )) + ) : ( +
+
+ empty label +
+
No labels yet
+
+ )} +
+ {Object.keys(groupedIssues).map((group, index) => ( - + {group} } diff --git a/web/components/core/sidebar/single-progress-stats.tsx b/web/components/core/sidebar/single-progress-stats.tsx index 3ff214b57..ba6675c9a 100644 --- a/web/components/core/sidebar/single-progress-stats.tsx +++ b/web/components/core/sidebar/single-progress-stats.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { ProgressBar } from "@plane/ui"; +import { CircularProgressIndicator } from "@plane/ui"; type TSingleProgressStatsProps = { title: any; @@ -27,7 +27,7 @@ export const SingleProgressStats: React.FC = ({
- + {isNaN(Math.floor((completed / total) * 100)) ? "0" : Math.floor((completed / total) * 100)}% diff --git a/web/components/headers/modules-list.tsx b/web/components/headers/modules-list.tsx index 8d5df1b32..01c6df5c8 100644 --- a/web/components/headers/modules-list.tsx +++ b/web/components/headers/modules-list.tsx @@ -1,25 +1,28 @@ import { useRouter } from "next/router"; import Link from "next/link"; import { observer } from "mobx-react-lite"; -import { Plus } from "lucide-react"; +import { GanttChart, LayoutGrid, List, Plus } from "lucide-react"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // hooks import useLocalStorage from "hooks/use-local-storage"; // ui import { Breadcrumbs, BreadcrumbItem, Button, Tooltip } from "@plane/ui"; -import { Icon } from "components/ui"; // helper import { replaceUnderscoreIfSnakeCase, truncateText } from "helpers/string.helper"; -const moduleViewOptions: { type: "grid" | "gantt_chart"; icon: any }[] = [ +const moduleViewOptions: { type: "list" | "grid" | "gantt_chart"; icon: any }[] = [ { - type: "gantt_chart", - icon: "view_timeline", + type: "list", + icon: List, }, { type: "grid", - icon: "table_rows", + icon: LayoutGrid, + }, + { + type: "gantt_chart", + icon: GanttChart, }, ]; @@ -67,7 +70,7 @@ export const ModulesListHeader: React.FC = observer(() => { }`} onClick={() => setModulesView(option.type)} > - + ))} diff --git a/web/components/modules/index.ts b/web/components/modules/index.ts index 750db2fd0..c87ea79d2 100644 --- a/web/components/modules/index.ts +++ b/web/components/modules/index.ts @@ -7,3 +7,5 @@ export * from "./modal"; export * from "./modules-list-view"; export * from "./sidebar"; export * from "./module-card-item"; +export * from "./module-list-item"; +export * from "./module-peek-overview"; diff --git a/web/components/modules/module-card-item.tsx b/web/components/modules/module-card-item.tsx index 74dd20ad9..620333f8e 100644 --- a/web/components/modules/module-card-item.tsx +++ b/web/components/modules/module-card-item.tsx @@ -10,14 +10,16 @@ import useToast from "hooks/use-toast"; import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules"; // ui import { AssigneesList } from "components/ui"; -import { CustomMenu, Tooltip } from "@plane/ui"; +import { CustomMenu, LayersIcon, Tooltip } from "@plane/ui"; // icons -import { CalendarDays, LinkIcon, Pencil, Star, Target, Trash2 } from "lucide-react"; +import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react"; // helpers -import { copyUrlToClipboard, truncateText } from "helpers/string.helper"; -import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; +import { copyUrlToClipboard } from "helpers/string.helper"; +import { renderShortDate, renderShortMonthDate } from "helpers/date-time.helper"; // types import { IModule } from "types"; +// constants +import { MODULE_STATUS } from "constants/module"; type Props = { module: IModule; @@ -72,9 +74,32 @@ export const ModuleCardItem: React.FC = observer((props) => { }); }; + const openModuleOverview = () => { + const { query } = router; + + router.push({ + pathname: router.pathname, + query: { ...query, peekModule: module.id }, + }); + }; + const endDate = new Date(module.target_date ?? ""); const startDate = new Date(module.start_date ?? ""); - const lastUpdated = new Date(module.updated_at ?? ""); + + const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); + + const moduleStatus = MODULE_STATUS.find((status) => status.value === module.status); + + const issueCount = + module.completed_issues && module.total_issues + ? module.total_issues === 0 + ? "0 Issue" + : module.total_issues === module.completed_issues + ? module.total_issues > 1 + ? `${module.total_issues} Issues` + : `${module.total_issues} Issue` + : `${module.completed_issues}/${module.total_issues} Issues` + : "0 Issue"; return ( <> @@ -88,96 +113,142 @@ export const ModuleCardItem: React.FC = observer((props) => { /> )} setModuleDeleteModal(false)} /> -
-
-
-
- - - -

- {truncateText(module.name, 75)} -

-
- + + +
+
+ + {module.name} +
+ {moduleStatus && ( + + {moduleStatus.label} + + )} + +
+
+
-
-
- {module?.status?.replace("-", " ")} +
+
+
+ + {issueCount} +
+ {module.members_detail.length > 0 && ( + +
+ +
+
+ )} +
+ + +
+
+
+
+ + +
+ + {areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} -{" "} + {areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")} + +
{module.is_favorite ? ( - ) : ( - )} - - - + + { + e.preventDefault(); + e.stopPropagation(); + setEditModuleModal(true); + }} + > - - Copy link - - - setEditModuleModal(true)}> - - + Edit module - setModuleDeleteModal(true)}> + { + e.preventDefault(); + e.stopPropagation(); + setModuleDeleteModal(true); + }} + > - + Delete module + { + e.preventDefault(); + e.stopPropagation(); + handleCopyText(); + }} + > + + + Copy module link + +
-
-
- - Start: - {renderShortDateWithYearFormat(startDate, "Not set")} -
-
- - End: - {renderShortDateWithYearFormat(endDate, "Not set")} -
-
-
-
-
- Progress -
-
-
- {isNaN(completionPercentage) ? 0 : completionPercentage.toFixed(0)}% -
-
-

- Last updated: - {renderShortDateWithYearFormat(lastUpdated)} -

- {module.members_detail.length > 0 && ( -
- -
- )} -
-
-
+
+ ); }); diff --git a/web/components/modules/module-list-item.tsx b/web/components/modules/module-list-item.tsx new file mode 100644 index 000000000..8b1271cc8 --- /dev/null +++ b/web/components/modules/module-list-item.tsx @@ -0,0 +1,242 @@ +import React, { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import useToast from "hooks/use-toast"; +// components +import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules"; +// ui +import { AssigneesList } from "components/ui"; +import { CircularProgressIndicator, CustomMenu, Tooltip } from "@plane/ui"; +// icons +import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react"; +// helpers +import { copyUrlToClipboard } from "helpers/string.helper"; +import { renderShortDate, renderShortMonthDate } from "helpers/date-time.helper"; +// types +import { IModule } from "types"; +// constants +import { MODULE_STATUS } from "constants/module"; + +type Props = { + module: IModule; +}; + +export const ModuleListItem: React.FC = observer((props) => { + const { module } = props; + + const [editModuleModal, setEditModuleModal] = useState(false); + const [moduleDeleteModal, setModuleDeleteModal] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { setToastAlert } = useToast(); + + const { module: moduleStore } = useMobxStore(); + + const completionPercentage = ((module.completed_issues + module.cancelled_issues) / module.total_issues) * 100; + + const handleAddToFavorites = () => { + if (!workspaceSlug || !projectId) return; + + moduleStore.addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the module to favorites. Please try again.", + }); + }); + }; + + const handleRemoveFromFavorites = () => { + if (!workspaceSlug || !projectId) return; + + moduleStore.removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't remove the module from favorites. Please try again.", + }); + }); + }; + + const handleCopyText = () => { + copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${module.id}`).then(() => { + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Module link copied to clipboard.", + }); + }); + }; + + const endDate = new Date(module.target_date ?? ""); + const startDate = new Date(module.start_date ?? ""); + + const renderDate = module.start_date || module.target_date; + + const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); + + const moduleStatus = MODULE_STATUS.find((status) => status.value === module.status); + + const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); + + const completedModuleCheck = module.status === "completed" && module.total_issues - module.completed_issues; + + const openModuleOverview = () => { + const { query } = router; + + router.push({ + pathname: router.pathname, + query: { ...query, peekModule: module.id }, + }); + }; + + return ( + <> + {workspaceSlug && projectId && ( + setEditModuleModal(false)} + data={module} + projectId={projectId.toString()} + workspaceSlug={workspaceSlug.toString()} + /> + )} + setModuleDeleteModal(false)} /> + + +
+
+ + + {completedModuleCheck ? ( + {`!`} + ) : progress === 100 ? ( + + ) : ( + {`${progress}%`} + )} + + + + {module.name} + +
+ +
+ +
+
+ {moduleStatus && ( + + {moduleStatus.label} + + )} +
+ + {renderDate && ( + + {areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} + {" - "} + {areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")} + + )} + + +
+ {module.members_detail.length > 0 ? ( + + ) : ( + + + + )} +
+
+ + {module.is_favorite ? ( + + ) : ( + + )} + + + { + e.preventDefault(); + e.stopPropagation(); + setEditModuleModal(true); + }} + > + + + Edit module + + + { + e.preventDefault(); + e.stopPropagation(); + setModuleDeleteModal(true); + }} + > + + + Delete module + + + { + e.preventDefault(); + e.stopPropagation(); + handleCopyText(); + }} + > + + + Copy module link + + + +
+
+ + + ); +}); diff --git a/web/components/modules/module-peek-overview.tsx b/web/components/modules/module-peek-overview.tsx new file mode 100644 index 000000000..c7d2630cb --- /dev/null +++ b/web/components/modules/module-peek-overview.tsx @@ -0,0 +1,55 @@ +import React, { useEffect } from "react"; + +import { useRouter } from "next/router"; + +// mobx +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; +// components +import { ModuleDetailsSidebar } from "./sidebar"; + +type Props = { + projectId: string; + workspaceSlug: string; +}; + +export const ModulePeekOverview: React.FC = observer(({ projectId, workspaceSlug }) => { + const router = useRouter(); + const { peekModule } = router.query; + + const ref = React.useRef(null); + + const { module: moduleStore } = useMobxStore(); + const { fetchModuleDetails } = moduleStore; + + const handleClose = () => { + delete router.query.peekModule; + router.push({ + pathname: router.pathname, + query: { ...router.query }, + }); + }; + + useEffect(() => { + if (!peekModule) return; + + fetchModuleDetails(workspaceSlug, projectId, peekModule.toString()); + }, [fetchModuleDetails, peekModule, projectId, workspaceSlug]); + + return ( + <> + {peekModule && ( +
+ +
+ )} + + ); +}); diff --git a/web/components/modules/modules-list-view.tsx b/web/components/modules/modules-list-view.tsx index d40b72a31..457f43524 100644 --- a/web/components/modules/modules-list-view.tsx +++ b/web/components/modules/modules-list-view.tsx @@ -1,3 +1,4 @@ +import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Plus } from "lucide-react"; // mobx store @@ -5,7 +6,7 @@ import { useMobxStore } from "lib/mobx/store-provider"; // hooks import useLocalStorage from "hooks/use-local-storage"; // components -import { ModuleCardItem, ModulesListGanttChartView } from "components/modules"; +import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "components/modules"; import { EmptyState } from "components/common"; // ui import { Loader } from "@plane/ui"; @@ -13,6 +14,9 @@ import { Loader } from "@plane/ui"; import emptyModule from "public/empty-state/module.svg"; export const ModulesListView: React.FC = observer(() => { + const router = useRouter(); + const { workspaceSlug, projectId, peekModule } = router.query; + const { module: moduleStore } = useMobxStore(); const { storedValue: modulesView } = useLocalStorage("modules_view", "grid"); @@ -22,12 +26,12 @@ export const ModulesListView: React.FC = observer(() => { if (!modulesList) return ( - - - - - - + + + + + + ); @@ -35,12 +39,39 @@ export const ModulesListView: React.FC = observer(() => { <> {modulesList.length > 0 ? ( <> + {modulesView === "list" && ( +
+
+
+ {modulesList.map((module) => ( + + ))} +
+ +
+
+ )} {modulesView === "grid" && ( -
-
- {modulesList.map((module) => ( - - ))} +
+
+
+ {modulesList.map((module) => ( + + ))} +
+
)} diff --git a/web/components/modules/sidebar-select/select-lead.tsx b/web/components/modules/sidebar-select/select-lead.tsx index 83ee93404..020aad037 100644 --- a/web/components/modules/sidebar-select/select-lead.tsx +++ b/web/components/modules/sidebar-select/select-lead.tsx @@ -7,7 +7,7 @@ import { ProjectService } from "services/project"; import { Avatar } from "components/ui"; import { CustomSearchSelect } from "@plane/ui"; // icons -import { UserCircle2 } from "lucide-react"; +import { ChevronDown, UserCircle2 } from "lucide-react"; // fetch-keys import { PROJECT_MEMBERS } from "constants/fetch-keys"; @@ -36,7 +36,7 @@ export const SidebarLeadSelect: FC = (props) => { query: member.member.display_name, content: (
- + {member.member.display_name}
), @@ -46,18 +46,27 @@ export const SidebarLeadSelect: FC = (props) => { return (
-
- - Lead +
+ + Lead
-
+
- {selectedOption && } - {selectedOption ? selectedOption?.display_name : No lead} -
+ customButtonClassName="rounded-sm" + customButton={ + selectedOption ? ( +
+ + {selectedOption?.display_name} +
+ ) : ( +
+ No lead + +
+ ) } options={options} maxHeight="md" diff --git a/web/components/modules/sidebar-select/select-members.tsx b/web/components/modules/sidebar-select/select-members.tsx index c25959e3f..7b74ef794 100644 --- a/web/components/modules/sidebar-select/select-members.tsx +++ b/web/components/modules/sidebar-select/select-members.tsx @@ -10,6 +10,7 @@ import { ProjectService } from "services/project"; import { AssigneesList, Avatar } from "components/ui"; import { CustomSearchSelect, UserGroupIcon } from "@plane/ui"; // icons +import { ChevronDown } from "lucide-react"; // fetch-keys import { PROJECT_MEMBERS } from "constants/fetch-keys"; @@ -37,7 +38,7 @@ export const SidebarMembersSelect: React.FC = ({ value, onChange }) => { query: member.member.display_name, content: (
- + {member.member.display_name}
), @@ -45,24 +46,26 @@ export const SidebarMembersSelect: React.FC = ({ value, onChange }) => { return (
-
- - Members +
+ + Members
-
+
- {value && value.length > 0 && Array.isArray(value) ? ( -
- - {value.length} Assignees -
- ) : ( - "No members" - )} -
+ customButtonClassName="rounded-sm" + customButton={ + value && value.length > 0 && Array.isArray(value) ? ( +
+ +
+ ) : ( +
+ No members + +
+ ) } options={options} onChange={onChange} diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index c6a2818ee..aa5462e08 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -3,8 +3,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { mutate } from "swr"; import { Controller, useForm } from "react-hook-form"; -import { Disclosure, Popover, Transition } from "@headlessui/react"; -import DatePicker from "react-datepicker"; +import { Disclosure, Transition } from "@headlessui/react"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // services @@ -18,22 +17,12 @@ import { LinkModal, LinksList, SidebarProgressStats } from "components/core"; import { DeleteModuleModal, SidebarLeadSelect, SidebarMembersSelect } from "components/modules"; import ProgressChart from "components/core/sidebar/progress-chart"; // ui -import { CustomSelect, CustomMenu, Loader, ProgressBar } from "@plane/ui"; +import { CustomMenu, Loader, LayersIcon } from "@plane/ui"; // icon -import { - AlertCircle, - CalendarDays, - ChevronDown, - File, - LinkIcon, - MoveRight, - PieChart, - Plus, - Trash2, -} from "lucide-react"; +import { AlertCircle, ChevronDown, ChevronRight, Info, LinkIcon, Plus, Trash2 } from "lucide-react"; // helpers -import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper"; -import { capitalizeFirstLetter, copyUrlToClipboard } from "helpers/string.helper"; +import { renderShortDate, renderShortMonthDate } from "helpers/date-time.helper"; +import { copyUrlToClipboard } from "helpers/string.helper"; // types import { linkDetails, IModule, ModuleLink } from "types"; // fetch-keys @@ -50,8 +39,8 @@ const defaultValues: Partial = { }; type Props = { - isOpen: boolean; moduleId: string; + handleClose: () => void; }; // services @@ -59,14 +48,14 @@ const moduleService = new ModuleService(); // TODO: refactor this component export const ModuleDetailsSidebar: React.FC = observer((props) => { - const { isOpen, moduleId } = props; + const { moduleId, handleClose } = props; const [moduleDeleteModal, setModuleDeleteModal] = useState(false); const [moduleLinkModal, setModuleLinkModal] = useState(false); const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState(null); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId, peekModule } = router.query; const { module: moduleStore, user: userStore } = useMobxStore(); @@ -77,7 +66,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const { setToastAlert } = useToast(); - const { reset, watch, control } = useForm({ + const { reset, control } = useForm({ defaultValues, }); @@ -209,12 +198,29 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { : null; const handleEditLink = (link: linkDetails) => { + console.log("link", link); setSelectedLinkToUpdate(link); setModuleLinkModal(true); }; if (!moduleDetails) return null; + const startDate = new Date(moduleDetails.start_date ?? ""); + const endDate = new Date(moduleDetails.target_date ?? ""); + + const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); + + const moduleStatus = MODULE_STATUS.find((status) => status.value === moduleDetails.status); + + const issueCount = + moduleDetails.total_issues === 0 + ? "0 Issue" + : moduleDetails.total_issues === moduleDetails.completed_issues + ? moduleDetails.total_issues > 1 + ? `${moduleDetails.total_issues}` + : `${moduleDetails.total_issues}` + : `${moduleDetails.completed_issues}/${moduleDetails.total_issues}`; + return ( <> = observer((props) => { updateIssueLink={handleUpdateLink} /> setModuleDeleteModal(false)} data={moduleDetails} /> -
- {module ? ( - <> -
-
-
- ( - - {capitalizeFirstLetter(`${watch("status")}`)} - - } - value={value} - onChange={(value: any) => { - submitChanges({ status: value }); - }} - > - {MODULE_STATUS.map((option) => ( - - {option.label} - - ))} - - )} - /> -
-
- - {({}) => ( - <> - - - - {renderShortDateWithYearFormat(new Date(`${moduleDetails.start_date}`), "Start date")} - - - - - - { - submitChanges({ - start_date: renderDateFormat(date), - }); - }} - selectsStart - startDate={new Date(`${watch("start_date")}`)} - endDate={new Date(`${watch("target_date")}`)} - maxDate={new Date(`${watch("target_date")}`)} - shouldCloseOnSelect - inline - /> - - - - )} - - - + {module ? ( + <> +
+
+ {peekModule && ( + + )} +
+
+ + + setModuleDeleteModal(true)}> + + + Delete - - {({}) => ( - <> - - + + +
+
- - {renderShortDateWithYearFormat(new Date(`${moduleDetails?.target_date}`), "End date")} - - +
+

{moduleDetails.name}

+
+ {moduleStatus && ( + + {moduleStatus.label} + + )} + + {areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} -{" "} + {areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")} + +
+
- - - { - submitChanges({ - target_date: renderDateFormat(date), - }); - }} - selectsEnd - startDate={new Date(`${watch("start_date")}`)} - endDate={new Date(`${watch("target_date")}`)} - minDate={new Date(`${watch("start_date")}`)} - shouldCloseOnSelect - inline - /> - - - - )} - -
+ {moduleDetails.description && ( + + {moduleDetails.description} + + )} + +
+ ( + { + submitChanges({ lead: val }); + }} + /> + )} + /> + ( + { + submitChanges({ members_list: val }); + }} + /> + )} + /> + +
+
+ + Issues
- -
-
-
-
-

- {moduleDetails.name} -

-
- - setModuleDeleteModal(true)}> - - - Delete - - - - - - Copy link - - - -
- - - {moduleDetails.description} - -
- -
- ( - { - submitChanges({ lead: val }); - }} - /> - )} - /> - ( - { - submitChanges({ members_list: val }); - }} - /> - )} - /> - -
-
- - Progress -
- -
- - - - {moduleDetails.completed_issues}/{moduleDetails.total_issues} -
-
-
+
+ {issueCount}
+
-
- +
+
+ {({ open }) => (
-
+
Progress - {!open && progressPercentage ? ( - +
+ +
+ {progressPercentage ? ( + {progressPercentage ? `${progressPercentage}%` : ""} ) : ( "" )} -
- - {isStartValid && isEndValid ? ( - - - ) : ( -
- - - Invalid date. Please enter valid date. - -
- )} -
- - {isStartValid && isEndValid ? ( -
-
-
- - - - - Pending Issues -{" "} - {moduleDetails.total_issues - - (moduleDetails.completed_issues + moduleDetails.cancelled_issues)}{" "} - -
- -
-
- - Ideal -
-
- - Current -
-
-
-
- -
-
+ + ) : ( - "" +
+ + + Invalid date. Please enter valid date. + +
)} -
-
-
- )} - -
- -
- - {({ open }) => ( -
-
-
- Other Information
- - {moduleDetails.total_issues > 0 ? ( - - - ) : ( -
- - - No issues found. Please add issue. - -
- )}
- {moduleDetails.total_issues > 0 ? ( - <> -
+
+ {isStartValid && isEndValid ? ( +
+
+
+
+ + Ideal +
+
+ + Current +
+
+
+
+ +
+
+ ) : ( + "" + )} + {moduleDetails.total_issues > 0 && ( +
= observer((props) => { }} totalIssues={moduleDetails.total_issues} module={moduleDetails} + isPeekModuleDetails={Boolean(peekModule)} />
- - ) : ( - "" - )} + )} +
@@ -555,42 +412,83 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => {
-
-
-

Links

- -
-
- {memberRole && moduleDetails.link_module && moduleDetails.link_module.length > 0 ? ( - - ) : null} -
+
+ + {({ open }) => ( +
+
+
+ Links +
+ +
+ + +
+
+ + +
+ {memberRole && moduleDetails.link_module && moduleDetails.link_module.length > 0 ? ( + <> +
+ +
+ + + + ) : ( +
+
+ + No links added yet +
+ +
+ )} +
+
+
+
+ )} +
- - ) : ( - -
- - -
-
- - - -
-
- )} -
+
+ + ) : ( + +
+ + +
+
+ + + +
+
+ )} ); }); diff --git a/web/components/notifications/notification-card.tsx b/web/components/notifications/notification-card.tsx index 45a2ab2b2..f8a2b1700 100644 --- a/web/components/notifications/notification-card.tsx +++ b/web/components/notifications/notification-card.tsx @@ -11,7 +11,7 @@ import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui"; import { ArchiveRestore, Clock, MessageSquare, User2 } from "lucide-react"; // helper -import { stripHTML, replaceUnderscoreIfSnakeCase, truncateText } from "helpers/string.helper"; +import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "helpers/string.helper"; import { formatDateDistance, render12HourFormatTime, @@ -115,10 +115,10 @@ export const NotificationCard: React.FC = (props) => { renderShortDateWithYearFormat(notification.data.issue_activity.new_value) ) : notification.data.issue_activity.field === "attachment" ? ( "the issue" - ) : stripHTML(notification.data.issue_activity.new_value).length > 55 ? ( - stripHTML(notification.data.issue_activity.new_value).slice(0, 50) + "..." + ) : notification.data.issue_activity.field === "description" ? ( + stripAndTruncateHTML(notification.data.issue_activity.new_value, 55) ) : ( - stripHTML(notification.data.issue_activity.new_value) + notification.data.issue_activity.new_value ) ) : ( diff --git a/web/components/ui/avatar.tsx b/web/components/ui/avatar.tsx index 0eb76fe93..44997f807 100644 --- a/web/components/ui/avatar.tsx +++ b/web/components/ui/avatar.tsx @@ -76,11 +76,20 @@ export const Avatar: React.FC = ({ type AsigneesListProps = { users?: Partial | (Partial | undefined)[] | Partial[]; userIds?: string[]; + height?: string; + width?: string; length?: number; showLength?: boolean; }; -export const AssigneesList: React.FC = ({ users, userIds, length = 3, showLength = true }) => { +export const AssigneesList: React.FC = ({ + users, + userIds, + height = "24px", + width = "24px", + length = 3, + showLength = true, +}) => { const router = useRouter(); const { workspaceSlug } = router.query; @@ -101,7 +110,7 @@ export const AssigneesList: React.FC = ({ users, userIds, len {users && ( <> {users.slice(0, length).map((user, index) => ( - + ))} {users.length > length ? (
@@ -118,7 +127,7 @@ export const AssigneesList: React.FC = ({ users, userIds, len {userIds.slice(0, length).map((userId, index) => { const user = people?.find((p) => p.member.id === userId)?.member; - return ; + return ; })} {showLength ? ( userIds.length > length ? ( diff --git a/web/constants/module.ts b/web/constants/module.ts index 058171328..a49df336c 100644 --- a/web/constants/module.ts +++ b/web/constants/module.ts @@ -5,11 +5,49 @@ export const MODULE_STATUS: { label: string; value: TModuleStatus; color: string; + textColor: string; + bgColor: string; }[] = [ - { label: "Backlog", value: "backlog", color: "#a3a3a2" }, - { label: "Planned", value: "planned", color: "#3f76ff" }, - { label: "In Progress", value: "in-progress", color: "#f39e1f" }, - { label: "Paused", value: "paused", color: "#525252" }, - { label: "Completed", value: "completed", color: "#16a34a" }, - { label: "Cancelled", value: "cancelled", color: "#ef4444" }, + { + label: "Backlog", + value: "backlog", + color: "#a3a3a2", + textColor: "text-custom-text-400", + bgColor: "bg-custom-background-80", + }, + { + label: "Planned", + value: "planned", + color: "#3f76ff", + textColor: "text-blue-500", + bgColor: "bg-indigo-50", + }, + { + label: "In Progress", + value: "in-progress", + color: "#f39e1f", + textColor: "text-amber-500", + bgColor: "bg-amber-50", + }, + { + label: "Paused", + value: "paused", + color: "#525252", + textColor: "text-custom-text-300", + bgColor: "bg-custom-background-90", + }, + { + label: "Completed", + value: "completed", + color: "#16a34a", + textColor: "text-green-600", + bgColor: "bg-green-100", + }, + { + label: "Cancelled", + value: "cancelled", + color: "#ef4444", + textColor: "text-red-500", + bgColor: "bg-red-50", + }, ]; diff --git a/web/helpers/date-time.helper.ts b/web/helpers/date-time.helper.ts index 08dff4a18..dced747f9 100644 --- a/web/helpers/date-time.helper.ts +++ b/web/helpers/date-time.helper.ts @@ -172,6 +172,18 @@ export const renderShortDate = (date: string | Date, placeholder?: string) => { return isNaN(date.getTime()) ? placeholder ?? "N/A" : `${day} ${month}`; }; +export const renderShortMonthDate = (date: string | Date, placeholder?: string) => { + if (!date || date === "") return null; + + date = new Date(date); + + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const month = months[date.getMonth()]; + const year = date.getFullYear(); + + return isNaN(date.getTime()) ? placeholder ?? "N/A" : `${month} ${year}`; +}; + export const render12HourFormatTime = (date: string | Date): string => { if (!date || date === "") return ""; diff --git a/web/helpers/string.helper.ts b/web/helpers/string.helper.ts index 6596f1d69..29f414200 100644 --- a/web/helpers/string.helper.ts +++ b/web/helpers/string.helper.ts @@ -111,11 +111,20 @@ export const getFirstCharacters = (str: string) => { */ export const stripHTML = (html: string) => { - const tmp = document.createElement("DIV"); - tmp.innerHTML = html; - return tmp.textContent || tmp.innerText || ""; + const strippedText = html.replace(/]*>[\s\S]*?<\/script>/gi, ""); // Remove script tags + return strippedText.replace(/<[^>]*>/g, ""); // Remove all other HTML tags }; +/** + * + * @example: + * const html = "

Some text

"; + * const text = stripAndTruncateHTML(html); + * console.log(text); // Some text + */ + +export const stripAndTruncateHTML = (html: string, length: number = 55) => truncateText(stripHTML(html), length); + /** * @description: This function return number count in string if number is more than 100 then it will return 99+ * @param {number} number diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx index fd6d05f8e..27e6880d7 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx @@ -27,7 +27,7 @@ const ModuleIssuesPage: NextPage = () => { const { module: moduleStore } = useMobxStore(); - const { storedValue } = useLocalStorage("module_sidebar_collapsed", "false"); + const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false"); const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false; const { error } = useSWR( @@ -60,6 +60,10 @@ const ModuleIssuesPage: NextPage = () => { // setModuleIssuesListModal(true); // }; + const toggleSidebar = () => { + setValue(`${!isSidebarCollapsed}`); + }; + return ( <> } withProjectWrapper> @@ -82,10 +86,20 @@ const ModuleIssuesPage: NextPage = () => { /> ) : (
-
+
- {moduleId && } + {moduleId && !isSidebarCollapsed && ( +
+ +
+ )}
)} diff --git a/web/public/empty-state/empty_label.svg b/web/public/empty-state/empty_label.svg new file mode 100644 index 000000000..c664da6f4 --- /dev/null +++ b/web/public/empty-state/empty_label.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/public/empty-state/empty_members.svg b/web/public/empty-state/empty_members.svg new file mode 100644 index 000000000..6672c587b --- /dev/null +++ b/web/public/empty-state/empty_members.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + +