From ab3c3a6cf95a3919f7149ff2daa180268b2c311a Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Wed, 21 Feb 2024 16:56:02 +0530 Subject: [PATCH] [WEB - 466] perf: improve performance for cycle and module endpoints (#3711) * dev: improve performance for cycle apis * dev: reduce module endpoints and create a new endpoint for getting issues by list * dev: remove unwanted fields from module * dev: update module endpoints * dev: optimize cycle endpoints * change module and cycle types * dev: module optimizations * dev: fix the issues check * dev: fix issues endpoint * dev: update module detail serializer * modify adding issues to modules and cycles * dev: update cycle issues * fix module links * dev: optimize issue list endpoint * fix: removing issues from the module when removing module_id from issue peekoverview * fix: updated the tooltip and ui for cycle select (#3718) * fix: updated the tooltip and ui for module select (#3716) --------- Co-authored-by: rahulramesha Co-authored-by: gurusainath --- apiserver/plane/app/serializers/__init__.py | 1 + apiserver/plane/app/serializers/cycle.py | 104 ++-- apiserver/plane/app/serializers/module.py | 75 ++- apiserver/plane/app/urls/issue.py | 6 + apiserver/plane/app/views/__init__.py | 1 + apiserver/plane/app/views/cycle.py | 450 +++++++++++------- apiserver/plane/app/views/issue.py | 237 +++++++-- apiserver/plane/app/views/module.py | 177 +++++-- packages/types/src/cycles.d.ts | 8 +- packages/types/src/issues.d.ts | 1 - packages/types/src/modules.d.ts | 13 +- .../sidebar/sidebar-header.tsx | 3 +- .../custom-analytics/sidebar/sidebar.tsx | 31 +- web/components/core/sidebar/links-list.tsx | 135 +++--- .../cycles/active-cycle-details.tsx | 9 +- web/components/cycles/cycles-board-card.tsx | 14 +- web/components/cycles/cycles-list-item.tsx | 14 +- web/components/cycles/form.tsx | 6 +- web/components/cycles/gantt-chart/blocks.tsx | 4 +- .../cycles/gantt-chart/cycles-list-layout.tsx | 2 +- web/components/cycles/modal.tsx | 8 +- .../cycles/transfer-issues-modal.tsx | 2 +- web/components/dropdowns/cycle.tsx | 12 +- web/components/dropdowns/module.tsx | 21 +- web/components/issues/issue-detail/root.tsx | 4 +- .../issue-layouts/empty-states/cycle.tsx | 18 +- web/components/issues/peek-overview/root.tsx | 4 +- .../modules/delete-module-modal.tsx | 2 +- web/components/modules/form.tsx | 16 +- web/components/modules/gantt-chart/blocks.tsx | 8 +- .../gantt-chart/modules-list-layout.tsx | 2 +- web/components/modules/modal.tsx | 12 +- web/components/modules/module-card-item.tsx | 20 +- web/components/modules/module-list-item.tsx | 16 +- web/components/modules/sidebar.tsx | 37 +- web/services/issue/issue.service.ts | 10 + web/store/cycle.store.ts | 14 +- web/store/issue/cycle/issue.store.ts | 30 +- web/store/issue/issue-details/issue.store.ts | 8 +- web/store/issue/issue.store.ts | 19 + web/store/issue/module/issue.store.ts | 26 +- web/store/module.store.ts | 4 +- 42 files changed, 1040 insertions(+), 544 deletions(-) diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 28e881060..d8d69f26c 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -72,6 +72,7 @@ from .issue import ( ) from .module import ( + ModuleDetailSerializer, ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer, diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index 77c3f16cc..a273b349c 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -3,10 +3,7 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer -from .user import UserLiteSerializer from .issue import IssueStateSerializer -from .workspace import WorkspaceLiteSerializer -from .project import ProjectLiteSerializer from plane.db.models import ( Cycle, CycleIssue, @@ -14,7 +11,6 @@ from plane.db.models import ( CycleUserProperties, ) - class CycleWriteSerializer(BaseSerializer): def validate(self, data): if ( @@ -30,60 +26,6 @@ class CycleWriteSerializer(BaseSerializer): class Meta: model = Cycle fields = "__all__" - - -class CycleSerializer(BaseSerializer): - is_favorite = serializers.BooleanField(read_only=True) - total_issues = serializers.IntegerField(read_only=True) - cancelled_issues = serializers.IntegerField(read_only=True) - completed_issues = serializers.IntegerField(read_only=True) - started_issues = serializers.IntegerField(read_only=True) - unstarted_issues = serializers.IntegerField(read_only=True) - backlog_issues = serializers.IntegerField(read_only=True) - assignees = serializers.SerializerMethodField(read_only=True) - total_estimates = serializers.IntegerField(read_only=True) - completed_estimates = serializers.IntegerField(read_only=True) - started_estimates = serializers.IntegerField(read_only=True) - workspace_detail = WorkspaceLiteSerializer( - read_only=True, source="workspace" - ) - project_detail = ProjectLiteSerializer(read_only=True, source="project") - status = serializers.CharField(read_only=True) - - def validate(self, data): - if ( - data.get("start_date", None) is not None - and data.get("end_date", None) is not None - and data.get("start_date", None) > data.get("end_date", None) - ): - raise serializers.ValidationError( - "Start date cannot exceed end date" - ) - return data - - def get_assignees(self, obj): - members = [ - { - "avatar": assignee.avatar, - "display_name": assignee.display_name, - "id": assignee.id, - } - for issue_cycle in obj.issue_cycle.prefetch_related( - "issue__assignees" - ).all() - for assignee in issue_cycle.issue.assignees.all() - ] - # Use a set comprehension to return only the unique objects - unique_objects = {frozenset(item.items()) for item in members} - - # Convert the set back to a list of dictionaries - unique_list = [dict(item) for item in unique_objects] - - return unique_list - - class Meta: - model = Cycle - fields = "__all__" read_only_fields = [ "workspace", "project", @@ -91,6 +33,52 @@ class CycleSerializer(BaseSerializer): ] +class CycleSerializer(BaseSerializer): + # favorite + is_favorite = serializers.BooleanField(read_only=True) + total_issues = serializers.IntegerField(read_only=True) + # state group wise distribution + cancelled_issues = serializers.IntegerField(read_only=True) + completed_issues = serializers.IntegerField(read_only=True) + started_issues = serializers.IntegerField(read_only=True) + unstarted_issues = serializers.IntegerField(read_only=True) + backlog_issues = serializers.IntegerField(read_only=True) + + # active | draft | upcoming | completed + status = serializers.CharField(read_only=True) + + + class Meta: + model = Cycle + fields = [ + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "status", + ] + read_only_fields = fields + + class CycleIssueSerializer(BaseSerializer): issue_detail = IssueStateSerializer(read_only=True, source="issue") sub_issues_count = serializers.IntegerField(read_only=True) diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py index e94195671..4aabfc50e 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -5,7 +5,6 @@ from rest_framework import serializers from .base import BaseSerializer, DynamicBaseSerializer from .user import UserLiteSerializer from .project import ProjectLiteSerializer -from .workspace import WorkspaceLiteSerializer from plane.db.models import ( User, @@ -19,17 +18,18 @@ from plane.db.models import ( class ModuleWriteSerializer(BaseSerializer): - members = serializers.ListField( + lead_id = serializers.PrimaryKeyRelatedField( + source="lead", + queryset=User.objects.all(), + required=False, + allow_null=True, + ) + member_ids = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), write_only=True, required=False, ) - project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer( - source="workspace", read_only=True - ) - class Meta: model = Module fields = "__all__" @@ -44,7 +44,9 @@ class ModuleWriteSerializer(BaseSerializer): def to_representation(self, instance): data = super().to_representation(instance) - data["members"] = [str(member.id) for member in instance.members.all()] + data["member_ids"] = [ + str(member.id) for member in instance.members.all() + ] return data def validate(self, data): @@ -59,12 +61,10 @@ class ModuleWriteSerializer(BaseSerializer): return data def create(self, validated_data): - members = validated_data.pop("members", None) - + members = validated_data.pop("member_ids", None) project = self.context["project"] module = Module.objects.create(**validated_data, project=project) - if members is not None: ModuleMember.objects.bulk_create( [ @@ -85,7 +85,7 @@ class ModuleWriteSerializer(BaseSerializer): return module def update(self, instance, validated_data): - members = validated_data.pop("members", None) + members = validated_data.pop("member_ids", None) if members is not None: ModuleMember.objects.filter(module=instance).delete() @@ -142,7 +142,6 @@ class ModuleIssueSerializer(BaseSerializer): class ModuleLinkSerializer(BaseSerializer): - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") class Meta: model = ModuleLink @@ -170,12 +169,9 @@ class ModuleLinkSerializer(BaseSerializer): class ModuleSerializer(DynamicBaseSerializer): - project_detail = ProjectLiteSerializer(read_only=True, source="project") - lead_detail = UserLiteSerializer(read_only=True, source="lead") - members_detail = UserLiteSerializer( - read_only=True, many=True, source="members" + member_ids = serializers.ListField( + child=serializers.UUIDField(), required=False, allow_null=True ) - link_module = ModuleLinkSerializer(read_only=True, many=True) is_favorite = serializers.BooleanField(read_only=True) total_issues = serializers.IntegerField(read_only=True) cancelled_issues = serializers.IntegerField(read_only=True) @@ -186,15 +182,46 @@ class ModuleSerializer(DynamicBaseSerializer): class Meta: model = Module - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", + fields = [ + # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + # computed fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", "created_at", "updated_at", ] + read_only_fields = fields + + + +class ModuleDetailSerializer(ModuleSerializer): + + link_module = ModuleLinkSerializer(read_only=True, many=True) + + class Meta(ModuleSerializer.Meta): + fields = ModuleSerializer.Meta.fields + ['link_module'] class ModuleFavoriteSerializer(BaseSerializer): diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 234c2824d..e93f58db3 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -2,6 +2,7 @@ from django.urls import path from plane.app.views import ( + IssueListEndpoint, IssueViewSet, LabelViewSet, BulkCreateIssueLabelsEndpoint, @@ -25,6 +26,11 @@ from plane.app.views import ( urlpatterns = [ + path( + "workspaces//projects//issues/list/", + IssueListEndpoint.as_view(), + name="project-issue", + ), path( "workspaces//projects//issues/", IssueViewSet.as_view( diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 0a959a667..79c3f9595 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -67,6 +67,7 @@ from .cycle import ( ) from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet from .issue import ( + IssueListEndpoint, IssueViewSet, WorkSpaceIssuesEndpoint, IssueActivityEndpoint, diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 63d8d28ae..eaa290662 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -20,7 +20,10 @@ from django.core import serializers from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.core.serializers.json import DjangoJSONEncoder +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce # Third party imports from rest_framework.response import Response @@ -33,7 +36,6 @@ from plane.app.serializers import ( CycleIssueSerializer, CycleFavoriteSerializer, IssueSerializer, - IssueStateSerializer, CycleWriteSerializer, CycleUserPropertiesSerializer, ) @@ -51,7 +53,6 @@ from plane.db.models import ( IssueAttachment, Label, CycleUserProperties, - IssueSubscriber, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters @@ -73,7 +74,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) def get_queryset(self): - subquery = CycleFavorite.objects.filter( + favorite_subquery = CycleFavorite.objects.filter( user=self.request.user, cycle_id=OuterRef("pk"), project_id=self.kwargs.get("project_id"), @@ -85,10 +86,24 @@ class CycleViewSet(WebhookMixin, BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .select_related("owned_by") - .annotate(is_favorite=Exists(subquery)) + .select_related("project", "workspace", "owned_by") + .prefetch_related( + Prefetch( + "issue_cycle__issue__assignees", + queryset=User.objects.only( + "avatar", "first_name", "id" + ).distinct(), + ) + ) + .prefetch_related( + Prefetch( + "issue_cycle__issue__labels", + queryset=Label.objects.only( + "name", "color", "id" + ).distinct(), + ) + ) + .annotate(is_favorite=Exists(favorite_subquery)) .annotate( total_issues=Count( "issue_cycle", @@ -148,29 +163,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ), ) ) - .annotate( - total_estimates=Sum("issue_cycle__issue__estimate_point") - ) - .annotate( - completed_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="completed", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - started_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="started", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) .annotate( status=Case( When( @@ -190,20 +182,16 @@ class CycleViewSet(WebhookMixin, BaseViewSet): output_field=CharField(), ) ) - .prefetch_related( - Prefetch( - "issue_cycle__issue__assignees", - queryset=User.objects.only( - "avatar", "first_name", "id" - ).distinct(), - ) - ) - .prefetch_related( - Prefetch( - "issue_cycle__issue__labels", - queryset=Label.objects.only( - "name", "color", "id" - ).distinct(), + .annotate( + assignee_ids=Coalesce( + ArrayAgg( + "issue_cycle__issue__assignees__id", + distinct=True, + filter=~Q( + issue_cycle__issue__assignees__id__isnull=True + ), + ), + Value([], output_field=ArrayField(UUIDField())), ) ) .order_by("-is_favorite", "name") @@ -213,12 +201,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet): def list(self, request, slug, project_id): queryset = self.get_queryset() cycle_view = request.GET.get("cycle_view", "all") - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] + # Update the order by queryset = queryset.order_by("-is_favorite", "-created_at") # Current Cycle @@ -228,9 +212,35 @@ class CycleViewSet(WebhookMixin, BaseViewSet): end_date__gte=timezone.now(), ) - data = CycleSerializer(queryset, many=True).data + data = queryset.values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) - if len(data): + if data: assignee_distribution = ( Issue.objects.filter( issue_cycle__cycle_id=data[0]["id"], @@ -315,19 +325,45 @@ class CycleViewSet(WebhookMixin, BaseViewSet): } if data[0]["start_date"] and data[0]["end_date"]: - data[0]["distribution"][ - "completion_chart" - ] = burndown_plot( - queryset=queryset.first(), - slug=slug, - project_id=project_id, - cycle_id=data[0]["id"], + data[0]["distribution"]["completion_chart"] = ( + burndown_plot( + queryset=queryset.first(), + slug=slug, + project_id=project_id, + cycle_id=data[0]["id"], + ) ) return Response(data, status=status.HTTP_200_OK) - cycles = CycleSerializer(queryset, many=True).data - return Response(cycles, status=status.HTTP_200_OK) + data = queryset.values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) + return Response(data, status=status.HTTP_200_OK) def create(self, request, slug, project_id): if ( @@ -337,7 +373,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None ): - serializer = CycleSerializer(data=request.data) + serializer = CycleWriteSerializer(data=request.data) if serializer.is_valid(): serializer.save( project_id=project_id, @@ -346,12 +382,36 @@ class CycleViewSet(WebhookMixin, BaseViewSet): cycle = ( self.get_queryset() .filter(pk=serializer.data["id"]) + .values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) .first() ) - serializer = CycleSerializer(cycle) - return Response( - serializer.data, status=status.HTTP_201_CREATED - ) + return Response(cycle, status=status.HTTP_201_CREATED) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST ) @@ -364,10 +424,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) def partial_update(self, request, slug, project_id, pk): - cycle = Cycle.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk + queryset = ( + self.get_queryset() + .filter(workspace__slug=slug, project_id=project_id, pk=pk) ) - + cycle = queryset.first() request_data = request.data if ( @@ -375,7 +436,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): and cycle.end_date < timezone.now().date() ): if "sort_order" in request_data: - # Can only change sort order + # Can only change sort order for a completed cycle`` request_data = { "sort_order": request_data.get( "sort_order", cycle.sort_order @@ -394,12 +455,71 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) if serializer.is_valid(): serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) + cycle = queryset.values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ).first() + return Response(cycle, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, slug, project_id, pk): - queryset = self.get_queryset().get(pk=pk) - + queryset = self.get_queryset().filter(pk=pk) + data = ( + self.get_queryset() + .filter(pk=pk) + .values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) + .first() + ) + queryset = queryset.first() # Assignee Distribution assignee_distribution = ( Issue.objects.filter( @@ -488,7 +608,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet): .order_by("label_name") ) - data = CycleSerializer(queryset).data data["distribution"] = { "assignees": assignee_distribution, "labels": label_distribution, @@ -591,20 +710,18 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): filters = issue_filters(request.query_params, "GET") issues = ( Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) - .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) .filter(project_id=project_id) .filter(workspace__slug=slug) .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels", "issue_module__module") + .prefetch_related( + "assignees", + "labels", + "issue_module__module", + "issue_cycle__cycle", + ) .order_by(order_by) .filter(**filters) + .annotate(module_ids=F("issue_module__module_id")) .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) @@ -621,11 +738,12 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): .values("count") ) .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - subscriber=self.request.user, issue_id=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") ) ) serializer = IssueSerializer( @@ -636,7 +754,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): def create(self, request, slug, project_id, cycle_id): issues = request.data.get("issues", []) - if not len(issues): + if not issues: return Response( {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST, @@ -658,52 +776,52 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): ) # Get all CycleIssues already created - cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues)) - update_cycle_issue_activity = [] - record_to_create = [] - records_to_update = [] + cycle_issues = list( + CycleIssue.objects.filter( + ~Q(cycle_id=cycle_id), issue_id__in=issues + ) + ) + existing_issues = [ + str(cycle_issue.issue_id) for cycle_issue in cycle_issues + ] + new_issues = list(set(issues) - set(existing_issues)) - for issue in issues: - cycle_issue = [ - cycle_issue - for cycle_issue in cycle_issues - if str(cycle_issue.issue_id) in issues - ] - # Update only when cycle changes - if len(cycle_issue): - if cycle_issue[0].cycle_id != cycle_id: - update_cycle_issue_activity.append( - { - "old_cycle_id": str(cycle_issue[0].cycle_id), - "new_cycle_id": str(cycle_id), - "issue_id": str(cycle_issue[0].issue_id), - } - ) - cycle_issue[0].cycle_id = cycle_id - records_to_update.append(cycle_issue[0]) - else: - record_to_create.append( - CycleIssue( - project_id=project_id, - workspace=cycle.workspace, - created_by=request.user, - updated_by=request.user, - cycle=cycle, - issue_id=issue, - ) + # New issues to create + created_records = CycleIssue.objects.bulk_create( + [ + CycleIssue( + project_id=project_id, + workspace_id=cycle.workspace_id, + created_by_id=request.user.id, + updated_by_id=request.user.id, + cycle_id=cycle_id, + issue_id=issue, ) - - CycleIssue.objects.bulk_create( - record_to_create, - batch_size=10, - ignore_conflicts=True, - ) - CycleIssue.objects.bulk_update( - records_to_update, - ["cycle"], + for issue in new_issues + ], batch_size=10, ) + # Updated Issues + updated_records = [] + update_cycle_issue_activity = [] + # Iterate over each cycle_issue in cycle_issues + for cycle_issue in cycle_issues: + # Update the cycle_issue's cycle_id + cycle_issue.cycle_id = cycle_id + # Add the modified cycle_issue to the records_to_update list + updated_records.append(cycle_issue) + # Record the update activity + update_cycle_issue_activity.append( + { + "old_cycle_id": str(cycle_issue.cycle_id), + "new_cycle_id": str(cycle_id), + "issue_id": str(cycle_issue.issue_id), + } + ) + + # Update the cycle issues + CycleIssue.objects.bulk_update(updated_records, ["cycle_id"], batch_size=100) # Capture Issue Activity issue_activity.delay( type="cycle.activity.created", @@ -715,7 +833,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): { "updated_cycle_issues": update_cycle_issue_activity, "created_cycle_issues": serializers.serialize( - "json", record_to_create + "json", created_records ), } ), @@ -723,16 +841,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): notification=True, origin=request.META.get("HTTP_ORIGIN"), ) - - # Return all Cycle Issues - issues = self.get_queryset().values_list("issue_id", flat=True) - - return Response( - IssueSerializer( - Issue.objects.filter(pk__in=issues), many=True - ).data, - status=status.HTTP_200_OK, - ) + return Response({"message": "success"}, status=status.HTTP_201_CREATED) def destroy(self, request, slug, project_id, cycle_id, issue_id): cycle_issue = CycleIssue.objects.get( @@ -776,6 +885,7 @@ class CycleDateCheckEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + # Check if any cycle intersects in the given interval cycles = Cycle.objects.filter( Q(workspace__slug=slug) & Q(project_id=project_id) @@ -785,7 +895,6 @@ class CycleDateCheckEndpoint(BaseAPIView): | Q(start_date__gte=start_date, end_date__lte=end_date) ) ).exclude(pk=cycle_id) - if cycles.exists(): return Response( { @@ -909,29 +1018,6 @@ class TransferCycleIssueEndpoint(BaseAPIView): ), ) ) - .annotate( - total_estimates=Sum("issue_cycle__issue__estimate_point") - ) - .annotate( - completed_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="completed", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - started_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="started", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) ) # Pass the new_cycle queryset to burndown_plot @@ -942,6 +1028,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): cycle_id=cycle_id, ) + # Get the assignee distribution assignee_distribution = ( Issue.objects.filter( issue_cycle__cycle_id=cycle_id, @@ -980,7 +1067,22 @@ class TransferCycleIssueEndpoint(BaseAPIView): ) .order_by("display_name") ) + # assignee distribution serialized + assignee_distribution_data = [ + { + "display_name": item["display_name"], + "assignee_id": ( + str(item["assignee_id"]) if item["assignee_id"] else None + ), + "avatar": item["avatar"], + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in assignee_distribution + ] + # Get the label distribution label_distribution = ( Issue.objects.filter( issue_cycle__cycle_id=cycle_id, @@ -1019,24 +1121,14 @@ class TransferCycleIssueEndpoint(BaseAPIView): ) .order_by("label_name") ) - - assignee_distribution_data = [ - { - "display_name": item["display_name"], - "assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None, - "avatar": item["avatar"], - "total_issues": item["total_issues"], - "completed_issues": item["completed_issues"], - "pending_issues": item["pending_issues"], - } - for item in assignee_distribution - ] - + # Label distribution serilization label_distribution_data = [ { "label_name": item["label_name"], "color": item["color"], - "label_id": str(item["label_id"]) if item["label_id"] else None, + "label_id": ( + str(item["label_id"]) if item["label_id"] else None + ), "total_issues": item["total_issues"], "completed_issues": item["completed_issues"], "pending_issues": item["pending_issues"], @@ -1058,7 +1150,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): "total_estimates": old_cycle.first().total_estimates, "completed_estimates": old_cycle.first().completed_estimates, "started_estimates": old_cycle.first().started_estimates, - "distribution":{ + "distribution": { "labels": label_distribution_data, "assignees": assignee_distribution_data, "completion_chart": completion_chart, diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index edefade16..ec87c5b31 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -4,7 +4,6 @@ import random from itertools import chain # Django imports -from django.db import models from django.utils import timezone from django.db.models import ( Prefetch, @@ -12,19 +11,21 @@ from django.db.models import ( Func, F, Q, - Count, Case, Value, CharField, When, Exists, Max, - IntegerField, ) from django.core.serializers.json import DjangoJSONEncoder from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page from django.db import IntegrityError +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce # Third Party imports from rest_framework.response import Response @@ -67,15 +68,11 @@ from plane.db.models import ( Label, IssueLink, IssueAttachment, - State, IssueSubscriber, ProjectMember, IssueReaction, CommentReaction, - ProjectDeployBoard, - IssueVote, IssueRelation, - ProjectPublicMember, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -83,6 +80,192 @@ from plane.utils.issue_filters import issue_filters from collections import defaultdict +class IssueListEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def get(self, request, slug, project_id): + issue_ids = request.GET.get("issues", False) + + if not issue_ids: + return Response( + {"error": "Issues are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + issue_ids = [issue_id for issue_id in issue_ids.split(",") if issue_id != ""] + + queryset = ( + Issue.issue_objects.filter( + workspace__slug=slug, project_id=project_id, pk__in=issue_ids + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + ).distinct() + + filters = issue_filters(request.query_params, "GET") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = queryset.filter(**filters) + + # 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] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" + if order_by_param.startswith("-") + else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + if self.fields or self.expand: + issues = IssueSerializer( + queryset, many=True, fields=self.fields, expand=self.expand + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + return Response(issues, status=status.HTTP_200_OK) + + class IssueViewSet(WebhookMixin, BaseViewSet): def get_serializer_class(self): return ( @@ -1085,7 +1268,7 @@ class IssueArchiveViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -1132,10 +1315,7 @@ class IssueArchiveViewSet(BaseViewSet): order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = ( - self.get_queryset() - .filter(**filters) - ) + issue_queryset = self.get_queryset().filter(**filters) # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": @@ -1580,15 +1760,17 @@ class IssueRelationViewSet(BaseViewSet): issue_relation = IssueRelation.objects.bulk_create( [ IssueRelation( - issue_id=issue - if relation_type == "blocking" - else issue_id, - related_issue_id=issue_id - if relation_type == "blocking" - else issue, - relation_type="blocked_by" - if relation_type == "blocking" - else relation_type, + issue_id=( + issue if relation_type == "blocking" else issue_id + ), + related_issue_id=( + issue_id if relation_type == "blocking" else issue + ), + relation_type=( + "blocked_by" + if relation_type == "blocking" + else relation_type + ), project_id=project_id, workspace_id=project.workspace_id, created_by=request.user, @@ -1669,9 +1851,7 @@ class IssueDraftViewSet(BaseViewSet): def get_queryset(self): return ( - Issue.objects.filter( - project_id=self.kwargs.get("project_id") - ) + Issue.objects.filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) .filter(is_draft=True) .select_related("workspace", "project", "state", "parent") @@ -1728,10 +1908,7 @@ class IssueDraftViewSet(BaseViewSet): order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = ( - self.get_queryset() - .filter(**filters) - ) + issue_queryset = self.get_queryset().filter(**filters) # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": @@ -1830,7 +2007,9 @@ class IssueDraftViewSet(BaseViewSet): issue = ( self.get_queryset().filter(pk=serializer.data["id"]).first() ) - return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED) + return Response( + IssueSerializer(issue).data, status=status.HTTP_201_CREATED + ) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, slug, project_id, pk): diff --git a/apiserver/plane/app/views/module.py b/apiserver/plane/app/views/module.py index 4792a1f79..41442579f 100644 --- a/apiserver/plane/app/views/module.py +++ b/apiserver/plane/app/views/module.py @@ -4,11 +4,12 @@ import json # Django Imports from django.utils import timezone from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q -from django.core import serializers from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.core.serializers.json import DjangoJSONEncoder - +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce # Third party imports from rest_framework.response import Response @@ -24,6 +25,7 @@ from plane.app.serializers import ( ModuleFavoriteSerializer, IssueSerializer, ModuleUserPropertiesSerializer, + ModuleDetailSerializer, ) from plane.app.permissions import ( ProjectEntityPermission, @@ -38,11 +40,9 @@ from plane.db.models import ( ModuleFavorite, IssueLink, IssueAttachment, - IssueSubscriber, ModuleUserProperties, ) from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.grouper import group_results from plane.utils.issue_filters import issue_filters from plane.utils.analytics_plot import burndown_plot @@ -62,7 +62,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ) def get_queryset(self): - subquery = ModuleFavorite.objects.filter( + favorite_subquery = ModuleFavorite.objects.filter( user=self.request.user, module_id=OuterRef("pk"), project_id=self.kwargs.get("project_id"), @@ -73,7 +73,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): .get_queryset() .filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) - .annotate(is_favorite=Exists(subquery)) + .annotate(is_favorite=Exists(favorite_subquery)) .select_related("project") .select_related("workspace") .select_related("lead") @@ -145,6 +145,16 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ), ) ) + .annotate( + member_ids=Coalesce( + ArrayAgg( + "members__id", + distinct=True, + filter=~Q(members__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) .order_by("-is_favorite", "-created_at") ) @@ -157,25 +167,84 @@ class ModuleViewSet(WebhookMixin, 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) + module = ( + self.get_queryset() + .filter(pk=serializer.data["id"]) + .values( # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + # computed fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "created_at", + "updated_at", + ) + ).first() + return Response(module, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def list(self, request, slug, project_id): queryset = self.get_queryset() - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] - modules = ModuleSerializer( - queryset, many=True, fields=fields if fields else None - ).data + if self.fields: + modules = ModuleSerializer( + queryset, + many=True, + fields=self.fields, + ).data + else: + modules = queryset.values( # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + # computed fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "created_at", + "updated_at", + ) return Response(modules, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, pk): - queryset = self.get_queryset().get(pk=pk) + queryset = self.get_queryset().filter(pk=pk) assignee_distribution = ( Issue.objects.filter( @@ -269,16 +338,16 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): .order_by("label_name") ) - data = ModuleSerializer(queryset).data + data = ModuleDetailSerializer(queryset.first()).data data["distribution"] = { "assignees": assignee_distribution, "labels": label_distribution, "completion_chart": {}, } - if queryset.start_date and queryset.target_date: + if queryset.first().start_date and queryset.first().target_date: data["distribution"]["completion_chart"] = burndown_plot( - queryset=queryset, + queryset=queryset.first(), slug=slug, project_id=project_id, module_id=pk, @@ -289,6 +358,47 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): status=status.HTTP_200_OK, ) + def partial_update(self, request, slug, project_id, pk): + queryset = self.get_queryset().filter(pk=pk) + serializer = ModuleWriteSerializer( + queryset.first(), data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + module = queryset.values( + # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + # computed fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "created_at", + "updated_at", + ).first() + return Response(module, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def destroy(self, request, slug, project_id, pk): module = Module.objects.get( workspace__slug=slug, project_id=project_id, pk=pk @@ -331,17 +441,16 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): ProjectEntityPermission, ] - def get_queryset(self): return ( Issue.issue_objects.filter( project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), - issue_module__module_id=self.kwargs.get("module_id") + issue_module__module_id=self.kwargs.get("module_id"), ) .select_related("workspace", "project", "state", "parent") .prefetch_related("labels", "assignees") - .prefetch_related('issue_module__module') + .prefetch_related("issue_module__module") .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) @@ -384,7 +493,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): # create multiple issues inside a module def create_module_issues(self, request, slug, project_id, module_id): issues = request.data.get("issues", []) - if not len(issues): + if not issues: return Response( {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST, @@ -420,15 +529,12 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): ) for issue in issues ] - issues = (self.get_queryset().filter(pk__in=issues)) - serializer = IssueSerializer(issues , many=True) - return Response(serializer.data, status=status.HTTP_201_CREATED) - + return Response({"message": "success"}, status=status.HTTP_201_CREATED) # create multiple module inside an issue def create_issue_modules(self, request, slug, project_id, issue_id): modules = request.data.get("modules", []) - if not len(modules): + if not modules: return Response( {"error": "Modules are required"}, status=status.HTTP_400_BAD_REQUEST, @@ -466,10 +572,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): for module in modules ] - issue = (self.get_queryset().filter(pk=issue_id).first()) - serializer = IssueSerializer(issue) - return Response(serializer.data, status=status.HTTP_201_CREATED) - + return Response({"message": "success"}, status=status.HTTP_201_CREATED) def destroy(self, request, slug, project_id, module_id, issue_id): module_issue = ModuleIssue.objects.get( @@ -484,7 +587,9 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_id), - current_instance=json.dumps({"module_name": module_issue.module.name}), + current_instance=json.dumps( + {"module_name": module_issue.module.name} + ), epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), diff --git a/packages/types/src/cycles.d.ts b/packages/types/src/cycles.d.ts index 5d715385a..0e4890b7f 100644 --- a/packages/types/src/cycles.d.ts +++ b/packages/types/src/cycles.d.ts @@ -32,8 +32,7 @@ export interface ICycle { name: string; owned_by: string; progress_snapshot: TProgressSnapshot; - project: string; - project_detail: IProjectLite; + project_id: string; status: TCycleGroups; sort_order: number; start_date: string | null; @@ -42,12 +41,11 @@ export interface ICycle { unstarted_issues: number; updated_at: Date; updated_by: string; - assignees: IUserLite[]; + assignee_ids: string[]; view_props: { filters: IIssueFilterOptions; }; - workspace: string; - workspace_detail: IWorkspaceLite; + workspace_id: string; } export type TProgressSnapshot = { diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index 1f4a35dd4..754d8df8f 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -58,7 +58,6 @@ export interface IIssueLink { export interface ILinkDetails { created_at: Date; created_by: string; - created_by_detail: IUserLite; id: string; metadata: any; title: string; diff --git a/packages/types/src/modules.d.ts b/packages/types/src/modules.d.ts index 0e49da7fe..fcf2d86a2 100644 --- a/packages/types/src/modules.d.ts +++ b/packages/types/src/modules.d.ts @@ -27,16 +27,12 @@ export interface IModule { labels: TLabelsDistribution[]; }; id: string; - lead: string | null; - lead_detail: IUserLite | null; + lead_id: string | null; link_module: ILinkDetails[]; - links_list: ModuleLink[]; - members: string[]; - members_detail: IUserLite[]; + member_ids: string[]; is_favorite: boolean; name: string; - project: string; - project_detail: IProjectLite; + project_id: string; sort_order: number; start_date: string | null; started_issues: number; @@ -49,8 +45,7 @@ export interface IModule { view_props: { filters: IIssueFilterOptions; }; - workspace: string; - workspace_detail: IWorkspaceLite; + workspace_id: string; } export interface ModuleIssueResponse { diff --git a/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx index ee677fe91..c2644abe0 100644 --- a/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx +++ b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx @@ -21,6 +21,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => { const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined; const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined; + const moduleLeadDetails = moduleDetails && moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined; return ( <> @@ -57,7 +58,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
Lead
- {moduleDetails.lead_detail?.display_name} + {moduleLeadDetails && {moduleLeadDetails?.display_name}}
Start Date
diff --git a/web/components/analytics/custom-analytics/sidebar/sidebar.tsx b/web/components/analytics/custom-analytics/sidebar/sidebar.tsx index c2e12dc3c..3ad2805f2 100644 --- a/web/components/analytics/custom-analytics/sidebar/sidebar.tsx +++ b/web/components/analytics/custom-analytics/sidebar/sidebar.tsx @@ -5,7 +5,7 @@ import { mutate } from "swr"; // services import { AnalyticsService } from "services/analytics.service"; // hooks -import { useCycle, useModule, useProject, useUser } from "hooks/store"; +import { useCycle, useModule, useProject, useUser, useWorkspace } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { CustomAnalyticsSidebarHeader, CustomAnalyticsSidebarProjectsList } from "components/analytics"; @@ -39,6 +39,8 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { // store hooks const { currentUser } = useUser(); const { workspaceProjectIds, getProjectById } = useProject(); + const { getWorkspaceById } = useWorkspace(); + const { fetchCycleDetails, getCycleById } = useCycle(); const { fetchModuleDetails, getModuleById } = useModule(); @@ -70,11 +72,14 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { if (cycleDetails || moduleDetails) { const details = cycleDetails || moduleDetails; - eventPayload.workspaceId = details?.workspace_detail?.id; - eventPayload.workspaceName = details?.workspace_detail?.name; - eventPayload.projectId = details?.project_detail.id; - eventPayload.projectIdentifier = details?.project_detail.identifier; - eventPayload.projectName = details?.project_detail.name; + const currentProjectDetails = getProjectById(details?.project_id || ""); + const currentWorkspaceDetails = getWorkspaceById(details?.workspace_id || ""); + + eventPayload.workspaceId = details?.workspace_id; + eventPayload.workspaceName = currentWorkspaceDetails?.name; + eventPayload.projectId = details?.project_id; + eventPayload.projectIdentifier = currentProjectDetails?.identifier; + eventPayload.projectName = currentProjectDetails?.name; } if (cycleDetails) { @@ -138,14 +143,18 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds; - return ( -
- {analytics ? analytics.total : "..."}
Issues
+ {analytics ? analytics.total : "..."} +
Issues
{isProjectLevel && (
@@ -154,8 +163,8 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { (cycleId ? cycleDetails?.created_at : moduleId - ? moduleDetails?.created_at - : projectDetails?.created_at) ?? "" + ? moduleDetails?.created_at + : projectDetails?.created_at) ?? "" )}
)} diff --git a/web/components/core/sidebar/links-list.tsx b/web/components/core/sidebar/links-list.tsx index 52b1e9de1..48a5e16b7 100644 --- a/web/components/core/sidebar/links-list.tsx +++ b/web/components/core/sidebar/links-list.tsx @@ -8,6 +8,9 @@ import { calculateTimeAgo } from "helpers/date-time.helper"; import { ILinkDetails, UserAuth } from "@plane/types"; // hooks import useToast from "hooks/use-toast"; +import { observer } from "mobx-react"; +import { useMeasure } from "@nivo/core"; +import { useMember } from "hooks/store"; type Props = { links: ILinkDetails[]; @@ -16,9 +19,10 @@ type Props = { userAuth: UserAuth; }; -export const LinksList: React.FC = ({ links, handleDeleteLink, handleEditLink, userAuth }) => { +export const LinksList: React.FC = observer(({ links, handleDeleteLink, handleEditLink, userAuth }) => { // toast const { setToastAlert } = useToast(); + const { getUserDetails } = useMember(); const isNotAllowed = userAuth.isGuest || userAuth.isViewer; @@ -33,70 +37,75 @@ export const LinksList: React.FC = ({ links, handleDeleteLink, handleEdit return ( <> - {links.map((link) => ( -
-
-
- - - - - copyToClipboard(link.title && link.title !== "" ? link.title : link.url)} - > - {link.title && link.title !== "" ? link.title : link.url} + {links.map((link) => { + const createdByDetails = getUserDetails(link.created_by); + return ( +
+
+
+ + - -
- - {!isNotAllowed && ( -
- - - - - + + copyToClipboard(link.title && link.title !== "" ? link.title : link.url)} + > + {link.title && link.title !== "" ? link.title : link.url} + +
- )} + + {!isNotAllowed && ( +
+ + + + + +
+ )} +
+
+

+ Added {calculateTimeAgo(link.created_at)} +
+ {createdByDetails && ( + <> + by{" "} + {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name} + + )} +

+
-
-

- Added {calculateTimeAgo(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/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle-details.tsx index 5d4bcd768..7e885635f 100644 --- a/web/components/cycles/active-cycle-details.tsx +++ b/web/components/cycles/active-cycle-details.tsx @@ -222,12 +222,13 @@ export const ActiveCycleDetails: React.FC = observer((props {cycleOwnerDetails?.display_name}
- {activeCycle.assignees.length > 0 && ( + {activeCycle.assignee_ids.length > 0 && (
- {activeCycle.assignees.map((assignee) => ( - - ))} + {activeCycle.assignee_ids.map((assigne_id) => { + const member = getUserDetails(assigne_id); + return ; + })}
)} diff --git a/web/components/cycles/cycles-board-card.tsx b/web/components/cycles/cycles-board-card.tsx index e96b01858..7d6b1e000 100644 --- a/web/components/cycles/cycles-board-card.tsx +++ b/web/components/cycles/cycles-board-card.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import Link from "next/link"; import { observer } from "mobx-react"; // hooks -import { useEventTracker, useCycle, useUser } from "hooks/store"; +import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; @@ -40,6 +40,7 @@ export const CyclesBoardCard: FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { addCycleToFavorites, removeCycleFromFavorites, getCycleById } = useCycle(); + const { getUserDetails } = useMember(); // toast alert const { setToastAlert } = useToast(); // computed @@ -212,13 +213,14 @@ export const CyclesBoardCard: FC = observer((props) => { {issueCount}
- {cycleDetails.assignees.length > 0 && ( - + {cycleDetails.assignee_ids.length > 0 && ( +
- {cycleDetails.assignees.map((assignee) => ( - - ))} + {cycleDetails.assignee_ids.map((assigne_id) => { + const member = getUserDetails(assigne_id); + return ; + })}
diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/cycles-list-item.tsx index ed2b26c53..31958cd84 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/cycles-list-item.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react"; // hooks -import { useEventTracker, useCycle, useUser } from "hooks/store"; +import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; @@ -44,6 +44,7 @@ export const CyclesListItem: FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle(); + const { getUserDetails } = useMember(); // toast alert const { setToastAlert } = useToast(); @@ -230,13 +231,14 @@ export const CyclesListItem: FC = observer((props) => {
- +
- {cycleDetails.assignees.length > 0 ? ( + {cycleDetails.assignee_ids?.length > 0 ? ( - {cycleDetails.assignees.map((assignee) => ( - - ))} + {cycleDetails.assignee_ids?.map((assigne_id) => { + const member = getUserDetails(assigne_id); + return ; + })} ) : ( diff --git a/web/components/cycles/form.tsx b/web/components/cycles/form.tsx index dfe2a878e..46aefcb0b 100644 --- a/web/components/cycles/form.tsx +++ b/web/components/cycles/form.tsx @@ -36,7 +36,7 @@ export const CycleForm: React.FC = (props) => { reset, } = useForm({ defaultValues: { - project: projectId, + project_id: projectId, name: data?.name || "", description: data?.description || "", start_date: data?.start_date || null, @@ -61,13 +61,13 @@ export const CycleForm: React.FC = (props) => { maxDate?.setDate(maxDate.getDate() - 1); return ( -
handleFormSubmit(formData,dirtyFields))}> + handleFormSubmit(formData, dirtyFields))}>
{!status && ( ( = observer((props) => { ? "rgb(var(--color-text-200))" : "", }} - onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)} + onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`)} >
= observer((props) => { return (
router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)} + onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`)} > = observer((props) => { const payload: any = { ...data }; if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder; - await updateCycleDetails(workspaceSlug.toString(), cycle.project, cycle.id, payload); + await updateCycleDetails(workspaceSlug.toString(), cycle.project_id, cycle.id, payload); }; const blockFormat = (blocks: (ICycle | null)[]) => { diff --git a/web/components/cycles/modal.tsx b/web/components/cycles/modal.tsx index e8f19d6a1..b22afb2b4 100644 --- a/web/components/cycles/modal.tsx +++ b/web/components/cycles/modal.tsx @@ -40,7 +40,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { const handleCreateCycle = async (payload: Partial) => { if (!workspaceSlug || !projectId) return; - const selectedProjectId = payload.project ?? projectId.toString(); + const selectedProjectId = payload.project_id ?? projectId.toString(); await createCycle(workspaceSlug, selectedProjectId, payload) .then((res) => { setToastAlert({ @@ -69,7 +69,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { const handleUpdateCycle = async (cycleId: string, payload: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId) return; - const selectedProjectId = payload.project ?? projectId.toString(); + const selectedProjectId = payload.project_id ?? projectId.toString(); await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload) .then((res) => { const changed_properties = Object.keys(dirtyFields); @@ -155,8 +155,8 @@ export const CycleCreateUpdateModal: React.FC = (props) => { // if data is present, set active project to the project of the // issue. This has more priority than the project in the url. - if (data && data.project) { - setActiveProject(data.project); + if (data && data.project_id) { + setActiveProject(data.project_id); return; } diff --git a/web/components/cycles/transfer-issues-modal.tsx b/web/components/cycles/transfer-issues-modal.tsx index 5956e4a1e..adff19545 100644 --- a/web/components/cycles/transfer-issues-modal.tsx +++ b/web/components/cycles/transfer-issues-modal.tsx @@ -56,7 +56,7 @@ export const TransferIssuesModal: React.FC = observer((props) => { const filteredOptions = currentProjectIncompleteCycleIds?.filter((optionId) => { const cycleDetails = getCycleById(optionId); - return cycleDetails?.name.toLowerCase().includes(query.toLowerCase()); + return cycleDetails?.name?.toLowerCase().includes(query?.toLowerCase()); }); // useEffect(() => { diff --git a/web/components/dropdowns/cycle.tsx b/web/components/dropdowns/cycle.tsx index 5086d2d26..ddcabb3c9 100644 --- a/web/components/dropdowns/cycle.tsx +++ b/web/components/dropdowns/cycle.tsx @@ -10,11 +10,12 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { DropdownButton } from "./buttons"; // icons -import { ContrastIcon } from "@plane/ui"; +import { ContrastIcon, CycleGroupIcon } from "@plane/ui"; // helpers import { cn } from "helpers/common.helper"; // types import { TDropdownProps } from "./types"; +import { TCycleGroups } from "@plane/types"; // constants import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; @@ -82,17 +83,22 @@ export const CycleDropdown: React.FC = observer((props) => { router: { workspaceSlug }, } = useApplication(); const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle(); - const cycleIds = getProjectCycleIds(projectId); + + const cycleIds = (getProjectCycleIds(projectId) ?? [])?.filter((cycleId) => { + const cycleDetails = getCycleById(cycleId); + return cycleDetails?.status.toLowerCase() != "completed" ? true : false; + }); const options: DropdownOptions = cycleIds?.map((cycleId) => { const cycleDetails = getCycleById(cycleId); + const cycleStatus = cycleDetails?.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; return { value: cycleId, query: `${cycleDetails?.name}`, content: (
- + {cycleDetails?.name}
), diff --git a/web/components/dropdowns/module.tsx b/web/components/dropdowns/module.tsx index c05eeb97e..57b02ee56 100644 --- a/web/components/dropdowns/module.tsx +++ b/web/components/dropdowns/module.tsx @@ -77,12 +77,16 @@ const ButtonContent: React.FC = (props) => { return ( <> {showCount ? ( - <> +
{!hideIcon && } - - {value.length > 0 ? `${value.length} Module${value.length === 1 ? "" : "s"}` : placeholder} - - +
+ {value.length > 0 + ? value.length === 1 + ? `${getModuleById(value[0])?.name || "module"}` + : `${value.length} Module${value.length === 1 ? "" : "s"}` + : placeholder} +
+
) : value.length > 0 ? (
{value.map((moduleId) => { @@ -298,7 +302,12 @@ export const ModuleDropdown: React.FC = observer((props) => { isActive={isOpen} tooltipHeading="Module" tooltipContent={ - Array.isArray(value) ? `${value?.length ?? 0} module${value?.length !== 1 ? "s" : ""}` : "" + Array.isArray(value) + ? `${value + .map((moduleId) => getModuleById(moduleId)?.name) + .toString() + .replaceAll(",", ", ")}` + : "" } showTooltip={showTooltip} variant={buttonVariant} diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx index 1fab25d96..e0d54e1ea 100644 --- a/web/components/issues/issue-detail/root.tsx +++ b/web/components/issues/issue-detail/root.tsx @@ -160,7 +160,7 @@ export const IssueDetailRoot: FC = observer((props) => { }, addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { try { - const response = await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); + await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); setToastAlert({ title: "Cycle added to issue successfully", type: "success", @@ -168,7 +168,7 @@ export const IssueDetailRoot: FC = observer((props) => { }); captureIssueEvent({ eventName: ISSUE_UPDATED, - payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, + payload: { ...issueIds, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "cycle_id", change_details: cycleId, diff --git a/web/components/issues/issue-layouts/empty-states/cycle.tsx b/web/components/issues/issue-layouts/empty-states/cycle.tsx index 69c2279dd..4b7676173 100644 --- a/web/components/issues/issue-layouts/empty-states/cycle.tsx +++ b/web/components/issues/issue-layouts/empty-states/cycle.tsx @@ -60,19 +60,13 @@ export const CycleEmptyState: React.FC = observer((props) => { const issueIds = data.map((i) => i.id); - await issues - .addIssueToCycle(workspaceSlug.toString(), projectId, cycleId.toString(), issueIds) - .then((res) => { - updateIssue(workspaceSlug, projectId, res.id, res); - fetchIssue(workspaceSlug, projectId, res.id); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Selected issues could not be added to the cycle. Please try again.", - }); + await issues.addIssueToCycle(workspaceSlug.toString(), projectId, cycleId.toString(), issueIds).catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Selected issues could not be added to the cycle. Please try again.", }); + }); }; const emptyStateDetail = CYCLE_EMPTY_STATE_DETAILS["no-issues"]; diff --git a/web/components/issues/peek-overview/root.tsx b/web/components/issues/peek-overview/root.tsx index c49c0a503..564b5a019 100644 --- a/web/components/issues/peek-overview/root.tsx +++ b/web/components/issues/peek-overview/root.tsx @@ -146,7 +146,7 @@ export const IssuePeekOverview: FC = observer((props) => { }, addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { try { - const response = await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); + await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); setToastAlert({ title: "Cycle added to issue successfully", type: "success", @@ -154,7 +154,7 @@ export const IssuePeekOverview: FC = observer((props) => { }); captureIssueEvent({ eventName: ISSUE_UPDATED, - payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, + payload: { ...issueIds, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: "cycle_id", change_details: cycleId, diff --git a/web/components/modules/delete-module-modal.tsx b/web/components/modules/delete-module-modal.tsx index 636a828ae..bf2e529b7 100644 --- a/web/components/modules/delete-module-modal.tsx +++ b/web/components/modules/delete-module-modal.tsx @@ -45,7 +45,7 @@ export const DeleteModuleModal: React.FC = observer((props) => { await deleteModule(workspaceSlug.toString(), projectId.toString(), data.id) .then(() => { - if (moduleId || peekModule) router.push(`/${workspaceSlug}/projects/${data.project}/modules`); + if (moduleId || peekModule) router.push(`/${workspaceSlug}/projects/${data.project_id}/modules`); handleClose(); setToastAlert({ type: "success", diff --git a/web/components/modules/form.tsx b/web/components/modules/form.tsx index 8fa63e826..a6f04ec54 100644 --- a/web/components/modules/form.tsx +++ b/web/components/modules/form.tsx @@ -23,8 +23,8 @@ const defaultValues: Partial = { name: "", description: "", status: "backlog", - lead: null, - members: [], + lead_id: null, + member_ids: [], }; export const ModuleForm: React.FC = ({ @@ -43,12 +43,12 @@ export const ModuleForm: React.FC = ({ reset, } = useForm({ defaultValues: { - project: projectId, + project_id: projectId, name: data?.name || "", description: data?.description || "", status: data?.status || "backlog", - lead: data?.lead || null, - members: data?.members || [], + lead_id: data?.lead_id || null, + member_ids: data?.member_ids || [], }, }); @@ -83,7 +83,7 @@ export const ModuleForm: React.FC = ({ {!status && ( (
= ({ (
= ({ /> (
= observer((props) => {
s.value === moduleDetails?.status)?.color }} - onClick={() => router.push(`/${workspaceSlug}/projects/${moduleDetails?.project}/modules/${moduleDetails?.id}`)} + onClick={() => + router.push(`/${workspaceSlug}/projects/${moduleDetails?.project_id}/modules/${moduleDetails?.id}`) + } >
= observer((props) => { return (
router.push(`/${workspaceSlug}/projects/${moduleDetails?.project}/modules/${moduleDetails?.id}`)} + onClick={() => + router.push(`/${workspaceSlug}/projects/${moduleDetails?.project_id}/modules/${moduleDetails?.id}`) + } >
{moduleDetails?.name}
diff --git a/web/components/modules/gantt-chart/modules-list-layout.tsx b/web/components/modules/gantt-chart/modules-list-layout.tsx index 8384c164e..c6caacc92 100644 --- a/web/components/modules/gantt-chart/modules-list-layout.tsx +++ b/web/components/modules/gantt-chart/modules-list-layout.tsx @@ -22,7 +22,7 @@ export const ModulesListGanttChartView: React.FC = observer(() => { const payload: any = { ...data }; if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder; - await updateModuleDetails(workspaceSlug.toString(), module.project, module.id, payload); + await updateModuleDetails(workspaceSlug.toString(), module.project_id, module.id, payload); }; const blockFormat = (blocks: string[]) => diff --git a/web/components/modules/modal.tsx b/web/components/modules/modal.tsx index 7990386df..47f331396 100644 --- a/web/components/modules/modal.tsx +++ b/web/components/modules/modal.tsx @@ -24,8 +24,8 @@ const defaultValues: Partial = { name: "", description: "", status: "backlog", - lead: null, - members: [], + lead_id: null, + member_ids: [], }; export const CreateUpdateModuleModal: React.FC = observer((props) => { @@ -51,7 +51,7 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { const handleCreateModule = async (payload: Partial) => { if (!workspaceSlug || !projectId) return; - const selectedProjectId = payload.project ?? projectId.toString(); + const selectedProjectId = payload.project_id ?? projectId.toString(); await createModule(workspaceSlug.toString(), selectedProjectId, payload) .then((res) => { handleClose(); @@ -81,7 +81,7 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { const handleUpdateModule = async (payload: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId || !data) return; - const selectedProjectId = payload.project ?? projectId.toString(); + const selectedProjectId = payload.project_id ?? projectId.toString(); await updateModuleDetails(workspaceSlug.toString(), selectedProjectId, data.id, payload) .then((res) => { handleClose(); @@ -129,8 +129,8 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { // if data is present, set active project to the project of the // issue. This has more priority than the project in the url. - if (data && data.project) { - setActiveProject(data.project); + if (data && data.project_id) { + setActiveProject(data.project_id); return; } diff --git a/web/components/modules/module-card-item.tsx b/web/components/modules/module-card-item.tsx index ce93ff961..4dec3df6e 100644 --- a/web/components/modules/module-card-item.tsx +++ b/web/components/modules/module-card-item.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react"; // hooks -import { useEventTracker, useModule, useUser } from "hooks/store"; +import { useEventTracker, useMember, useModule, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules"; @@ -37,6 +37,7 @@ export const ModuleCardItem: React.FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); + const { getUserDetails } = useMember(); const { setTrackElement, captureEvent } = useEventTracker(); // derived values const moduleDetails = getModuleById(moduleId); @@ -147,8 +148,8 @@ export const ModuleCardItem: React.FC = observer((props) => { ? !moduleTotalIssues || moduleTotalIssues === 0 ? "0 Issue" : moduleTotalIssues === moduleDetails.completed_issues - ? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? "s" : ""}` - : `${moduleDetails.completed_issues}/${moduleTotalIssues} Issues` + ? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? "s" : ""}` + : `${moduleDetails.completed_issues}/${moduleTotalIssues} Issues` : "0 Issue"; return ( @@ -163,7 +164,7 @@ export const ModuleCardItem: React.FC = observer((props) => { /> )} setDeleteModal(false)} /> - +
@@ -195,13 +196,14 @@ export const ModuleCardItem: React.FC = observer((props) => { {issueCount ?? "0 Issue"}
- {moduleDetails.members_detail.length > 0 && ( - + {moduleDetails.member_ids?.length > 0 && ( +
- {moduleDetails.members_detail.map((member) => ( - - ))} + {moduleDetails.member_ids.map((member_id) => { + const member = getUserDetails(member_id); + return ; + })}
diff --git a/web/components/modules/module-list-item.tsx b/web/components/modules/module-list-item.tsx index 79e559ca7..e3913115e 100644 --- a/web/components/modules/module-list-item.tsx +++ b/web/components/modules/module-list-item.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react"; // hooks -import { useModule, useUser, useEventTracker } from "hooks/store"; +import { useModule, useUser, useEventTracker, useMember } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules"; @@ -37,6 +37,7 @@ export const ModuleListItem: React.FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); + const { getUserDetails } = useMember(); const { setTrackElement, captureEvent } = useEventTracker(); // derived values const moduleDetails = getModuleById(moduleId); @@ -153,7 +154,7 @@ export const ModuleListItem: React.FC = observer((props) => { /> )} setDeleteModal(false)} /> - +
@@ -206,13 +207,14 @@ export const ModuleListItem: React.FC = observer((props) => {
- +
- {moduleDetails.members_detail.length > 0 ? ( + {moduleDetails.member_ids.length > 0 ? ( - {moduleDetails.members_detail.map((member) => ( - - ))} + {moduleDetails.member_ids.map((member_id) => { + const member = getUserDetails(member_id); + return ; + })} ) : ( diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index 5e674b303..b37875386 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -37,8 +37,8 @@ import { EUserProjectRoles } from "constants/project"; import { MODULE_LINK_CREATED, MODULE_LINK_DELETED, MODULE_LINK_UPDATED, MODULE_UPDATED } from "constants/event-tracker"; const defaultValues: Partial = { - lead: "", - members: [], + lead_id: "", + member_ids: [], start_date: null, target_date: null, status: "backlog", @@ -323,8 +323,9 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { = observer((props) => { <> {renderFormattedDate(startDate) ?? "No date selected"} @@ -427,13 +430,15 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { <> {renderFormattedDate(endDate) ?? "No date selected"} @@ -485,13 +490,13 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => {
(
{ - submitChanges({ lead: val }); + submitChanges({ lead_id: val }); }} projectId={projectId?.toString() ?? ""} multiple={false} @@ -509,13 +514,13 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => {
(
{ - submitChanges({ members: val }); + submitChanges({ member_ids: val }); }} multiple projectId={projectId?.toString() ?? ""} diff --git a/web/services/issue/issue.service.ts b/web/services/issue/issue.service.ts index 0b65b0482..5d3663dd6 100644 --- a/web/services/issue/issue.service.ts +++ b/web/services/issue/issue.service.ts @@ -59,6 +59,16 @@ export class IssueService extends APIService { }); } + async retrieveIssues(workspaceSlug: string, projectId: string, issueIds: string[]): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/list/`, { + params: { issues: issueIds.join(",") }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async getIssueActivities(workspaceSlug: string, projectId: string, issueId: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/history/`) .then((response) => response?.data) diff --git a/web/store/cycle.store.ts b/web/store/cycle.store.ts index 51340d740..917fd8022 100644 --- a/web/store/cycle.store.ts +++ b/web/store/cycle.store.ts @@ -102,7 +102,7 @@ export class CycleStore implements ICycleStore { get currentProjectCycleIds() { const projectId = this.rootStore.app.router.projectId; if (!projectId || !this.fetchedMap[projectId]) return null; - let allCycles = Object.values(this.cycleMap ?? {}).filter((c) => c?.project === projectId); + let allCycles = Object.values(this.cycleMap ?? {}).filter((c) => c?.project_id === projectId); allCycles = sortBy(allCycles, [(c) => c.sort_order]); const allCycleIds = allCycles.map((c) => c.id); return allCycleIds; @@ -116,7 +116,7 @@ export class CycleStore implements ICycleStore { if (!projectId || !this.fetchedMap[projectId]) return null; let completedCycles = Object.values(this.cycleMap ?? {}).filter((c) => { const hasEndDatePassed = isPast(new Date(c.end_date ?? "")); - return c.project === projectId && hasEndDatePassed; + return c.project_id === projectId && hasEndDatePassed; }); completedCycles = sortBy(completedCycles, [(c) => c.sort_order]); const completedCycleIds = completedCycles.map((c) => c.id); @@ -131,7 +131,7 @@ export class CycleStore implements ICycleStore { if (!projectId || !this.fetchedMap[projectId]) return null; let upcomingCycles = Object.values(this.cycleMap ?? {}).filter((c) => { const isStartDateUpcoming = isFuture(new Date(c.start_date ?? "")); - return c.project === projectId && isStartDateUpcoming; + return c.project_id === projectId && isStartDateUpcoming; }); upcomingCycles = sortBy(upcomingCycles, [(c) => c.sort_order]); const upcomingCycleIds = upcomingCycles.map((c) => c.id); @@ -146,7 +146,7 @@ export class CycleStore implements ICycleStore { if (!projectId || !this.fetchedMap[projectId]) return null; let incompleteCycles = Object.values(this.cycleMap ?? {}).filter((c) => { const hasEndDatePassed = isPast(new Date(c.end_date ?? "")); - return c.project === projectId && !hasEndDatePassed; + return c.project_id === projectId && !hasEndDatePassed; }); incompleteCycles = sortBy(incompleteCycles, [(c) => c.sort_order]); const incompleteCycleIds = incompleteCycles.map((c) => c.id); @@ -160,7 +160,7 @@ export class CycleStore implements ICycleStore { const projectId = this.rootStore.app.router.projectId; if (!projectId || !this.fetchedMap[projectId]) return null; let draftCycles = Object.values(this.cycleMap ?? {}).filter( - (c) => c.project === projectId && !c.start_date && !c.end_date + (c) => c.project_id === projectId && !c.start_date && !c.end_date ); draftCycles = sortBy(draftCycles, [(c) => c.sort_order]); const draftCycleIds = draftCycles.map((c) => c.id); @@ -174,7 +174,7 @@ export class CycleStore implements ICycleStore { const projectId = this.rootStore.app.router.projectId; if (!projectId) return null; const activeCycle = Object.keys(this.activeCycleIdMap ?? {}).find( - (cycleId) => this.cycleMap?.[cycleId]?.project === projectId + (cycleId) => this.cycleMap?.[cycleId]?.project_id === projectId ); return activeCycle || null; } @@ -202,7 +202,7 @@ export class CycleStore implements ICycleStore { getProjectCycleIds = computedFn((projectId: string): string[] | null => { if (!this.fetchedMap[projectId]) return null; - let cycles = Object.values(this.cycleMap ?? {}).filter((c) => c.project === projectId); + let cycles = Object.values(this.cycleMap ?? {}).filter((c) => c.project_id === projectId); cycles = sortBy(cycles, [(c) => c.sort_order]); const cycleIds = cycles.map((c) => c.id); return cycleIds || null; diff --git a/web/store/issue/cycle/issue.store.ts b/web/store/issue/cycle/issue.store.ts index 71618d51c..5a9cae62c 100644 --- a/web/store/issue/cycle/issue.store.ts +++ b/web/store/issue/cycle/issue.store.ts @@ -54,7 +54,13 @@ export interface ICycleIssues { data: TIssue, cycleId?: string | undefined ) => Promise; - addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; + addIssueToCycle: ( + workspaceSlug: string, + projectId: string, + cycleId: string, + issueIds: string[], + fetchAddedIssues?: boolean + ) => Promise; removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; transferIssuesFromCycle: ( workspaceSlug: string, @@ -182,7 +188,7 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { if (!cycleId) throw new Error("Cycle Id is required"); const response = await this.rootIssueStore.projectIssues.createIssue(workspaceSlug, projectId, data); - await this.addIssueToCycle(workspaceSlug, projectId, cycleId, [response.id]); + await this.addIssueToCycle(workspaceSlug, projectId, cycleId, [response.id], false); this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); return response; @@ -265,21 +271,33 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { } }; - addIssueToCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { + addIssueToCycle = async ( + workspaceSlug: string, + projectId: string, + cycleId: string, + issueIds: string[], + fetchAddedIssues = true + ) => { try { - const issueToCycle = await this.issueService.addIssueToCycle(workspaceSlug, projectId, cycleId, { + await this.issueService.addIssueToCycle(workspaceSlug, projectId, cycleId, { issues: issueIds, }); + if (fetchAddedIssues) await this.rootIssueStore.issues.getIssues(workspaceSlug, projectId, issueIds); + runInAction(() => { update(this.issues, cycleId, (cycleIssueIds = []) => uniq(concat(cycleIssueIds, issueIds))); }); issueIds.forEach((issueId) => { + const issueCycleId = this.rootIssueStore.issues.getIssueById(issueId)?.cycle_id; + if (issueCycleId && issueCycleId !== cycleId) { + runInAction(() => { + pull(this.issues[issueCycleId], issueId); + }); + } this.rootStore.issues.updateIssue(issueId, { cycle_id: cycleId }); }); this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); - - return issueToCycle; } catch (error) { throw error; } diff --git a/web/store/issue/issue-details/issue.store.ts b/web/store/issue/issue-details/issue.store.ts index 43a7ca093..ccde8c26b 100644 --- a/web/store/issue/issue-details/issue.store.ts +++ b/web/store/issue/issue-details/issue.store.ts @@ -11,7 +11,7 @@ export interface IIssueStoreActions { fetchIssue: (workspaceSlug: string, projectId: string, issueId: string, isArchived?: boolean) => Promise; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; - addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; + addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; addModulesToIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise; removeModulesFromIssue: ( @@ -123,15 +123,15 @@ export class IssueStore implements IIssueStore { this.rootIssueDetailStore.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); addIssueToCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { - const cycle = await this.rootIssueDetailStore.rootIssueStore.cycleIssues.addIssueToCycle( + await this.rootIssueDetailStore.rootIssueStore.cycleIssues.addIssueToCycle( workspaceSlug, projectId, cycleId, - issueIds + issueIds, + false ); if (issueIds && issueIds.length > 0) await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueIds[0]); - return cycle; }; removeIssueFromCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => { diff --git a/web/store/issue/issue.store.ts b/web/store/issue/issue.store.ts index 36b2d8741..8bdb18dad 100644 --- a/web/store/issue/issue.store.ts +++ b/web/store/issue/issue.store.ts @@ -5,11 +5,14 @@ import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // types import { TIssue } from "@plane/types"; +//services +import { IssueService } from "services/issue"; export type IIssueStore = { // observables issuesMap: Record; // Record defines issue_id as key and TIssue as value // actions + getIssues(workspaceSlug: string, projectId: string, issueIds: string[]): Promise; addIssue(issues: TIssue[], shouldReplace?: boolean): void; updateIssue(issueId: string, issue: Partial): void; removeIssue(issueId: string): void; @@ -21,6 +24,8 @@ export type IIssueStore = { export class IssueStore implements IIssueStore { // observables issuesMap: { [issue_id: string]: TIssue } = {}; + // service + issueService; constructor() { makeObservable(this, { @@ -31,6 +36,8 @@ export class IssueStore implements IIssueStore { updateIssue: action, removeIssue: action, }); + + this.issueService = new IssueService(); } // actions @@ -48,6 +55,18 @@ export class IssueStore implements IIssueStore { }); }; + getIssues = async (workspaceSlug: string, projectId: string, issueIds: string[]) => { + const issues = await this.issueService.retrieveIssues(workspaceSlug, projectId, issueIds); + + runInAction(() => { + issues.forEach((issue) => { + if (!this.issuesMap[issue.id]) set(this.issuesMap, issue.id, issue); + }); + }); + + return issues; + }; + /** * @description This method will update the issue in the issuesMap * @param {string} issueId diff --git a/web/store/issue/module/issue.store.ts b/web/store/issue/module/issue.store.ts index c9ed459a1..b83519cd2 100644 --- a/web/store/issue/module/issue.store.ts +++ b/web/store/issue/module/issue.store.ts @@ -52,7 +52,13 @@ export interface IModuleIssues { data: TIssue, moduleId?: string | undefined ) => Promise; - addIssuesToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise; + addIssuesToModule: ( + workspaceSlug: string, + projectId: string, + moduleId: string, + issueIds: string[], + fetchAddedIssues?: boolean + ) => Promise; removeIssuesFromModule: ( workspaceSlug: string, projectId: string, @@ -187,7 +193,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { if (!moduleId) throw new Error("Module Id is required"); const response = await this.rootIssueStore.projectIssues.createIssue(workspaceSlug, projectId, data); - await this.addIssuesToModule(workspaceSlug, projectId, moduleId, [response.id]); + await this.addIssuesToModule(workspaceSlug, projectId, moduleId, [response.id], false); this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); return response; @@ -269,12 +275,20 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { } }; - addIssuesToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { + addIssuesToModule = async ( + workspaceSlug: string, + projectId: string, + moduleId: string, + issueIds: string[], + fetchAddedIssues = true + ) => { try { - const issueToModule = await this.moduleService.addIssuesToModule(workspaceSlug, projectId, moduleId, { + await this.moduleService.addIssuesToModule(workspaceSlug, projectId, moduleId, { issues: issueIds, }); + if (fetchAddedIssues) await this.rootIssueStore.issues.getIssues(workspaceSlug, projectId, issueIds); + runInAction(() => { update(this.issues, moduleId, (moduleIssueIds = []) => { if (!moduleIssueIds) return [...issueIds]; @@ -289,8 +303,6 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { }); }); this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); - - return issueToModule; } catch (error) { throw error; } @@ -356,7 +368,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { runInAction(() => { moduleIds.forEach((moduleId) => { update(this.issues, moduleId, (moduleIssueIds = []) => { - if (moduleIssueIds.includes(issueId)) return moduleIssueIds; + if (moduleIssueIds.includes(issueId)) return pull(moduleIssueIds, issueId); else return uniq(concat(moduleIssueIds, [issueId])); }); update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => diff --git a/web/store/module.store.ts b/web/store/module.store.ts index 5c80e39d0..e550dc7a0 100644 --- a/web/store/module.store.ts +++ b/web/store/module.store.ts @@ -99,7 +99,7 @@ export class ModulesStore implements IModuleStore { get projectModuleIds() { const projectId = this.rootStore.app.router.projectId; if (!projectId || !this.fetchedMap[projectId]) return null; - let projectModules = Object.values(this.moduleMap).filter((m) => m.project === projectId); + let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId); projectModules = sortBy(projectModules, [(m) => m.sort_order]); const projectModuleIds = projectModules.map((m) => m.id); return projectModuleIds || null; @@ -119,7 +119,7 @@ export class ModulesStore implements IModuleStore { getProjectModuleIds = computedFn((projectId: string) => { if (!this.fetchedMap[projectId]) return null; - let projectModules = Object.values(this.moduleMap).filter((m) => m.project === projectId); + let projectModules = Object.values(this.moduleMap).filter((m) => m.project_id === projectId); projectModules = sortBy(projectModules, [(m) => m.sort_order]); const projectModuleIds = projectModules.map((m) => m.id); return projectModuleIds;