diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py index 7762d3b80..bb759784e 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -222,9 +222,13 @@ class ModuleSerializer(DynamicBaseSerializer): class ModuleDetailSerializer(ModuleSerializer): link_module = ModuleLinkSerializer(read_only=True, many=True) sub_issues = serializers.IntegerField(read_only=True) + backlog_estimate_points = serializers.IntegerField(read_only=True) + unstarted_estimate_points = serializers.IntegerField(read_only=True) + started_estimate_points = serializers.IntegerField(read_only=True) + cancelled_estimate_points = serializers.IntegerField(read_only=True) class Meta(ModuleSerializer.Meta): - fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"] + fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues", "backlog_estimate_points", "unstarted_estimate_points", "started_estimate_points", "cancelled_estimate_points"] class ModuleUserPropertiesSerializer(BaseSerializer): diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 91012748f..8c3f98ac6 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -46,6 +46,7 @@ from plane.db.models import ( Issue, Label, User, + Project, ) from plane.utils.analytics_plot import burndown_plot @@ -325,7 +326,6 @@ class CycleViewSet(BaseViewSet): def list(self, request, slug, project_id): queryset = self.get_queryset().filter(archived_at__isnull=True) - plot_type = request.GET.get("plot_type", "issues") cycle_view = request.GET.get("cycle_view", "all") # Update the order by @@ -373,8 +373,108 @@ class CycleViewSet(BaseViewSet): "status", "created_by", ) + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() if data: + data[0]["estimate_distribution"] = {} + if estimate_type: + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=data[0]["id"], + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=data[0]["id"], + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data[0]["estimate_distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + if data[0]["start_date"] and data[0]["end_date"]: + data[0]["estimate_distribution"]["completion_chart"] = ( + burndown_plot( + queryset=queryset.first(), + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=data[0]["id"], + ) + ) + assignee_distribution = ( Issue.objects.filter( issue_cycle__cycle_id=data[0]["id"], @@ -388,7 +488,10 @@ class CycleViewSet(BaseViewSet): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), ), ) .annotate( @@ -427,8 +530,11 @@ class CycleViewSet(BaseViewSet): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), - ) + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ), ) .annotate( completed_issues=Count( @@ -464,7 +570,7 @@ class CycleViewSet(BaseViewSet): queryset=queryset.first(), slug=slug, project_id=project_id, - plot_type=plot_type, + plot_type="issues", cycle_id=data[0]["id"], ) ) @@ -659,7 +765,6 @@ class CycleViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, slug, project_id, pk): - plot_type = request.GET.get("plot_type", "issues") queryset = ( self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk) ) @@ -710,12 +815,107 @@ class CycleViewSet(BaseViewSet): ) queryset = queryset.first() - if data is None: - return Response( - {"error": "Cycle does not exist"}, - status=status.HTTP_400_BAD_REQUEST, + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + data["estimate_distribution"] = {} + if estimate_type: + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") ) + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data["estimate_distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + if data["start_date"] and data["end_date"]: + data["estimate_distribution"]["completion_chart"] = ( + burndown_plot( + queryset=queryset, + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=pk, + ) + ) + # Assignee Distribution assignee_distribution = ( Issue.objects.filter( @@ -738,7 +938,10 @@ class CycleViewSet(BaseViewSet): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), ), ) .annotate( @@ -778,7 +981,10 @@ class CycleViewSet(BaseViewSet): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), ), ) .annotate( @@ -815,7 +1021,7 @@ class CycleViewSet(BaseViewSet): queryset=queryset, slug=slug, project_id=project_id, - plot_type=plot_type, + plot_type="issues", cycle_id=pk, ) @@ -932,7 +1138,6 @@ class TransferCycleIssueEndpoint(BaseAPIView): def post(self, request, slug, project_id, cycle_id): new_cycle_id = request.data.get("new_cycle_id", False) - plot_type = request.GET.get("plot_type", "issues") if not new_cycle_id: return Response( @@ -1009,14 +1214,127 @@ class TransferCycleIssueEndpoint(BaseAPIView): ) ) - # Pass the new_cycle queryset to burndown_plot - completion_chart = burndown_plot( - queryset=old_cycle.first(), - slug=slug, - project_id=project_id, - plot_type=plot_type, - cycle_id=cycle_id, - ) + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + if estimate_type: + assignee_estimate_data = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + # assignee distribution serialization + assignee_estimate_distribution = [ + { + "display_name": item["display_name"], + "assignee_id": ( + str(item["assignee_id"]) + if item["assignee_id"] + else None + ), + "avatar": item["avatar"], + "total_estimates": item["total_estimates"], + "completed_estimates": item["completed_estimates"], + "pending_estimates": item["pending_estimates"], + } + for item in assignee_estimate_data + ] + + label_distribution_data = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + estimate_completion_chart = burndown_plot( + queryset=old_cycle.first(), + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=cycle_id, + ) + # Label distribution serialization + label_estimate_distribution = [ + { + "label_name": item["label_name"], + "color": item["color"], + "label_id": ( + str(item["label_id"]) if item["label_id"] else None + ), + "total_estimates": item["total_estimates"], + "completed_estimates": item["completed_estimates"], + "pending_estimates": item["pending_estimates"], + } + for item in label_distribution_data + ] # Get the assignee distribution assignee_distribution = ( @@ -1032,7 +1350,10 @@ class TransferCycleIssueEndpoint(BaseAPIView): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), ), ) .annotate( @@ -1086,8 +1407,11 @@ class TransferCycleIssueEndpoint(BaseAPIView): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), - ) + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ), ) .annotate( completed_issues=Count( @@ -1112,20 +1436,6 @@ 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 = [ { @@ -1141,6 +1451,15 @@ class TransferCycleIssueEndpoint(BaseAPIView): for item in label_distribution ] + # Pass the new_cycle queryset to burndown_plot + completion_chart = burndown_plot( + queryset=old_cycle.first(), + slug=slug, + project_id=project_id, + plot_type="issues", + cycle_id=cycle_id, + ) + current_cycle = Cycle.objects.filter( workspace__slug=slug, project_id=project_id, pk=cycle_id ).first() @@ -1157,6 +1476,15 @@ class TransferCycleIssueEndpoint(BaseAPIView): "assignees": assignee_distribution_data, "completion_chart": completion_chart, }, + "estimate_distribution": ( + {} + if not estimate_type + else { + "labels": label_estimate_distribution, + "assignees": assignee_estimate_distribution, + "completion_chart": estimate_completion_chart, + } + ), } current_cycle.save(update_fields=["progress_snapshot"]) diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py index d474f26c6..5bc91ff1c 100644 --- a/apiserver/plane/app/views/module/archive.py +++ b/apiserver/plane/app/views/module/archive.py @@ -12,8 +12,9 @@ from django.db.models import ( Subquery, UUIDField, Value, + Sum ) -from django.db.models.functions import Coalesce +from django.db.models.functions import Coalesce, Cast from django.utils import timezone # Third party imports @@ -25,7 +26,7 @@ from plane.app.permissions import ( from plane.app.serializers import ( ModuleDetailSerializer, ) -from plane.db.models import Issue, Module, ModuleLink, UserFavorite +from plane.db.models import Issue, Module, ModuleLink, UserFavorite, Project from plane.utils.analytics_plot import burndown_plot from plane.utils.user_timezone_converter import user_timezone_converter @@ -165,7 +166,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): ) def get(self, request, slug, project_id, pk=None): - plot_type = request.GET.get("plot_type", "issues") if pk is None: queryset = self.get_queryset() modules = queryset.values( # Required fields @@ -218,6 +218,116 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .values("count") ) ) + + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + data = ModuleDetailSerializer(queryset.first()).data + modules = queryset.first() + + data["estimate_distribution"] = {} + + if estimate_type: + label_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(display_name=F("assignees__display_name")) + .annotate(avatar=F("assignees__avatar")) + .values( + "first_name", + "last_name", + "assignee_id", + "avatar", + "display_name", + ) + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ), + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + assignee_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ), + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data["estimate_distribution"]["assignee"] = assignee_distribution + data["estimate_distribution"]["label"] = label_distribution + + if modules and modules.start_date and modules.target_date: + data["estimate_distribution"]["completion_chart"] = ( + burndown_plot( + queryset=modules, + slug=slug, + project_id=project_id, + plot_type="points", + module_id=pk, + ) + ) assignee_distribution = ( Issue.objects.filter( issue_module__module_id=pk, @@ -310,7 +420,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .order_by("label_name") ) - data = ModuleDetailSerializer(queryset.first()).data data["distribution"] = { "assignees": assignee_distribution, "labels": label_distribution, @@ -318,13 +427,12 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): } # Fetch the modules - modules = queryset.first() if modules and modules.start_date and modules.target_date: data["distribution"]["completion_chart"] = burndown_plot( queryset=modules, slug=slug, project_id=project_id, - plot_type=plot_type, + plot_type="issues", module_id=pk, ) diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index 5b631be46..e1a323880 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -157,6 +157,62 @@ class ModuleViewSet(BaseViewSet): ) .values("total_estimate_points")[:1] ) + backlog_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="backlog", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + backlog_estimate_point=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("backlog_estimate_point")[:1] + ) + unstarted_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="unstarted", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + unstarted_estimate_point=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("unstarted_estimate_point")[:1] + ) + started_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="started", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + started_estimate_point=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("started_estimate_point")[:1] + ) + cancelled_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="cancelled", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + cancelled_estimate_point=Sum( + Cast("estimate_point__value", IntegerField()) + ) + ) + .values("cancelled_estimate_point")[:1] + ) return ( super() .get_queryset() @@ -211,6 +267,30 @@ class ModuleViewSet(BaseViewSet): Value(0, output_field=IntegerField()), ) ) + .annotate( + backlog_estimate_points=Coalesce( + Subquery(backlog_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) + .annotate( + unstarted_estimate_points=Coalesce( + Subquery(unstarted_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) + .annotate( + started_estimate_points=Coalesce( + Subquery(started_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) + .annotate( + cancelled_estimate_points=Coalesce( + Subquery(cancelled_estimate_point), + Value(0, output_field=IntegerField()), + ), + ) .annotate( completed_estimate_points=Coalesce( Subquery(completed_estimate_point), @@ -346,7 +426,6 @@ class ModuleViewSet(BaseViewSet): return Response(modules, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, pk): - plot_type = request.GET.get("plot_type", "burndown") queryset = ( self.get_queryset() .filter(archived_at__isnull=True) @@ -363,6 +442,116 @@ class ModuleViewSet(BaseViewSet): ) ) + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + data = ModuleDetailSerializer(queryset.first()).data + modules = queryset.first() + + data["estimate_distribution"] = {} + + if estimate_type: + assignee_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(display_name=F("assignees__display_name")) + .annotate(avatar=F("assignees__avatar")) + .values( + "first_name", + "last_name", + "assignee_id", + "avatar", + "display_name", + ) + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ), + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ), + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data["estimate_distribution"]["assignees"] = assignee_distribution + data["estimate_distribution"]["labels"] = label_distribution + + if modules and modules.start_date and modules.target_date: + data["estimate_distribution"]["completion_chart"] = ( + burndown_plot( + queryset=modules, + slug=slug, + project_id=project_id, + plot_type="points", + module_id=pk, + ) + ) + assignee_distribution = ( Issue.objects.filter( issue_module__module_id=pk, @@ -388,7 +577,7 @@ class ModuleViewSet(BaseViewSet): archived_at__isnull=True, is_draft=False, ), - ) + ), ) .annotate( completed_issues=Count( @@ -455,21 +644,17 @@ class ModuleViewSet(BaseViewSet): .order_by("label_name") ) - data = ModuleDetailSerializer(queryset.first()).data data["distribution"] = { "assignees": assignee_distribution, "labels": label_distribution, "completion_chart": {}, } - - # Fetch the modules - modules = queryset.first() if modules and modules.start_date and modules.target_date: data["distribution"]["completion_chart"] = burndown_plot( queryset=modules, slug=slug, project_id=project_id, - plot_type=plot_type, + plot_type="issues", module_id=pk, ) diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index 80f6ec9dc..a736ed8ec 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -430,17 +430,14 @@ class IssueVotePublicViewSet(BaseViewSet): return IssueVote.objects.none() def create(self, request, anchor, issue_id): - print("hite") project_deploy_board = DeployBoard.objects.get( anchor=anchor, entity_name="project" ) - print("awer") issue_vote, _ = IssueVote.objects.get_or_create( actor_id=request.user.id, project_id=project_deploy_board.project_id, issue_id=issue_id, ) - print("AWer") # Add the user for workspace tracking if not ProjectMember.objects.filter( project_id=project_deploy_board.project_id, diff --git a/packages/types/src/cycle/cycle.d.ts b/packages/types/src/cycle/cycle.d.ts index e93d6e444..b0528ccc1 100644 --- a/packages/types/src/cycle/cycle.d.ts +++ b/packages/types/src/cycle/cycle.d.ts @@ -2,32 +2,81 @@ import type { TIssue, IIssueFilterOptions } from "@plane/types"; export type TCycleGroups = "current" | "upcoming" | "completed" | "draft"; -export interface ICycle { - backlog_issues: number; - cancelled_issues: number; +export type TCycleCompletionChartDistribution = { + [key: string]: number | null; +}; + +export type TCycleDistributionBase = { + total_issues: number; + pending_issues: number; completed_issues: number; +}; + +export type TCycleEstimateDistributionBase = { + total_estimates: number; + pending_estimates: number; + completed_estimates: number; +}; + +export type TCycleAssigneesDistribution = { + assignee_id: string | null; + avatar: string | null; + first_name: string | null; + last_name: string | null; + display_name: string | null; +}; + +export type TCycleLabelsDistribution = { + color: string | null; + label_id: string | null; + label_name: string | null; +}; + +export type TCycleDistribution = { + assignees: (TCycleAssigneesDistribution & TCycleDistributionBase)[]; + completion_chart: TCycleCompletionChartDistribution; + labels: (TCycleLabelsDistribution & TCycleDistributionBase)[]; +}; + +export type TCycleEstimateDistribution = { + assignees: (TCycleAssigneesDistribution & TCycleEstimateDistributionBase)[]; + completion_chart: TCycleCompletionChartDistribution; + labels: (TCycleLabelsDistribution & TCycleEstimateDistributionBase)[]; +}; + +export type TProgressSnapshot = { + total_issues: number; + completed_issues: number; + backlog_issues: number; + started_issues: number; + unstarted_issues: number; + cancelled_issues: number; + total_estimate_points?: number; + completed_estimate_points?: number; + backlog_estimate_points: number; + started_estimate_points: number; + unstarted_estimate_points: number; + cancelled_estimate_points: number; + distribution?: TCycleDistribution; + estimate_distribution?: TCycleEstimateDistribution; +}; + +export interface ICycle extends TProgressSnapshot { + progress_snapshot: TProgressSnapshot | undefined; + created_at?: string; created_by?: string; description: string; - distribution?: { - assignees: TAssigneesDistribution[]; - completion_chart: TCompletionChartDistribution; - labels: TLabelsDistribution[]; - }; end_date: string | null; id: string; is_favorite?: boolean; name: string; owned_by_id: string; - progress_snapshot: TProgressSnapshot; project_id: string; status?: TCycleGroups; sort_order: number; start_date: string | null; - started_issues: number; sub_issues?: number; - total_issues: number; - unstarted_issues: number; updated_at?: string; updated_by?: string; archived_at: string | null; @@ -38,47 +87,6 @@ export interface ICycle { workspace_id: string; } -export type TProgressSnapshot = { - backlog_issues: number; - cancelled_issues: number; - completed_estimates: number | null; - completed_issues: number; - distribution?: { - assignees: TAssigneesDistribution[]; - completion_chart: TCompletionChartDistribution; - labels: TLabelsDistribution[]; - }; - started_estimates: number | null; - started_issues: number; - total_estimates: number | null; - total_issues: number; - unstarted_issues: number; -}; - -export type TAssigneesDistribution = { - assignee_id: string | null; - avatar: string | null; - completed_issues: number; - first_name: string | null; - last_name: string | null; - display_name: string | null; - pending_issues: number; - total_issues: number; -}; - -export type TCompletionChartDistribution = { - [key: string]: number | null; -}; - -export type TLabelsDistribution = { - color: string | null; - completed_issues: number; - label_id: string | null; - label_name: string | null; - pending_issues: number; - total_issues: number; -}; - export interface CycleIssueResponse { id: string; issue_detail: TIssue; @@ -102,3 +110,5 @@ export type CycleDateCheckData = { end_date: string; cycle_id?: string; }; + +export type TCyclePlotType = "burndown" | "points"; diff --git a/packages/types/src/module/modules.d.ts b/packages/types/src/module/modules.d.ts index 53be14347..6a5a09231 100644 --- a/packages/types/src/module/modules.d.ts +++ b/packages/types/src/module/modules.d.ts @@ -1,11 +1,4 @@ -import type { - TIssue, - IIssueFilterOptions, - ILinkDetails, - TAssigneesDistribution, - TCompletionChartDistribution, - TLabelsDistribution, -} from "@plane/types"; +import type { TIssue, IIssueFilterOptions, ILinkDetails } from "@plane/types"; export type TModuleStatus = | "backlog" @@ -15,44 +8,88 @@ export type TModuleStatus = | "completed" | "cancelled"; -export interface IModule { - backlog_issues: number; - cancelled_issues: number; +export type TModuleCompletionChartDistribution = { + [key: string]: number | null; +}; + +export type TModuleDistributionBase = { + total_issues: number; + pending_issues: number; completed_issues: number; - created_at: string; - created_by?: string; +}; + +export type TModuleEstimateDistributionBase = { + total_estimates: number; + pending_estimates: number; + completed_estimates: number; +}; + +export type TModuleAssigneesDistribution = { + assignee_id: string | null; + avatar: string | null; + first_name: string | null; + last_name: string | null; + display_name: string | null; +}; + +export type TModuleLabelsDistribution = { + color: string | null; + label_id: string | null; + label_name: string | null; +}; + +export type TModuleDistribution = { + assignees: (TModuleAssigneesDistribution & TModuleDistributionBase)[]; + completion_chart: TModuleCompletionChartDistribution; + labels: (TModuleLabelsDistribution & TModuleDistributionBase)[]; +}; + +export type TModuleEstimateDistribution = { + assignees: (TModuleAssigneesDistribution & TModuleEstimateDistributionBase)[]; + completion_chart: TModuleCompletionChartDistribution; + labels: (TModuleLabelsDistribution & TModuleEstimateDistributionBase)[]; +}; + +export interface IModule { + total_issues: number; + completed_issues: number; + backlog_issues: number; + started_issues: number; + unstarted_issues: number; + cancelled_issues: number; + total_estimate_points?: number; + completed_estimate_points?: number; + backlog_estimate_points: number; + started_estimate_points: number; + unstarted_estimate_points: number; + cancelled_estimate_points: number; + distribution?: TModuleDistribution; + estimate_distribution?: TModuleEstimateDistribution; + + id: string; + name: string; description: string; description_text: any; description_html: any; - distribution?: { - assignees: TAssigneesDistribution[]; - completion_chart: TCompletionChartDistribution; - labels: TLabelsDistribution[]; - }; - id: string; - lead_id: string | null; - link_module?: ILinkDetails[]; - member_ids: string[]; - is_favorite: boolean; - name: string; + workspace_id: string; project_id: string; - sort_order: number; + lead_id: string | null; + member_ids: string[]; + link_module?: ILinkDetails[]; sub_issues?: number; - start_date: string | null; - started_issues: number; - status?: TModuleStatus; - target_date: string | null; - total_issues: number; - unstarted_issues: number; - total_estimate_points?: number; - completed_estimate_points?: number; - updated_at: string; - updated_by?: string; - archived_at: string | null; + is_favorite: boolean; + sort_order: number; view_props: { filters: IIssueFilterOptions; }; - workspace_id: string; + status?: TModuleStatus; + archived_at: string | null; + start_date: string | null; + target_date: string | null; + created_at: string; + updated_at: string; + created_by?: string; + updated_by?: string; } export interface ModuleIssueResponse { @@ -78,3 +115,5 @@ export type ModuleLink = { export type SelectModuleType = | (IModule & { actionType: "edit" | "delete" | "create-issue" }) | undefined; + +export type TModulePlotType = "burndown" | "points"; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx index 2b142fac9..a89593e07 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx @@ -7,9 +7,7 @@ import useSWR from "swr"; import { EmptyState } from "@/components/common"; import { PageHead } from "@/components/core"; import { ModuleLayoutRoot } from "@/components/issues"; -import { ModuleDetailsSidebar } from "@/components/modules"; -// constants -// import { EIssuesStoreType } from "@/constants/issue"; +import { ModuleAnalyticsSidebar } from "@/components/modules"; // helpers import { cn } from "@/helpers/common.helper"; // hooks @@ -77,7 +75,7 @@ const ModuleIssuesPage = observer(() => { "0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)", }} > - + )} diff --git a/web/core/components/core/sidebar/index.ts b/web/core/components/core/sidebar/index.ts index 91d415894..f970e0f18 100644 --- a/web/core/components/core/sidebar/index.ts +++ b/web/core/components/core/sidebar/index.ts @@ -1,4 +1,3 @@ export * from "./links-list"; -export * from "./sidebar-progress-stats"; export * from "./single-progress-stats"; export * from "./sidebar-menu-hamburger-toggle"; diff --git a/web/core/components/core/sidebar/progress-chart.tsx b/web/core/components/core/sidebar/progress-chart.tsx index 5f0e56fb7..f62736002 100644 --- a/web/core/components/core/sidebar/progress-chart.tsx +++ b/web/core/components/core/sidebar/progress-chart.tsx @@ -1,6 +1,6 @@ import React from "react"; import { eachDayOfInterval, isValid } from "date-fns"; -import { TCompletionChartDistribution } from "@plane/types"; +import { TModuleCompletionChartDistribution } from "@plane/types"; // ui import { LineGraph } from "@/components/ui"; // helpers @@ -8,11 +8,12 @@ import { getDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.hel //types type Props = { - distribution: TCompletionChartDistribution; + distribution: TModuleCompletionChartDistribution; startDate: string | Date; endDate: string | Date; totalIssues: number; className?: string; + plotTitle?: string; }; const styleById = { @@ -41,7 +42,14 @@ const DashedLine = ({ series, lineGenerator, xScale, yScale }: any) => /> )); -const ProgressChart: React.FC = ({ distribution, startDate, endDate, totalIssues, className = "" }) => { +const ProgressChart: React.FC = ({ + distribution, + startDate, + endDate, + totalIssues, + className = "", + plotTitle = "issues", +}) => { const chartData = Object.keys(distribution ?? []).map((key) => ({ currentDate: renderFormattedDateWithoutYear(key), pending: distribution[key], @@ -129,7 +137,7 @@ const ProgressChart: React.FC = ({ distribution, startDate, endDate, tota sliceTooltip={(datum) => (
{datum.slice.points[0].data.yFormatted} - issues pending on + {plotTitle} pending on {datum.slice.points[0].data.xFormatted}
)} diff --git a/web/core/components/core/sidebar/sidebar-progress-stats.tsx b/web/core/components/core/sidebar/sidebar-progress-stats.tsx deleted file mode 100644 index 20d791a8a..000000000 --- a/web/core/components/core/sidebar/sidebar-progress-stats.tsx +++ /dev/null @@ -1,285 +0,0 @@ -"use client"; -import React from "react"; -import { observer } from "mobx-react"; -import Image from "next/image"; -// headless ui -import { Tab } from "@headlessui/react"; -import { - IIssueFilterOptions, - IIssueFilters, - IModule, - TAssigneesDistribution, - TCompletionChartDistribution, - TLabelsDistribution, - TStateGroups, -} from "@plane/types"; -// hooks -import { Avatar, StateGroupIcon } from "@plane/ui"; -import { SingleProgressStats } from "@/components/core"; -import { useProjectState } from "@/hooks/store"; -import useLocalStorage from "@/hooks/use-local-storage"; -// images -import emptyLabel from "@/public/empty-state/empty_label.svg"; -import emptyMembers from "@/public/empty-state/empty_members.svg"; -// components -// ui -// types - -type Props = { - distribution: - | { - assignees: TAssigneesDistribution[]; - completion_chart: TCompletionChartDistribution; - labels: TLabelsDistribution[]; - } - | undefined; - groupedIssues: { - [key: string]: number; - }; - totalIssues: number; - module?: IModule; - roundedTab?: boolean; - noBackground?: boolean; - isPeekView?: boolean; - isCompleted?: boolean; - filters?: IIssueFilters | undefined; - handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; -}; - -export const SidebarProgressStats: React.FC = observer((props) => { - const { - distribution, - groupedIssues, - totalIssues, - module, - roundedTab, - noBackground, - isPeekView = false, - isCompleted = false, - filters, - handleFiltersUpdate, - } = props; - const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees"); - - const { groupedProjectStates } = useProjectState(); - - const currentValue = (tab: string | null) => { - switch (tab) { - case "Assignees": - return 0; - case "Labels": - return 1; - case "States": - return 2; - default: - return 0; - } - }; - - const getStateGroupState = (stateGroup: string) => { - const stateGroupStates = groupedProjectStates?.[stateGroup]; - const stateGroupStatesId = stateGroupStates?.map((state) => state.id); - return stateGroupStatesId; - }; - - return ( - { - switch (i) { - case 0: - return setTab("Assignees"); - case 1: - return setTab("Labels"); - case 2: - return setTab("States"); - default: - return setTab("Assignees"); - } - }} - > - - - `w-full ${ - roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded" - } px-3 py-1 text-custom-text-100 ${ - selected - ? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs" - : "text-custom-text-400 hover:text-custom-text-300" - }` - } - > - Assignees - - - `w-full ${ - roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded" - } px-3 py-1 text-custom-text-100 ${ - selected - ? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs" - : "text-custom-text-400 hover:text-custom-text-300" - }` - } - > - Labels - - - `w-full ${ - roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded" - } px-3 py-1 text-custom-text-100 ${ - selected - ? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs" - : "text-custom-text-400 hover:text-custom-text-300" - }` - } - > - States - - - - - {distribution && distribution?.assignees.length > 0 ? ( - distribution.assignees.map((assignee, index) => { - if (assignee.assignee_id) - return ( - - - {assignee?.display_name ?? ""} - - } - completed={assignee.completed_issues} - total={assignee.total_issues} - {...(!isPeekView && - !isCompleted && { - onClick: () => handleFiltersUpdate("assignees", assignee.assignee_id ?? ""), - selected: filters?.filters?.assignees?.includes(assignee.assignee_id ?? ""), - })} - /> - ); - else - return ( - -
- User -
- No assignee - - } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - }) - ) : ( -
-
- empty members -
-
No assignees yet
-
- )} -
- - {distribution && distribution?.labels.length > 0 ? ( - distribution.labels.map((label, index) => { - if (label.label_id) { - return ( - - - {label.label_name ?? "No labels"} - - } - completed={label.completed_issues} - total={label.total_issues} - {...(!isPeekView && - !isCompleted && { - onClick: () => handleFiltersUpdate("labels", label.label_id ?? ""), - selected: filters?.filters?.labels?.includes(label.label_id ?? `no-label-${index}`), - })} - /> - ); - } else { - return ( - - - {label.label_name ?? "No labels"} - - } - completed={label.completed_issues} - total={label.total_issues} - /> - ); - } - }) - ) : ( -
-
- empty label -
-
No labels yet
-
- )} -
- - {Object.keys(groupedIssues).map((group, index) => ( - - - {group} - - } - completed={groupedIssues[group]} - total={totalIssues} - {...(!isPeekView && - !isCompleted && { - onClick: () => handleFiltersUpdate("state", getStateGroupState(group) ?? []), - })} - /> - ))} - -
-
- ); -}); diff --git a/web/core/components/cycles/active-cycle/productivity.tsx b/web/core/components/cycles/active-cycle/productivity.tsx index 32d17df75..b3d5a6c04 100644 --- a/web/core/components/cycles/active-cycle/productivity.tsx +++ b/web/core/components/cycles/active-cycle/productivity.tsx @@ -1,12 +1,13 @@ -import { FC } from "react"; +import { FC, Fragment, useState } from "react"; import Link from "next/link"; -// types -import { ICycle } from "@plane/types"; +import { ICycle, TCyclePlotType } from "@plane/types"; +import { CustomSelect, Spinner } from "@plane/ui"; // components import ProgressChart from "@/components/core/sidebar/progress-chart"; import { EmptyState } from "@/components/empty-state"; // constants import { EmptyStateType } from "@/constants/empty-state"; +import { useCycle } from "@/hooks/store"; export type ActiveCycleProductivityProps = { workspaceSlug: string; @@ -14,51 +15,113 @@ export type ActiveCycleProductivityProps = { cycle: ICycle; }; +const cycleBurnDownChartOptions = [ + { value: "burndown", label: "Issues" }, + { value: "points", label: "Points" }, +]; + export const ActiveCycleProductivity: FC = (props) => { const { workspaceSlug, projectId, cycle } = props; + // hooks + const { getPlotTypeByCycleId, setPlotType, fetchCycleDetails } = useCycle(); + // state + const [loader, setLoader] = useState(false); + // derived values + const plotType: TCyclePlotType = (cycle && getPlotTypeByCycleId(cycle.id)) || "burndown"; + + const onChange = async (value: TCyclePlotType) => { + if (!workspaceSlug || !projectId || !cycle || !cycle.id) return; + setPlotType(cycle.id, value); + try { + setLoader(true); + await fetchCycleDetails(workspaceSlug, projectId, cycle.id); + setLoader(false); + } catch (error) { + setLoader(false); + setPlotType(cycle.id, plotType); + } + }; + + const chartDistributionData = plotType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined; + const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; return ( - +
-

Issue burndown

+ +

Issue burndown

+ +
+ {cycleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"}} + onChange={onChange} + maxHeight="lg" + > + {cycleBurnDownChartOptions.map((item) => ( + + {item.label} + + ))} + + {loader && } +
- {cycle.total_issues > 0 ? ( - <> -
-
-
-
- - Ideal -
-
- - Current + + {cycle.total_issues > 0 ? ( + <> +
+
+
+
+ + Ideal +
+
+ + Current +
+ {plotType === "points" ? ( + {`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`} + ) : ( + {`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`} + )} +
+ +
+ {completionChartDistributionData && ( + + {plotType === "points" ? ( + + ) : ( + + )} + + )}
- {`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}
-
- + + ) : ( + <> +
+
-
- - ) : ( - <> -
- -
- - )} - + + )} + +
); }; diff --git a/web/core/components/cycles/analytics-sidebar/index.ts b/web/core/components/cycles/analytics-sidebar/index.ts new file mode 100644 index 000000000..c509152a2 --- /dev/null +++ b/web/core/components/cycles/analytics-sidebar/index.ts @@ -0,0 +1,3 @@ +export * from "./root"; +export * from "./issue-progress"; +export * from "./progress-stats"; diff --git a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx new file mode 100644 index 000000000..3c20c93dd --- /dev/null +++ b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { FC, Fragment, useCallback, useMemo, useState } from "react"; +import isEmpty from "lodash/isEmpty"; +import isEqual from "lodash/isEqual"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +import { AlertCircle, ChevronUp, ChevronDown } from "lucide-react"; +import { Disclosure, Transition } from "@headlessui/react"; +import { ICycle, IIssueFilterOptions, TCyclePlotType, TProgressSnapshot } from "@plane/types"; +import { CustomSelect, Spinner } from "@plane/ui"; +// components +import ProgressChart from "@/components/core/sidebar/progress-chart"; +import { CycleProgressStats } from "@/components/cycles"; +// constants +import { EEstimateSystem } from "@/constants/estimates"; +import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; +// helpers +import { getDate } from "@/helpers/date-time.helper"; +// hooks +import { useIssues, useCycle, useProjectEstimates } from "@/hooks/store"; + +type TCycleAnalyticsProgress = { + workspaceSlug: string; + projectId: string; + cycleId: string; +}; + +const cycleBurnDownChartOptions = [ + { value: "burndown", label: "Issues" }, + { value: "points", label: "Points" }, +]; + +const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => { + if (!cycleDetails || cycleDetails === null) return cycleDetails; + + const updatedCycleDetails: any = { ...cycleDetails }; + if (!isEmpty(cycleDetails.progress_snapshot)) { + Object.keys(cycleDetails.progress_snapshot || {}).forEach((key) => { + const currentKey = key as keyof TProgressSnapshot; + if (!isEmpty(cycleDetails.progress_snapshot) && !isEmpty(updatedCycleDetails)) { + updatedCycleDetails[currentKey as keyof ICycle] = cycleDetails?.progress_snapshot?.[currentKey]; + } + }); + } + return updatedCycleDetails; +}; + +export const CycleAnalyticsProgress: FC = observer((props) => { + // props + const { workspaceSlug, projectId, cycleId } = props; + // router + const searchParams = useSearchParams(); + const peekCycle = searchParams.get("peekCycle") || undefined; + // hooks + const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates(); + const { getPlotTypeByCycleId, setPlotType, getCycleById, fetchCycleDetails } = useCycle(); + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.CYCLE); + // state + const [loader, setLoader] = useState(false); + + // derived values + const cycleDetails = validateCycleSnapshot(getCycleById(cycleId)); + const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId); + const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false; + const estimateDetails = + isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId); + const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS; + + const completedIssues = cycleDetails?.completed_issues || 0; + const totalIssues = cycleDetails?.total_issues || 0; + const completedEstimatePoints = cycleDetails?.completed_estimate_points || 0; + const totalEstimatePoints = cycleDetails?.total_estimate_points || 0; + + const progressHeaderPercentage = cycleDetails + ? plotType === "points" + ? completedEstimatePoints != 0 && totalEstimatePoints != 0 + ? Math.round((completedEstimatePoints / totalEstimatePoints) * 100) + : 0 + : completedIssues != 0 && completedIssues != 0 + ? Math.round((completedIssues / totalIssues) * 100) + : 0 + : 0; + + const chartDistributionData = + plotType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined; + const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; + + const groupedIssues = useMemo( + () => ({ + backlog: plotType === "points" ? cycleDetails?.backlog_estimate_points || 0 : cycleDetails?.backlog_issues || 0, + unstarted: + plotType === "points" ? cycleDetails?.unstarted_estimate_points || 0 : cycleDetails?.unstarted_issues || 0, + started: plotType === "points" ? cycleDetails?.started_estimate_points || 0 : cycleDetails?.started_issues || 0, + completed: + plotType === "points" ? cycleDetails?.completed_estimate_points || 0 : cycleDetails?.completed_issues || 0, + cancelled: + plotType === "points" ? cycleDetails?.cancelled_estimate_points || 0 : cycleDetails?.cancelled_issues || 0, + }), + [plotType, cycleDetails] + ); + + const cycleStartDate = getDate(cycleDetails?.start_date); + const cycleEndDate = getDate(cycleDetails?.end_date); + const isCycleStartDateValid = cycleStartDate && cycleStartDate <= new Date(); + const isCycleEndDateValid = cycleStartDate && cycleEndDate && cycleEndDate >= cycleStartDate; + const isCycleDateValid = isCycleStartDateValid && isCycleEndDateValid; + + // handlers + const onChange = async (value: TCyclePlotType) => { + setPlotType(cycleId, value); + if (!workspaceSlug || !projectId || !cycleId) return; + try { + setLoader(true); + await fetchCycleDetails(workspaceSlug, projectId, cycleId); + setLoader(false); + } catch (error) { + setLoader(false); + setPlotType(cycleId, plotType); + } + }; + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + + let newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + if (key === "state") { + if (isEqual(newValues, value)) newValues = []; + else newValues = value; + } else { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + } + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.FILTERS, + { [key]: newValues }, + cycleId + ); + }, + [workspaceSlug, projectId, cycleId, issueFilters, updateFilters] + ); + + if (!cycleDetails) return <>; + return ( +
+ + {({ open }) => ( +
+ {/* progress bar header */} + {isCycleDateValid ? ( +
+ +
Progress
+ {progressHeaderPercentage > 0 && ( +
{`${progressHeaderPercentage}%`}
+ )} +
+ {isCurrentEstimateTypeIsPoints && ( + <> +
+ {cycleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"} + } + onChange={onChange} + maxHeight="lg" + > + {cycleBurnDownChartOptions.map((item) => ( + + {item.label} + + ))} + +
+ {loader && } + + )} + + {open ? ( + +
+ ) : ( +
+
Progress
+
+ + + {cycleDetails?.start_date && cycleDetails?.end_date + ? "This cycle isn't active yet." + : "Invalid date. Please enter valid date."} + +
+
+ )} + + + + {/* progress burndown chart */} +
+
+
+ + Ideal +
+
+ + Current +
+
+ {cycleStartDate && cycleEndDate && completionChartDistributionData && ( + + {plotType === "points" ? ( + + ) : ( + + )} + + )} +
+ + {/* progress detailed view */} + {chartDistributionData && ( +
+ +
+ )} +
+
+
+ )} +
+
+ ); +}); diff --git a/web/core/components/cycles/analytics-sidebar/progress-stats.tsx b/web/core/components/cycles/analytics-sidebar/progress-stats.tsx new file mode 100644 index 000000000..dc0efb255 --- /dev/null +++ b/web/core/components/cycles/analytics-sidebar/progress-stats.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import { Tab } from "@headlessui/react"; +import { + IIssueFilterOptions, + IIssueFilters, + TCycleDistribution, + TCycleEstimateDistribution, + TCyclePlotType, + TStateGroups, +} from "@plane/types"; +import { Avatar, StateGroupIcon } from "@plane/ui"; +// components +import { SingleProgressStats } from "@/components/core"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useProjectState } from "@/hooks/store"; +import useLocalStorage from "@/hooks/use-local-storage"; +// public +import emptyLabel from "@/public/empty-state/empty_label.svg"; +import emptyMembers from "@/public/empty-state/empty_members.svg"; + +// assignee types +type TAssigneeData = { + id: string | undefined; + title: string | undefined; + avatar: string | undefined; + completed: number; + total: number; +}[]; + +type TAssigneeStatComponent = { + distribution: TAssigneeData; + isEditable?: boolean; + filters?: IIssueFilters | undefined; + handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; +}; + +// labelTypes +type TLabelData = { + id: string | undefined; + title: string | undefined; + color: string | undefined; + completed: number; + total: number; +}[]; + +type TLabelStatComponent = { + distribution: TLabelData; + isEditable?: boolean; + filters?: IIssueFilters | undefined; + handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; +}; + +// stateTypes +type TStateData = { + state: string | undefined; + completed: number; + total: number; +}[]; + +type TStateStatComponent = { + distribution: TStateData; + totalIssuesCount: number; + isEditable?: boolean; + handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; +}; + +export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => { + const { distribution, isEditable, filters, handleFiltersUpdate } = props; + return ( +
+ {distribution && distribution.length > 0 ? ( + distribution.map((assignee, index) => { + if (assignee?.id) + return ( + + + {assignee?.title ?? ""} +
+ } + completed={assignee?.completed ?? 0} + total={assignee?.total ?? 0} + {...(isEditable && { + onClick: () => handleFiltersUpdate("assignees", assignee.id ?? ""), + selected: filters?.filters?.assignees?.includes(assignee.id ?? ""), + })} + /> + ); + else + return ( + +
+ User +
+ No assignee +
+ } + completed={assignee?.completed ?? 0} + total={assignee?.total ?? 0} + /> + ); + }) + ) : ( +
+
+ empty members +
+
No assignees yet
+
+ )} +
+ ); +}); + +export const LabelStatComponent = observer((props: TLabelStatComponent) => { + const { distribution, isEditable, filters, handleFiltersUpdate } = props; + return ( +
+ {distribution && distribution.length > 0 ? ( + distribution.map((label, index) => { + if (label.id) { + return ( + + + {label.title ?? "No labels"} +
+ } + completed={label.completed} + total={label.total} + {...(isEditable && { + onClick: () => handleFiltersUpdate("labels", label.id ?? ""), + selected: filters?.filters?.labels?.includes(label.id ?? `no-label-${index}`), + })} + /> + ); + } else { + return ( + + + {label.title ?? "No labels"} +
+ } + completed={label.completed} + total={label.total} + /> + ); + } + }) + ) : ( +
+
+ empty label +
+
No labels yet
+
+ )} +
+ ); +}); + +export const StateStatComponent = observer((props: TStateStatComponent) => { + const { distribution, isEditable, totalIssuesCount, handleFiltersUpdate } = props; + // hooks + const { groupedProjectStates } = useProjectState(); + // derived values + const getStateGroupState = (stateGroup: string) => { + const stateGroupStates = groupedProjectStates?.[stateGroup]; + const stateGroupStatesId = stateGroupStates?.map((state) => state.id); + return stateGroupStatesId; + }; + + return ( +
+ {distribution.map((group, index) => ( + + + {group.state} +
+ } + completed={group.completed} + total={totalIssuesCount} + {...(isEditable && { + onClick: () => group.state && handleFiltersUpdate("state", getStateGroupState(group.state) ?? []), + })} + /> + ))} + + ); +}); + +const progressStats = [ + { + key: "stat-assignees", + title: "Assignees", + }, + { + key: "stat-labels", + title: "Labels", + }, + { + key: "stat-states", + title: "States", + }, +]; + +type TCycleProgressStats = { + cycleId: string; + plotType: TCyclePlotType; + distribution: TCycleDistribution | TCycleEstimateDistribution | undefined; + groupedIssues: Record; + totalIssuesCount: number; + isEditable?: boolean; + filters?: IIssueFilters | undefined; + handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; + size?: "xs" | "sm"; + roundedTab?: boolean; + noBackground?: boolean; +}; + +export const CycleProgressStats: FC = observer((props) => { + const { + cycleId, + plotType, + distribution, + groupedIssues, + totalIssuesCount, + isEditable = false, + filters, + handleFiltersUpdate, + size = "sm", + roundedTab = false, + noBackground = false, + } = props; + // hooks + const { storedValue: currentTab, setValue: setCycleTab } = useLocalStorage( + `cycle-analytics-tab-${cycleId}`, + "stat-assignees" + ); + // derived values + const currentTabIndex = (tab: string): number => progressStats.findIndex((stat) => stat.key === tab); + + const currentDistribution = distribution as TCycleDistribution; + const currentEstimateDistribution = distribution as TCycleEstimateDistribution; + + const distributionAssigneeData: TAssigneeData = + plotType === "burndown" + ? (currentDistribution?.assignees || []).map((assignee) => ({ + id: assignee?.assignee_id || undefined, + title: assignee?.display_name || undefined, + avatar: assignee?.avatar || undefined, + completed: assignee.completed_issues, + total: assignee.total_issues, + })) + : (currentEstimateDistribution?.assignees || []).map((assignee) => ({ + id: assignee?.assignee_id || undefined, + title: assignee?.display_name || undefined, + avatar: assignee?.avatar || undefined, + completed: assignee.completed_estimates, + total: assignee.total_estimates, + })); + + const distributionLabelData: TLabelData = + plotType === "burndown" + ? (currentDistribution?.labels || []).map((label) => ({ + id: label?.label_id || undefined, + title: label?.label_name || undefined, + color: label?.color || undefined, + completed: label.completed_issues, + total: label.total_issues, + })) + : (currentEstimateDistribution?.labels || []).map((label) => ({ + id: label?.label_id || undefined, + title: label?.label_name || undefined, + color: label?.color || undefined, + completed: label.completed_estimates, + total: label.total_estimates, + })); + + const distributionStateData: TStateData = Object.keys(groupedIssues || {}).map((state) => ({ + state: state, + completed: groupedIssues?.[state] || 0, + total: totalIssuesCount || 0, + })); + + return ( +
+ + + {progressStats.map((stat) => ( + setCycleTab(stat.key)} + > + {stat.title} + + ))} + + + + + + + + + + + + + +
+ ); +}); diff --git a/web/core/components/cycles/sidebar.tsx b/web/core/components/cycles/analytics-sidebar/root.tsx similarity index 54% rename from web/core/components/cycles/sidebar.tsx rename to web/core/components/cycles/analytics-sidebar/root.tsx index 1d2f3559f..9a800af7b 100644 --- a/web/core/components/cycles/sidebar.tsx +++ b/web/core/components/cycles/analytics-sidebar/root.tsx @@ -1,42 +1,29 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import isEmpty from "lodash/isEmpty"; -import isEqual from "lodash/isEqual"; import { observer } from "mobx-react"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { Controller, useForm } from "react-hook-form"; // icons -import { - ArchiveRestoreIcon, - ChevronDown, - LinkIcon, - Trash2, - AlertCircle, - ChevronRight, - CalendarClock, - SquareUser, -} from "lucide-react"; -import { Disclosure, Transition } from "@headlessui/react"; +import { ArchiveRestoreIcon, LinkIcon, Trash2, ChevronRight, CalendarClock, SquareUser } from "lucide-react"; // types -import { ICycle, IIssueFilterOptions } from "@plane/types"; +import { ICycle } from "@plane/types"; // ui import { Avatar, ArchiveIcon, CustomMenu, Loader, LayersIcon, TOAST_TYPE, setToast, TextArea } from "@plane/ui"; // components -import { SidebarProgressStats } from "@/components/core"; -import ProgressChart from "@/components/core/sidebar/progress-chart"; -import { ArchiveCycleModal, CycleDeleteModal } from "@/components/cycles"; +import { ArchiveCycleModal, CycleDeleteModal, CycleAnalyticsProgress } from "@/components/cycles"; import { DateRangeDropdown } from "@/components/dropdowns"; // constants import { CYCLE_STATUS } from "@/constants/cycle"; +import { EEstimateSystem } from "@/constants/estimates"; import { CYCLE_UPDATED } from "@/constants/event-tracker"; -import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; import { EUserWorkspaceRoles } from "@/constants/workspace"; // helpers import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; // hooks -import { useEventTracker, useCycle, useUser, useMember, useIssues } from "@/hooks/store"; +import { useEventTracker, useCycle, useUser, useMember, useProjectEstimates } from "@/hooks/store"; // services import { CycleService } from "@/services/cycle.service"; @@ -63,10 +50,9 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { // router const router = useRouter(); const { workspaceSlug, projectId } = useParams(); - const searchParams = useSearchParams(); - const peekCycle = searchParams.get("peekCycle"); // store hooks const { setTrackElement, captureCycleEvent } = useEventTracker(); + const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates(); const { membership: { currentProjectRole }, } = useUser(); @@ -197,58 +183,9 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { } }; - const { - issuesFilter: { issueFilters, updateFilters }, - } = useIssues(EIssuesStoreType.CYCLE); - - const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !projectId) return; - let newValues = issueFilters?.filters?.[key] ?? []; - - if (Array.isArray(value)) { - if (key === "state") { - if (isEqual(newValues, value)) newValues = []; - else newValues = value; - } else { - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); - } - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - - updateFilters( - workspaceSlug.toString(), - projectId.toString(), - EIssueFilterType.FILTERS, - { [key]: newValues }, - cycleId - ); - }, - [workspaceSlug, projectId, cycleId, issueFilters, updateFilters] - ); - const cycleStatus = cycleDetails?.status?.toLocaleLowerCase(); const isCompleted = cycleStatus === "completed"; - const startDate = getDate(cycleDetails?.start_date); - const endDate = getDate(cycleDetails?.end_date); - - const isStartValid = startDate && startDate <= new Date(); - const isEndValid = endDate && startDate && endDate >= startDate; - - const progressPercentage = cycleDetails - ? isCompleted && cycleDetails?.progress_snapshot - ? Math.round( - (cycleDetails.progress_snapshot.completed_issues / cycleDetails.progress_snapshot.total_issues) * 100 - ) - : Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100) - : null; - if (!cycleDetails) return ( @@ -266,6 +203,17 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); + const areEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId.toString()); + const estimateType = areEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId); + // NOTE: validate if the cycle is snapshot and the estimate system is points + const isEstimatePointValid = isEmpty(cycleDetails?.progress_snapshot || {}) + ? estimateType && estimateType?.type == EEstimateSystem.POINTS + ? true + : false + : isEmpty(cycleDetails?.progress_snapshot?.estimate_distribution || {}) + ? false + : true; + const issueCount = isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? cycleDetails.progress_snapshot.total_issues === 0 @@ -275,6 +223,15 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { ? "0 Issue" : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + const issueEstimatePointCount = + isCompleted && !isEmpty(cycleDetails.progress_snapshot) + ? cycleDetails.progress_snapshot.total_issues === 0 + ? "0 Issue" + : `${cycleDetails.progress_snapshot.completed_estimate_points}/${cycleDetails.progress_snapshot.total_estimate_points}` + : cycleDetails.total_issues === 0 + ? "0 Issue" + : `${cycleDetails.completed_estimate_points}/${cycleDetails.total_estimate_points}`; + const daysLeft = findHowManyDaysLeft(cycleDetails.end_date); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; @@ -456,160 +413,30 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { {issueCount} + + {/** + * NOTE: Render this section when estimate points of he projects is enabled and the estimate system is points + */} + {isEstimatePointValid && ( +
+
+ + Points +
+
+ {issueEstimatePointCount} +
+
+ )} -
-
- - {({ open }) => ( -
- -
- Progress -
- -
- {progressPercentage ? ( - - {progressPercentage ? `${progressPercentage}%` : ""} - - ) : ( - "" - )} - {isStartValid && isEndValid ? ( -
-
- - -
- {isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? ( - <> - {cycleDetails.progress_snapshot.distribution?.completion_chart && - cycleDetails.start_date && - cycleDetails.end_date && ( -
-
-
-
- - Ideal -
-
- - Current -
-
-
-
- -
-
- )} - - ) : ( - <> - {cycleDetails.distribution?.completion_chart && - cycleDetails.start_date && - cycleDetails.end_date && ( -
-
-
-
- - Ideal -
-
- - Current -
-
-
-
- -
-
- )} - - )} - {/* stats */} - {isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? ( - <> - {cycleDetails.progress_snapshot.total_issues > 0 && - cycleDetails.progress_snapshot.distribution && ( -
- -
- )} - - ) : ( - <> - {cycleDetails.total_issues > 0 && cycleDetails.distribution && ( -
- -
- )} - - )} -
-
-
-
- )} -
-
-
+ {workspaceSlug && projectId && cycleDetails?.id && ( + + )} ); diff --git a/web/core/components/cycles/cycle-peek-overview.tsx b/web/core/components/cycles/cycle-peek-overview.tsx index 3a5c1d9b0..9779e8b5d 100644 --- a/web/core/components/cycles/cycle-peek-overview.tsx +++ b/web/core/components/cycles/cycle-peek-overview.tsx @@ -5,7 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { generateQueryParams } from "@/helpers/router.helper"; import { useCycle } from "@/hooks/store"; // components -import { CycleDetailsSidebar } from "./sidebar"; +import { CycleDetailsSidebar } from "./"; type Props = { projectId: string; @@ -25,7 +25,7 @@ export const CyclePeekOverview: React.FC = observer(({ projectId, workspa const { fetchCycleDetails, fetchArchivedCycleDetails } = useCycle(); const handleClose = () => { - const query = generateQueryParams(searchParams, ['peekCycle']); + const query = generateQueryParams(searchParams, ["peekCycle"]); router.push(`${pathname}?${query}`); }; diff --git a/web/core/components/cycles/index.ts b/web/core/components/cycles/index.ts index 12fc7564d..f286b39e6 100644 --- a/web/core/components/cycles/index.ts +++ b/web/core/components/cycles/index.ts @@ -11,10 +11,10 @@ export * from "./delete-modal"; export * from "./form"; export * from "./modal"; export * from "./quick-actions"; -export * from "./sidebar"; export * from "./transfer-issues-modal"; export * from "./transfer-issues"; export * from "./cycles-view-header"; +export * from "./analytics-sidebar"; // archived cycles export * from "./archived-cycles"; diff --git a/web/core/components/modules/analytics-sidebar/index.ts b/web/core/components/modules/analytics-sidebar/index.ts new file mode 100644 index 000000000..c509152a2 --- /dev/null +++ b/web/core/components/modules/analytics-sidebar/index.ts @@ -0,0 +1,3 @@ +export * from "./root"; +export * from "./issue-progress"; +export * from "./progress-stats"; diff --git a/web/core/components/modules/analytics-sidebar/issue-progress.tsx b/web/core/components/modules/analytics-sidebar/issue-progress.tsx new file mode 100644 index 000000000..bc222c882 --- /dev/null +++ b/web/core/components/modules/analytics-sidebar/issue-progress.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { FC, Fragment, useCallback, useMemo, useState } from "react"; +import isEqual from "lodash/isEqual"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +import { AlertCircle, ChevronUp, ChevronDown } from "lucide-react"; +import { Disclosure, Transition } from "@headlessui/react"; +import { IIssueFilterOptions, TModulePlotType } from "@plane/types"; +import { CustomSelect, Spinner } from "@plane/ui"; +// components +import ProgressChart from "@/components/core/sidebar/progress-chart"; +import { ModuleProgressStats } from "@/components/modules"; +// constants +import { EEstimateSystem } from "@/constants/estimates"; +import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; +// helpers +import { getDate } from "@/helpers/date-time.helper"; +// hooks +import { useIssues, useModule, useProjectEstimates } from "@/hooks/store"; + +type TModuleAnalyticsProgress = { + workspaceSlug: string; + projectId: string; + moduleId: string; +}; + +const moduleBurnDownChartOptions = [ + { value: "burndown", label: "Issues" }, + { value: "points", label: "Points" }, +]; + +export const ModuleAnalyticsProgress: FC = observer((props) => { + // props + const { workspaceSlug, projectId, moduleId } = props; + // router + const searchParams = useSearchParams(); + const peekModule = searchParams.get("peekModule") || undefined; + // hooks + const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates(); + const { getPlotTypeByModuleId, setPlotType, getModuleById, fetchModuleDetails } = useModule(); + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.MODULE); + // state + const [loader, setLoader] = useState(false); + + // derived values + const moduleDetails = getModuleById(moduleId); + const plotType: TModulePlotType = getPlotTypeByModuleId(moduleId); + const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false; + const estimateDetails = + isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId); + const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS; + + const completedIssues = moduleDetails?.completed_issues || 0; + const totalIssues = moduleDetails?.total_issues || 0; + const completedEstimatePoints = moduleDetails?.completed_estimate_points || 0; + const totalEstimatePoints = moduleDetails?.total_estimate_points || 0; + + const progressHeaderPercentage = moduleDetails + ? plotType === "points" + ? completedEstimatePoints != 0 && totalEstimatePoints != 0 + ? Math.round((completedEstimatePoints / totalEstimatePoints) * 100) + : 0 + : completedIssues != 0 && completedIssues != 0 + ? Math.round((completedIssues / totalIssues) * 100) + : 0 + : 0; + + const chartDistributionData = + plotType === "points" ? moduleDetails?.estimate_distribution : moduleDetails?.distribution || undefined; + const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; + + const groupedIssues = useMemo( + () => ({ + backlog: plotType === "points" ? moduleDetails?.backlog_estimate_points || 0 : moduleDetails?.backlog_issues || 0, + unstarted: + plotType === "points" ? moduleDetails?.unstarted_estimate_points || 0 : moduleDetails?.unstarted_issues || 0, + started: plotType === "points" ? moduleDetails?.started_estimate_points || 0 : moduleDetails?.started_issues || 0, + completed: + plotType === "points" ? moduleDetails?.completed_estimate_points || 0 : moduleDetails?.completed_issues || 0, + cancelled: + plotType === "points" ? moduleDetails?.cancelled_estimate_points || 0 : moduleDetails?.cancelled_issues || 0, + }), + [plotType, moduleDetails] + ); + + const moduleStartDate = getDate(moduleDetails?.start_date); + const moduleEndDate = getDate(moduleDetails?.target_date); + const isModuleStartDateValid = moduleStartDate && moduleStartDate <= new Date(); + const isModuleEndDateValid = moduleStartDate && moduleEndDate && moduleEndDate >= moduleStartDate; + const isModuleDateValid = isModuleStartDateValid && isModuleEndDateValid; + + // handlers + const onChange = async (value: TModulePlotType) => { + setPlotType(moduleId, value); + if (!workspaceSlug || !projectId || !moduleId) return; + try { + setLoader(true); + await fetchModuleDetails(workspaceSlug, projectId, moduleId); + setLoader(false); + } catch (error) { + setLoader(false); + setPlotType(moduleId, plotType); + } + }; + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + + let newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + if (key === "state") { + if (isEqual(newValues, value)) newValues = []; + else newValues = value; + } else { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + } + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.FILTERS, + { [key]: newValues }, + moduleId + ); + }, + [workspaceSlug, projectId, moduleId, issueFilters, updateFilters] + ); + + if (!moduleDetails) return <>; + return ( +
+ + {({ open }) => ( +
+ {/* progress bar header */} + {isModuleDateValid ? ( +
+ +
Progress
+ {progressHeaderPercentage > 0 && ( +
{`${progressHeaderPercentage}%`}
+ )} +
+ {isCurrentEstimateTypeIsPoints && ( + <> +
+ {moduleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"} + } + onChange={onChange} + maxHeight="lg" + > + {moduleBurnDownChartOptions.map((item) => ( + + {item.label} + + ))} + +
+ {loader && } + + )} + + {open ? ( + +
+ ) : ( +
+
Progress
+
+ + + {moduleDetails?.start_date && moduleDetails?.target_date + ? "This module isn't active yet." + : "Invalid date. Please enter valid date."} + +
+
+ )} + + + + {/* progress burndown chart */} +
+
+
+ + Ideal +
+
+ + Current +
+
+ {moduleStartDate && moduleEndDate && completionChartDistributionData && ( + + {plotType === "points" ? ( + + ) : ( + + )} + + )} +
+ + {/* progress detailed view */} + {chartDistributionData && ( +
+ +
+ )} +
+
+
+ )} +
+
+ ); +}); diff --git a/web/core/components/modules/analytics-sidebar/progress-stats.tsx b/web/core/components/modules/analytics-sidebar/progress-stats.tsx new file mode 100644 index 000000000..712928f14 --- /dev/null +++ b/web/core/components/modules/analytics-sidebar/progress-stats.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import { Tab } from "@headlessui/react"; +import { + IIssueFilterOptions, + IIssueFilters, + TModuleDistribution, + TModuleEstimateDistribution, + TModulePlotType, + TStateGroups, +} from "@plane/types"; +import { Avatar, StateGroupIcon } from "@plane/ui"; +// components +import { SingleProgressStats } from "@/components/core"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useProjectState } from "@/hooks/store"; +import useLocalStorage from "@/hooks/use-local-storage"; +// public +import emptyLabel from "@/public/empty-state/empty_label.svg"; +import emptyMembers from "@/public/empty-state/empty_members.svg"; + +// assignee types +type TAssigneeData = { + id: string | undefined; + title: string | undefined; + avatar: string | undefined; + completed: number; + total: number; +}[]; + +type TAssigneeStatComponent = { + distribution: TAssigneeData; + isEditable?: boolean; + filters?: IIssueFilters | undefined; + handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; +}; + +// labelTypes +type TLabelData = { + id: string | undefined; + title: string | undefined; + color: string | undefined; + completed: number; + total: number; +}[]; + +type TLabelStatComponent = { + distribution: TLabelData; + isEditable?: boolean; + filters?: IIssueFilters | undefined; + handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; +}; + +// stateTypes +type TStateData = { + state: string | undefined; + completed: number; + total: number; +}[]; + +type TStateStatComponent = { + distribution: TStateData; + totalIssuesCount: number; + isEditable?: boolean; + handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; +}; + +export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => { + const { distribution, isEditable, filters, handleFiltersUpdate } = props; + return ( +
+ {distribution && distribution.length > 0 ? ( + distribution.map((assignee, index) => { + if (assignee?.id) + return ( + + + {assignee?.title ?? ""} +
+ } + completed={assignee?.completed ?? 0} + total={assignee?.total ?? 0} + {...(isEditable && { + onClick: () => handleFiltersUpdate("assignees", assignee.id ?? ""), + selected: filters?.filters?.assignees?.includes(assignee.id ?? ""), + })} + /> + ); + else + return ( + +
+ User +
+ No assignee + + } + completed={assignee?.completed ?? 0} + total={assignee?.total ?? 0} + /> + ); + }) + ) : ( +
+
+ empty members +
+
No assignees yet
+
+ )} + + ); +}); + +export const LabelStatComponent = observer((props: TLabelStatComponent) => { + const { distribution, isEditable, filters, handleFiltersUpdate } = props; + return ( +
+ {distribution && distribution.length > 0 ? ( + distribution.map((label, index) => { + if (label.id) { + return ( + + + {label.title ?? "No labels"} +
+ } + completed={label.completed} + total={label.total} + {...(isEditable && { + onClick: () => handleFiltersUpdate("labels", label.id ?? ""), + selected: filters?.filters?.labels?.includes(label.id ?? `no-label-${index}`), + })} + /> + ); + } else { + return ( + + + {label.title ?? "No labels"} + + } + completed={label.completed} + total={label.total} + /> + ); + } + }) + ) : ( +
+
+ empty label +
+
No labels yet
+
+ )} + + ); +}); + +export const StateStatComponent = observer((props: TStateStatComponent) => { + const { distribution, isEditable, totalIssuesCount, handleFiltersUpdate } = props; + // hooks + const { groupedProjectStates } = useProjectState(); + // derived values + const getStateGroupState = (stateGroup: string) => { + const stateGroupStates = groupedProjectStates?.[stateGroup]; + const stateGroupStatesId = stateGroupStates?.map((state) => state.id); + return stateGroupStatesId; + }; + + return ( +
+ {distribution.map((group, index) => ( + + + {group.state} +
+ } + completed={group.completed} + total={totalIssuesCount} + {...(isEditable && { + onClick: () => group.state && handleFiltersUpdate("state", getStateGroupState(group.state) ?? []), + })} + /> + ))} + + ); +}); + +const progressStats = [ + { + key: "stat-assignees", + title: "Assignees", + }, + { + key: "stat-labels", + title: "Labels", + }, + { + key: "stat-states", + title: "States", + }, +]; + +type TModuleProgressStats = { + moduleId: string; + plotType: TModulePlotType; + distribution: TModuleDistribution | TModuleEstimateDistribution | undefined; + groupedIssues: Record; + totalIssuesCount: number; + isEditable?: boolean; + filters?: IIssueFilters | undefined; + handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; + size?: "xs" | "sm"; + roundedTab?: boolean; + noBackground?: boolean; +}; + +export const ModuleProgressStats: FC = observer((props) => { + const { + moduleId, + plotType, + distribution, + groupedIssues, + totalIssuesCount, + isEditable = false, + filters, + handleFiltersUpdate, + size = "sm", + roundedTab = false, + noBackground = false, + } = props; + // hooks + const { storedValue: currentTab, setValue: setModuleTab } = useLocalStorage( + `module-analytics-tab-${moduleId}`, + "stat-assignees" + ); + // derived values + const currentTabIndex = (tab: string): number => progressStats.findIndex((stat) => stat.key === tab); + + const currentDistribution = distribution as TModuleDistribution; + const currentEstimateDistribution = distribution as TModuleEstimateDistribution; + + const distributionAssigneeData: TAssigneeData = + plotType === "burndown" + ? (currentDistribution?.assignees || []).map((assignee) => ({ + id: assignee?.assignee_id || undefined, + title: assignee?.display_name || undefined, + avatar: assignee?.avatar || undefined, + completed: assignee.completed_issues, + total: assignee.total_issues, + })) + : (currentEstimateDistribution?.assignees || []).map((assignee) => ({ + id: assignee?.assignee_id || undefined, + title: assignee?.display_name || undefined, + avatar: assignee?.avatar || undefined, + completed: assignee.completed_estimates, + total: assignee.total_estimates, + })); + + const distributionLabelData: TLabelData = + plotType === "burndown" + ? (currentDistribution?.labels || []).map((label) => ({ + id: label?.label_id || undefined, + title: label?.label_name || undefined, + color: label?.color || undefined, + completed: label.completed_issues, + total: label.total_issues, + })) + : (currentEstimateDistribution?.labels || []).map((label) => ({ + id: label?.label_id || undefined, + title: label?.label_name || undefined, + color: label?.color || undefined, + completed: label.completed_estimates, + total: label.total_estimates, + })); + + const distributionStateData: TStateData = Object.keys(groupedIssues || {}).map((state) => ({ + state: state, + completed: groupedIssues?.[state] || 0, + total: totalIssuesCount || 0, + })); + + return ( +
+ + + {progressStats.map((stat) => ( + setModuleTab(stat.key)} + > + {stat.title} + + ))} + + + + + + + + + + + + + +
+ ); +}); diff --git a/web/core/components/modules/sidebar.tsx b/web/core/components/modules/analytics-sidebar/root.tsx similarity index 75% rename from web/core/components/modules/sidebar.tsx rename to web/core/components/modules/analytics-sidebar/root.tsx index b065dca04..b195d9fa3 100644 --- a/web/core/components/modules/sidebar.tsx +++ b/web/core/components/modules/analytics-sidebar/root.tsx @@ -1,12 +1,10 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; -import isEqual from "lodash/isEqual"; +import React, { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { Controller, useForm } from "react-hook-form"; import { - AlertCircle, ArchiveRestoreIcon, CalendarClock, ChevronDown, @@ -19,7 +17,7 @@ import { Users, } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; -import { IIssueFilterOptions, ILinkDetails, IModule, ModuleLink } from "@plane/types"; +import { ILinkDetails, IModule, ModuleLink } from "@plane/types"; // ui import { CustomMenu, @@ -33,26 +31,24 @@ import { TextArea, } from "@plane/ui"; // components -import { LinkModal, LinksList, SidebarProgressStats } from "@/components/core"; -import ProgressChart from "@/components/core/sidebar/progress-chart"; +import { LinkModal, LinksList } from "@/components/core"; import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns"; -import { ArchiveModuleModal, DeleteModuleModal } from "@/components/modules"; +import { ArchiveModuleModal, DeleteModuleModal, ModuleAnalyticsProgress } from "@/components/modules"; // constant +import { EEstimateSystem } from "@/constants/estimates"; import { MODULE_LINK_CREATED, MODULE_LINK_DELETED, MODULE_LINK_UPDATED, MODULE_UPDATED, } from "@/constants/event-tracker"; -import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; import { MODULE_STATUS } from "@/constants/module"; import { EUserProjectRoles } from "@/constants/project"; // helpers import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; // hooks -import { useModule, useUser, useEventTracker, useIssues } from "@/hooks/store"; -// types +import { useModule, useUser, useEventTracker, useProjectEstimates } from "@/hooks/store"; const defaultValues: Partial = { lead_id: "", @@ -69,7 +65,7 @@ type Props = { }; // TODO: refactor this component -export const ModuleDetailsSidebar: React.FC = observer((props) => { +export const ModuleAnalyticsSidebar: React.FC = observer((props) => { const { moduleId, handleClose, isArchived } = props; // states const [moduleDeleteModal, setModuleDeleteModal] = useState(false); @@ -79,8 +75,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { // router const router = useRouter(); const { workspaceSlug, projectId } = useParams(); - const searchParams = useSearchParams(); - const peekModule = searchParams.get("peekModule"); + // store hooks const { membership: { currentProjectRole }, @@ -88,14 +83,17 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink, restoreModule } = useModule(); const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker(); - const { - issuesFilter: { issueFilters, updateFilters }, - } = useIssues(EIssuesStoreType.MODULE); - const moduleDetails = getModuleById(moduleId); + const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates(); + // derived values + const moduleDetails = getModuleById(moduleId); const moduleState = moduleDetails?.status?.toLocaleLowerCase(); const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState); + const areEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId.toString()); + const estimateType = areEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId); + const isEstimatePointValid = estimateType && estimateType?.type == EEstimateSystem.POINTS ? true : false; + const { reset, control } = useForm({ defaultValues, }); @@ -254,46 +252,6 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { }); }, [moduleDetails, reset]); - const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !projectId) return; - let newValues = issueFilters?.filters?.[key] ?? []; - - if (Array.isArray(value)) { - if (key === "state") { - if (isEqual(newValues, value)) newValues = []; - else newValues = value; - } else { - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); - } - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - - updateFilters( - workspaceSlug.toString(), - projectId.toString(), - EIssueFilterType.FILTERS, - { [key]: newValues }, - moduleId - ); - }, - [workspaceSlug, projectId, moduleId, issueFilters, updateFilters] - ); - - const startDate = getDate(moduleDetails?.start_date); - const endDate = getDate(moduleDetails?.target_date); - const isStartValid = startDate && startDate <= new Date(); - const isEndValid = startDate && endDate && endDate >= startDate; - - const progressPercentage = moduleDetails - ? Math.round((moduleDetails.completed_issues / moduleDetails.total_issues) * 100) - : null; - const handleEditLink = (link: ILinkDetails) => { setSelectedLinkToUpdate(link); setModuleLinkModal(true); @@ -319,6 +277,11 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const issueCount = moduleDetails.total_issues === 0 ? "0 Issue" : `${moduleDetails.completed_issues}/${moduleDetails.total_issues}`; + const issueEstimatePointCount = + moduleDetails.total_estimate_points === 0 + ? "0 Issue" + : `${moduleDetails.completed_estimate_points}/${moduleDetails.total_estimate_points}`; + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; return ( @@ -559,99 +522,32 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { {issueCount} + + {/** + * NOTE: Render this section when estimate points of he projects is enabled and the estimate system is points + */} + {isEstimatePointValid && ( +
+
+ + Points +
+
+ {issueEstimatePointCount} +
+
+ )} + {workspaceSlug && projectId && moduleDetails?.id && ( + + )} +
-
- - {({ open }) => ( -
- -
- Progress -
- -
- {progressPercentage ? ( - - {progressPercentage ? `${progressPercentage}%` : ""} - - ) : ( - "" - )} - {isStartValid && isEndValid ? ( -
-
- - -
- {moduleDetails.start_date && moduleDetails.target_date ? ( -
-
-
-
- - Ideal -
-
- - Current -
-
-
-
- -
-
- ) : ( - "" - )} - {moduleDetails.total_issues > 0 && ( -
- -
- )} -
-
-
-
- )} -
-
-
{/* Accessing link outside the disclosure as mobx is not considering the children inside Disclosure as part of the component hence not observing their state change*/} diff --git a/web/core/components/modules/index.ts b/web/core/components/modules/index.ts index c3ab36c00..be77ac004 100644 --- a/web/core/components/modules/index.ts +++ b/web/core/components/modules/index.ts @@ -7,7 +7,6 @@ export * from "./form"; export * from "./gantt-chart"; 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"; @@ -15,5 +14,6 @@ export * from "./quick-actions"; export * from "./module-list-item-action"; export * from "./module-view-header"; +export * from "./analytics-sidebar"; // archived modules export * from "./archived-modules"; diff --git a/web/core/components/modules/module-peek-overview.tsx b/web/core/components/modules/module-peek-overview.tsx index 22c57a5d2..106856d7d 100644 --- a/web/core/components/modules/module-peek-overview.tsx +++ b/web/core/components/modules/module-peek-overview.tsx @@ -5,7 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { generateQueryParams } from "@/helpers/router.helper"; import { useModule } from "@/hooks/store"; // components -import { ModuleDetailsSidebar } from "./sidebar"; +import { ModuleAnalyticsSidebar } from "./"; type Props = { projectId: string; @@ -46,7 +46,7 @@ export const ModulePeekOverview: React.FC = observer(({ projectId, worksp "0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)", }} > - ; cycleMap: Record; + plotType: Record; activeCycleIdMap: Record; // computed currentProjectCycleIds: string[] | null; @@ -39,8 +40,10 @@ export interface ICycleStore { getCycleNameById: (cycleId: string) => string | undefined; getActiveCycleById: (cycleId: string) => ICycle | null; getProjectCycleIds: (projectId: string) => string[] | null; + getPlotTypeByCycleId: (cycleId: string) => TCyclePlotType; // actions validateDate: (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) => Promise; + setPlotType: (cycleId: string, plotType: TCyclePlotType) => void; // fetch fetchWorkspaceCycles: (workspaceSlug: string) => Promise; fetchAllCycles: (workspaceSlug: string, projectId: string) => Promise; @@ -69,6 +72,7 @@ export class CycleStore implements ICycleStore { // observables loader: boolean = false; cycleMap: Record = {}; + plotType: Record = {}; activeCycleIdMap: Record = {}; //loaders fetchedMap: Record = {}; @@ -85,6 +89,7 @@ export class CycleStore implements ICycleStore { // observables loader: observable.ref, cycleMap: observable, + plotType: observable.ref, activeCycleIdMap: observable, fetchedMap: observable, // computed @@ -96,6 +101,7 @@ export class CycleStore implements ICycleStore { currentProjectActiveCycleId: computed, currentProjectArchivedCycleIds: computed, // actions + setPlotType: action, fetchWorkspaceCycles: action, fetchAllCycles: action, fetchActiveCycle: action, @@ -334,6 +340,26 @@ export class CycleStore implements ICycleStore { validateDate = async (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) => await this.cycleService.cycleDateCheck(workspaceSlug, projectId, payload); + /** + * @description gets the plot type for the module store + * @param {TCyclePlotType} plotType + */ + getPlotTypeByCycleId = (cycleId: string) => { + const { projectId } = this.rootStore.router; + + return projectId && this.rootStore.projectEstimate.areEstimateEnabledByProjectId(projectId) + ? this.plotType[cycleId] || "burndown" + : "burndown"; + }; + + /** + * @description updates the plot type for the module store + * @param {TCyclePlotType} plotType + */ + setPlotType = (cycleId: string, plotType: TCyclePlotType) => { + set(this.plotType, [cycleId], plotType); + }; + /** * @description fetch all cycles * @param workspaceSlug diff --git a/web/core/store/module.store.ts b/web/core/store/module.store.ts index 0f2592bbe..845b7edf2 100644 --- a/web/core/store/module.store.ts +++ b/web/core/store/module.store.ts @@ -5,7 +5,7 @@ import update from "lodash/update"; import { action, computed, observable, makeObservable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // types -import { IModule, ILinkDetails } from "@plane/types"; +import { IModule, ILinkDetails, TModulePlotType } from "@plane/types"; // helpers import { orderModules, shouldFilterModule } from "@/helpers/module.helper"; // services @@ -19,6 +19,7 @@ export interface IModuleStore { //Loaders loader: boolean; fetchedMap: Record; + plotType: Record; // observables moduleMap: Record; // computed @@ -30,7 +31,9 @@ export interface IModuleStore { getModuleById: (moduleId: string) => IModule | null; getModuleNameById: (moduleId: string) => string; getProjectModuleIds: (projectId: string) => string[] | null; + getPlotTypeByModuleId: (moduleId: string) => TModulePlotType; // actions + setPlotType: (moduleId: string, plotType: TModulePlotType) => void; // fetch fetchWorkspaceModules: (workspaceSlug: string) => Promise; fetchModules: (workspaceSlug: string, projectId: string) => Promise; @@ -72,6 +75,7 @@ export class ModulesStore implements IModuleStore { // observables loader: boolean = false; moduleMap: Record = {}; + plotType: Record = {}; //loaders fetchedMap: Record = {}; // root store @@ -86,11 +90,13 @@ export class ModulesStore implements IModuleStore { // observables loader: observable.ref, moduleMap: observable, + plotType: observable.ref, fetchedMap: observable, // computed projectModuleIds: computed, projectArchivedModuleIds: computed, // actions + setPlotType: action, fetchWorkspaceModules: action, fetchModules: action, fetchArchivedModules: action, @@ -213,6 +219,26 @@ export class ModulesStore implements IModuleStore { return projectModuleIds; }); + /** + * @description gets the plot type for the module store + * @param {TModulePlotType} plotType + */ + getPlotTypeByModuleId = (moduleId: string) => { + const { projectId } = this.rootStore.router; + + return projectId && this.rootStore.projectEstimate.areEstimateEnabledByProjectId(projectId) + ? this.plotType[moduleId] || "burndown" + : "burndown"; + }; + + /** + * @description updates the plot type for the module store + * @param {TModulePlotType} plotType + */ + setPlotType = (moduleId: string, plotType: TModulePlotType) => { + set(this.plotType, [moduleId], plotType); + }; + /** * @description fetch all modules * @param workspaceSlug