[WEB-522] chore: enabled estimate point analytics for module and cycle (#4763)

* chore: updated modal and form validations

* chore: module estimate analytics

* chore: state analytics

* chore: cycle estimate analytics

* chore: module points serializer

* chore: added fields in serializer

* chore: module state estimate points

* dev: updated module analytics

* dev: updated hover description on the burndown

* dev: UI and module total percentage validation

* chore: estimate points structure change

* chore: module burndown

* chore: key values changed

* chore: cycle progress snapshot

* chore: cycle detail endpoint

* chore: progress snapshot payload change

* chore: resolved merge conflicts

* chore: updated issue and point dropdown in active cycle

* chore: optimized grouped issues count in cycle and module

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
guru_sainath 2024-06-12 19:02:27 +05:30 committed by GitHub
parent 8071350640
commit 61d8586f7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 2373 additions and 858 deletions

View File

@ -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):

View File

@ -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,10 +815,105 @@ 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
@ -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(
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=plot_type,
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"])

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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,

View File

@ -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";

View File

@ -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";

View File

@ -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)",
}}
>
<ModuleDetailsSidebar moduleId={moduleId.toString()} handleClose={toggleSidebar} />
<ModuleAnalyticsSidebar moduleId={moduleId.toString()} handleClose={toggleSidebar} />
</div>
)}
</div>

View File

@ -1,4 +1,3 @@
export * from "./links-list";
export * from "./sidebar-progress-stats";
export * from "./single-progress-stats";
export * from "./sidebar-menu-hamburger-toggle";

View File

@ -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<Props> = ({ distribution, startDate, endDate, totalIssues, className = "" }) => {
const ProgressChart: React.FC<Props> = ({
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<Props> = ({ distribution, startDate, endDate, tota
sliceTooltip={(datum) => (
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
{datum.slice.points[0].data.yFormatted}
<span className="text-custom-text-200"> issues pending on </span>
<span className="text-custom-text-200"> {plotTitle} pending on </span>
{datum.slice.points[0].data.xFormatted}
</div>
)}

View File

@ -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<Props> = 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 (
<Tab.Group
defaultIndex={currentValue(tab)}
onChange={(i) => {
switch (i) {
case 0:
return setTab("Assignees");
case 1:
return setTab("Labels");
case 2:
return setTab("States");
default:
return setTab("Assignees");
}
}}
>
<Tab.List
as="div"
className={`flex w-full items-center justify-between gap-2 rounded-md ${
noBackground ? "" : "bg-custom-background-90"
} p-0.5
${module ? "text-xs" : "text-sm"}`}
>
<Tab
className={({ selected }) =>
`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
</Tab>
<Tab
className={({ selected }) =>
`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
</Tab>
<Tab
className={({ selected }) =>
`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
</Tab>
</Tab.List>
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
<Tab.Panel
as="div"
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{distribution && distribution?.assignees.length > 0 ? (
distribution.assignees.map((assignee, index) => {
if (assignee.assignee_id)
return (
<SingleProgressStats
key={assignee.assignee_id}
title={
<div className="flex items-center gap-2">
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
<span>{assignee?.display_name ?? ""}</span>
</div>
}
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 (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>No assignee</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
/>
);
})
) : (
<div className="flex h-full flex-col items-center justify-center gap-2">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
</div>
<h6 className="text-base text-custom-text-300">No assignees yet</h6>
</div>
)}
</Tab.Panel>
<Tab.Panel
as="div"
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{distribution && distribution?.labels.length > 0 ? (
distribution.labels.map((label, index) => {
if (label.label_id) {
return (
<SingleProgressStats
key={label.label_id}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: label.color ?? "transparent",
}}
/>
<span className="text-xs">{label.label_name ?? "No labels"}</span>
</div>
}
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 (
<SingleProgressStats
key={`no-label-${index}`}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: label.color ?? "transparent",
}}
/>
<span className="text-xs">{label.label_name ?? "No labels"}</span>
</div>
}
completed={label.completed_issues}
total={label.total_issues}
/>
);
}
})
) : (
<div className="flex h-full flex-col items-center justify-center gap-2">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
</div>
<h6 className="text-base text-custom-text-300">No labels yet</h6>
</div>
)}
</Tab.Panel>
<Tab.Panel
as="div"
className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
>
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={group as TStateGroups} />
<span className="text-xs capitalize">{group}</span>
</div>
}
completed={groupedIssues[group]}
total={totalIssues}
{...(!isPeekView &&
!isCompleted && {
onClick: () => handleFiltersUpdate("state", getStateGroupState(group) ?? []),
})}
/>
))}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
);
});

View File

@ -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,17 +15,59 @@ export type ActiveCycleProductivityProps = {
cycle: ICycle;
};
const cycleBurnDownChartOptions = [
{ value: "burndown", label: "Issues" },
{ value: "points", label: "Points" },
];
export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (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 (
<Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}
className="flex flex-col justify-center min-h-[17rem] gap-5 py-4 px-3.5 bg-custom-background-100 border border-custom-border-200 rounded-lg"
>
<div className="flex flex-col justify-center min-h-[17rem] gap-5 py-4 px-3.5 bg-custom-background-100 border border-custom-border-200 rounded-lg">
<div className="flex items-center justify-between gap-4">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}>
<h3 className="text-base text-custom-text-300 font-semibold">Issue burndown</h3>
</Link>
<div id="no-redirection" className="flex items-center gap-2">
<CustomSelect
value={plotType}
label={<span>{cycleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"}</span>}
onChange={onChange}
maxHeight="lg"
>
{cycleBurnDownChartOptions.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
{loader && <Spinner className="h-3 w-3" />}
</div>
</div>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}>
{cycle.total_issues > 0 ? (
<>
<div className="h-full w-full px-2">
@ -39,16 +82,35 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (props)
<span>Current</span>
</div>
</div>
{plotType === "points" ? (
<span>{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span>
) : (
<span>{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span>
)}
</div>
<div className="relative h-full">
{completionChartDistributionData && (
<Fragment>
{plotType === "points" ? (
<ProgressChart
className="h-full"
distribution={cycle.distribution?.completion_chart ?? {}}
distribution={completionChartDistributionData}
startDate={cycle.start_date ?? ""}
endDate={cycle.end_date ?? ""}
totalIssues={cycle.total_issues}
plotTitle={"points"}
/>
) : (
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycle.start_date ?? ""}
endDate={cycle.end_date ?? ""}
totalIssues={cycle.total_estimate_points || 0}
plotTitle={"issues"}
/>
)}
</Fragment>
)}
</div>
</div>
</>
@ -60,5 +122,6 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (props)
</>
)}
</Link>
</div>
);
};

View File

@ -0,0 +1,3 @@
export * from "./root";
export * from "./issue-progress";
export * from "./progress-stats";

View File

@ -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<TCycleAnalyticsProgress> = 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 (
<div className="border-t border-custom-border-200 space-y-4 py-4 px-3">
<Disclosure defaultOpen={isCycleDateValid ? true : false}>
{({ open }) => (
<div className="space-y-6">
{/* progress bar header */}
{isCycleDateValid ? (
<div className="relative w-full flex justify-between items-center gap-2">
<Disclosure.Button className="relative flex items-center gap-2 w-full">
<div className="font-medium text-custom-text-200 text-sm">Progress</div>
{progressHeaderPercentage > 0 && (
<div className="flex h-5 w-9 items-center justify-center rounded bg-amber-500/20 text-xs font-medium text-amber-500">{`${progressHeaderPercentage}%`}</div>
)}
</Disclosure.Button>
{isCurrentEstimateTypeIsPoints && (
<>
<div>
<CustomSelect
value={plotType}
label={
<span>{cycleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"}</span>
}
onChange={onChange}
maxHeight="lg"
>
{cycleBurnDownChartOptions.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
</div>
{loader && <Spinner className="h-3 w-3" />}
</>
)}
<Disclosure.Button className="ml-auto">
{open ? (
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
)}
</Disclosure.Button>
</div>
) : (
<div className="relative w-full flex justify-between items-center gap-2">
<div className="font-medium text-custom-text-200 text-sm">Progress</div>
<div className="flex items-center gap-1">
<AlertCircle height={14} width={14} className="text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
{cycleDetails?.start_date && cycleDetails?.end_date
? "This cycle isn't active yet."
: "Invalid date. Please enter valid date."}
</span>
</div>
</div>
)}
<Transition show={open}>
<Disclosure.Panel className="space-y-4">
{/* progress burndown chart */}
<div>
<div className="relative flex items-center gap-2">
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
{cycleStartDate && cycleEndDate && completionChartDistributionData && (
<Fragment>
{plotType === "points" ? (
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycleStartDate}
endDate={cycleEndDate}
totalIssues={totalEstimatePoints}
plotTitle={"points"}
/>
) : (
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycleStartDate}
endDate={cycleEndDate}
totalIssues={totalIssues}
plotTitle={"issues"}
/>
)}
</Fragment>
)}
</div>
{/* progress detailed view */}
{chartDistributionData && (
<div className="w-full border-t border-custom-border-200 pt-5">
<CycleProgressStats
cycleId={cycleId}
plotType={plotType}
distribution={chartDistributionData}
groupedIssues={groupedIssues}
totalIssuesCount={plotType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
isEditable={Boolean(peekCycle)}
size="xs"
roundedTab={false}
noBackground={false}
filters={issueFilters}
handleFiltersUpdate={handleFiltersUpdate}
/>
</div>
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
);
});

View File

@ -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 (
<div>
{distribution && distribution.length > 0 ? (
distribution.map((assignee, index) => {
if (assignee?.id)
return (
<SingleProgressStats
key={assignee?.id}
title={
<div className="flex items-center gap-2">
<Avatar name={assignee?.title ?? undefined} src={assignee?.avatar ?? undefined} />
<span>{assignee?.title ?? ""}</span>
</div>
}
completed={assignee?.completed ?? 0}
total={assignee?.total ?? 0}
{...(isEditable && {
onClick: () => handleFiltersUpdate("assignees", assignee.id ?? ""),
selected: filters?.filters?.assignees?.includes(assignee.id ?? ""),
})}
/>
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>No assignee</span>
</div>
}
completed={assignee?.completed ?? 0}
total={assignee?.total ?? 0}
/>
);
})
) : (
<div className="flex h-full flex-col items-center justify-center gap-2">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
</div>
<h6 className="text-base text-custom-text-300">No assignees yet</h6>
</div>
)}
</div>
);
});
export const LabelStatComponent = observer((props: TLabelStatComponent) => {
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
return (
<div>
{distribution && distribution.length > 0 ? (
distribution.map((label, index) => {
if (label.id) {
return (
<SingleProgressStats
key={label.id}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: label.color ?? "transparent",
}}
/>
<span className="text-xs">{label.title ?? "No labels"}</span>
</div>
}
completed={label.completed}
total={label.total}
{...(isEditable && {
onClick: () => handleFiltersUpdate("labels", label.id ?? ""),
selected: filters?.filters?.labels?.includes(label.id ?? `no-label-${index}`),
})}
/>
);
} else {
return (
<SingleProgressStats
key={`no-label-${index}`}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: label.color ?? "transparent",
}}
/>
<span className="text-xs">{label.title ?? "No labels"}</span>
</div>
}
completed={label.completed}
total={label.total}
/>
);
}
})
) : (
<div className="flex h-full flex-col items-center justify-center gap-2">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
</div>
<h6 className="text-base text-custom-text-300">No labels yet</h6>
</div>
)}
</div>
);
});
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 (
<div>
{distribution.map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={group.state as TStateGroups} />
<span className="text-xs capitalize">{group.state}</span>
</div>
}
completed={group.completed}
total={totalIssuesCount}
{...(isEditable && {
onClick: () => group.state && handleFiltersUpdate("state", getStateGroupState(group.state) ?? []),
})}
/>
))}
</div>
);
});
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<string, number>;
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<TCycleProgressStats> = 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 (
<div>
<Tab.Group defaultIndex={currentTabIndex(currentTab ? currentTab : "stat-assignees")}>
<Tab.List
as="div"
className={cn(
`flex w-full items-center justify-between gap-2 rounded-md p-1`,
roundedTab ? `rounded-3xl` : `rounded-md`,
noBackground ? `` : `bg-custom-background-90`,
size === "xs" ? `text-xs` : `text-sm`
)}
>
{progressStats.map((stat) => (
<Tab
className={cn(
`p-1 w-full text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all`,
roundedTab ? `rounded-3xl border border-custom-border-200` : `rounded`,
stat.key === currentTab
? "bg-custom-background-100 text-custom-text-300"
: "text-custom-text-400 hover:text-custom-text-300"
)}
key={stat.key}
onClick={() => setCycleTab(stat.key)}
>
{stat.title}
</Tab>
))}
</Tab.List>
<Tab.Panels className="py-3 text-custom-text-200">
<Tab.Panel key={"stat-assignees"}>
<AssigneeStatComponent
distribution={distributionAssigneeData}
isEditable={isEditable}
filters={filters}
handleFiltersUpdate={handleFiltersUpdate}
/>
</Tab.Panel>
<Tab.Panel key={"stat-labels"}>
<LabelStatComponent
distribution={distributionLabelData}
isEditable={isEditable}
filters={filters}
handleFiltersUpdate={handleFiltersUpdate}
/>
</Tab.Panel>
<Tab.Panel key={"stat-states"}>
<StateStatComponent
distribution={distributionStateData}
totalIssuesCount={totalIssuesCount}
isEditable={isEditable}
handleFiltersUpdate={handleFiltersUpdate}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
);
});

View File

@ -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<Props> = 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<Props> = 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 (
<Loader className="px-5">
@ -266,6 +203,17 @@ export const CycleDetailsSidebar: React.FC<Props> = 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<Props> = 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,161 +413,31 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
<span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span>
</div>
</div>
{/**
* NOTE: Render this section when estimate points of he projects is enabled and the estimate system is points
*/}
{isEstimatePointValid && (
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<LayersIcon className="h-4 w-4" />
<span className="text-base">Points</span>
</div>
<div className="flex w-3/5 items-center">
<span className="px-1.5 text-sm text-custom-text-300">{issueEstimatePointCount}</span>
</div>
</div>
)}
</div>
<div className="flex flex-col">
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 px-1.5 py-5">
<Disclosure defaultOpen>
{({ open }) => (
<div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}>
<Disclosure.Button
className="flex w-full items-center justify-between gap-2 p-1.5"
disabled={!isStartValid || !isEndValid}
>
<div className="flex items-center justify-start gap-2 text-sm">
<span className="font-medium text-custom-text-200">Progress</span>
</div>
<div className="flex items-center gap-2.5">
{progressPercentage ? (
<span className="flex h-5 w-9 items-center justify-center rounded bg-amber-500/20 text-xs font-medium text-amber-500">
{progressPercentage ? `${progressPercentage}%` : ""}
</span>
) : (
""
)}
{isStartValid && isEndValid ? (
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
) : (
<div className="flex items-center gap-1">
<AlertCircle height={14} width={14} className="text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
{cycleDetails?.start_date && cycleDetails?.end_date
? "This cycle isn't active yet."
: "Invalid date. Please enter valid date."}
</span>
</div>
)}
</div>
</Disclosure.Button>
<Transition show={open}>
<Disclosure.Panel>
<div className="flex flex-col gap-3">
{isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? (
<>
{cycleDetails.progress_snapshot.distribution?.completion_chart &&
cycleDetails.start_date &&
cycleDetails.end_date && (
<div className="h-full w-full pt-4">
<div className="flex items-start gap-4 py-2 text-xs">
<div className="flex items-center gap-3 text-custom-text-100">
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
</div>
<div className="relative h-40 w-80">
<ProgressChart
distribution={cycleDetails.progress_snapshot.distribution?.completion_chart}
startDate={cycleDetails.start_date}
endDate={cycleDetails.end_date}
totalIssues={cycleDetails.progress_snapshot.total_issues}
{workspaceSlug && projectId && cycleDetails?.id && (
<CycleAnalyticsProgress
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
cycleId={cycleDetails?.id}
/>
</div>
</div>
)}
</>
) : (
<>
{cycleDetails.distribution?.completion_chart &&
cycleDetails.start_date &&
cycleDetails.end_date && (
<div className="h-full w-full pt-4">
<div className="flex items-start gap-4 py-2 text-xs">
<div className="flex items-center gap-3 text-custom-text-100">
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
</div>
<div className="relative h-40 w-80">
<ProgressChart
distribution={cycleDetails.distribution?.completion_chart}
startDate={cycleDetails.start_date}
endDate={cycleDetails.end_date}
totalIssues={cycleDetails.total_issues}
/>
</div>
</div>
)}
</>
)}
{/* stats */}
{isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? (
<>
{cycleDetails.progress_snapshot.total_issues > 0 &&
cycleDetails.progress_snapshot.distribution && (
<div className="h-full w-full border-t border-custom-border-200 pt-5">
<SidebarProgressStats
distribution={cycleDetails.progress_snapshot.distribution}
groupedIssues={{
backlog: cycleDetails.progress_snapshot.backlog_issues,
unstarted: cycleDetails.progress_snapshot.unstarted_issues,
started: cycleDetails.progress_snapshot.started_issues,
completed: cycleDetails.progress_snapshot.completed_issues,
cancelled: cycleDetails.progress_snapshot.cancelled_issues,
}}
totalIssues={cycleDetails.progress_snapshot.total_issues}
isPeekView={Boolean(peekCycle)}
isCompleted={isCompleted}
filters={issueFilters}
handleFiltersUpdate={handleFiltersUpdate}
/>
</div>
)}
</>
) : (
<>
{cycleDetails.total_issues > 0 && cycleDetails.distribution && (
<div className="h-full w-full border-t border-custom-border-200 pt-5">
<SidebarProgressStats
distribution={cycleDetails.distribution}
groupedIssues={{
backlog: cycleDetails.backlog_issues,
unstarted: cycleDetails.unstarted_issues,
started: cycleDetails.started_issues,
completed: cycleDetails.completed_issues,
cancelled: cycleDetails.cancelled_issues,
}}
totalIssues={cycleDetails.total_issues}
isPeekView={Boolean(peekCycle)}
isCompleted={isCompleted}
filters={issueFilters}
handleFiltersUpdate={handleFiltersUpdate}
/>
</div>
)}
</>
)}
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
</div>
</>
</div>
);
});

View File

@ -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<Props> = observer(({ projectId, workspa
const { fetchCycleDetails, fetchArchivedCycleDetails } = useCycle();
const handleClose = () => {
const query = generateQueryParams(searchParams, ['peekCycle']);
const query = generateQueryParams(searchParams, ["peekCycle"]);
router.push(`${pathname}?${query}`);
};

View File

@ -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";

View File

@ -0,0 +1,3 @@
export * from "./root";
export * from "./issue-progress";
export * from "./progress-stats";

View File

@ -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<TModuleAnalyticsProgress> = 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 (
<div className="border-t border-custom-border-200 space-y-4 py-4 px-3">
<Disclosure defaultOpen={isModuleDateValid ? true : false}>
{({ open }) => (
<div className="space-y-6">
{/* progress bar header */}
{isModuleDateValid ? (
<div className="relative w-full flex justify-between items-center gap-2">
<Disclosure.Button className="relative flex items-center gap-2 w-full">
<div className="font-medium text-custom-text-200 text-sm">Progress</div>
{progressHeaderPercentage > 0 && (
<div className="flex h-5 w-9 items-center justify-center rounded bg-amber-500/20 text-xs font-medium text-amber-500">{`${progressHeaderPercentage}%`}</div>
)}
</Disclosure.Button>
{isCurrentEstimateTypeIsPoints && (
<>
<div>
<CustomSelect
value={plotType}
label={
<span>{moduleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"}</span>
}
onChange={onChange}
maxHeight="lg"
>
{moduleBurnDownChartOptions.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
</div>
{loader && <Spinner className="h-3 w-3" />}
</>
)}
<Disclosure.Button className="ml-auto">
{open ? (
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
)}
</Disclosure.Button>
</div>
) : (
<div className="relative w-full flex justify-between items-center gap-2">
<div className="font-medium text-custom-text-200 text-sm">Progress</div>
<div className="flex items-center gap-1">
<AlertCircle height={14} width={14} className="text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
{moduleDetails?.start_date && moduleDetails?.target_date
? "This module isn't active yet."
: "Invalid date. Please enter valid date."}
</span>
</div>
</div>
)}
<Transition show={open}>
<Disclosure.Panel className="space-y-4">
{/* progress burndown chart */}
<div>
<div className="relative flex items-center gap-2">
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
{moduleStartDate && moduleEndDate && completionChartDistributionData && (
<Fragment>
{plotType === "points" ? (
<ProgressChart
distribution={completionChartDistributionData}
startDate={moduleStartDate}
endDate={moduleEndDate}
totalIssues={totalEstimatePoints}
plotTitle={"points"}
/>
) : (
<ProgressChart
distribution={completionChartDistributionData}
startDate={moduleStartDate}
endDate={moduleEndDate}
totalIssues={totalIssues}
plotTitle={"issues"}
/>
)}
</Fragment>
)}
</div>
{/* progress detailed view */}
{chartDistributionData && (
<div className="w-full border-t border-custom-border-200 pt-5">
<ModuleProgressStats
moduleId={moduleId}
plotType={plotType}
distribution={chartDistributionData}
groupedIssues={groupedIssues}
totalIssuesCount={plotType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
isEditable={Boolean(peekModule)}
size="xs"
roundedTab={false}
noBackground={false}
filters={issueFilters}
handleFiltersUpdate={handleFiltersUpdate}
/>
</div>
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
);
});

View File

@ -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 (
<div>
{distribution && distribution.length > 0 ? (
distribution.map((assignee, index) => {
if (assignee?.id)
return (
<SingleProgressStats
key={assignee?.id}
title={
<div className="flex items-center gap-2">
<Avatar name={assignee?.title ?? undefined} src={assignee?.avatar ?? undefined} />
<span>{assignee?.title ?? ""}</span>
</div>
}
completed={assignee?.completed ?? 0}
total={assignee?.total ?? 0}
{...(isEditable && {
onClick: () => handleFiltersUpdate("assignees", assignee.id ?? ""),
selected: filters?.filters?.assignees?.includes(assignee.id ?? ""),
})}
/>
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>No assignee</span>
</div>
}
completed={assignee?.completed ?? 0}
total={assignee?.total ?? 0}
/>
);
})
) : (
<div className="flex h-full flex-col items-center justify-center gap-2">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
</div>
<h6 className="text-base text-custom-text-300">No assignees yet</h6>
</div>
)}
</div>
);
});
export const LabelStatComponent = observer((props: TLabelStatComponent) => {
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
return (
<div>
{distribution && distribution.length > 0 ? (
distribution.map((label, index) => {
if (label.id) {
return (
<SingleProgressStats
key={label.id}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: label.color ?? "transparent",
}}
/>
<span className="text-xs">{label.title ?? "No labels"}</span>
</div>
}
completed={label.completed}
total={label.total}
{...(isEditable && {
onClick: () => handleFiltersUpdate("labels", label.id ?? ""),
selected: filters?.filters?.labels?.includes(label.id ?? `no-label-${index}`),
})}
/>
);
} else {
return (
<SingleProgressStats
key={`no-label-${index}`}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: label.color ?? "transparent",
}}
/>
<span className="text-xs">{label.title ?? "No labels"}</span>
</div>
}
completed={label.completed}
total={label.total}
/>
);
}
})
) : (
<div className="flex h-full flex-col items-center justify-center gap-2">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
</div>
<h6 className="text-base text-custom-text-300">No labels yet</h6>
</div>
)}
</div>
);
});
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 (
<div>
{distribution.map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={group.state as TStateGroups} />
<span className="text-xs capitalize">{group.state}</span>
</div>
}
completed={group.completed}
total={totalIssuesCount}
{...(isEditable && {
onClick: () => group.state && handleFiltersUpdate("state", getStateGroupState(group.state) ?? []),
})}
/>
))}
</div>
);
});
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<string, number>;
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<TModuleProgressStats> = 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 (
<div>
<Tab.Group defaultIndex={currentTabIndex(currentTab ? currentTab : "stat-assignees")}>
<Tab.List
as="div"
className={cn(
`flex w-full items-center justify-between gap-2 rounded-md p-1`,
roundedTab ? `rounded-3xl` : `rounded-md`,
noBackground ? `` : `bg-custom-background-90`,
size === "xs" ? `text-xs` : `text-sm`
)}
>
{progressStats.map((stat) => (
<Tab
className={cn(
`p-1 w-full text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all`,
roundedTab ? `rounded-3xl border border-custom-border-200` : `rounded`,
stat.key === currentTab
? "bg-custom-background-100 text-custom-text-300"
: "text-custom-text-400 hover:text-custom-text-300"
)}
key={stat.key}
onClick={() => setModuleTab(stat.key)}
>
{stat.title}
</Tab>
))}
</Tab.List>
<Tab.Panels className="py-3 text-custom-text-200">
<Tab.Panel key={"stat-assignees"}>
<AssigneeStatComponent
distribution={distributionAssigneeData}
isEditable={isEditable}
filters={filters}
handleFiltersUpdate={handleFiltersUpdate}
/>
</Tab.Panel>
<Tab.Panel key={"stat-labels"}>
<LabelStatComponent
distribution={distributionLabelData}
isEditable={isEditable}
filters={filters}
handleFiltersUpdate={handleFiltersUpdate}
/>
</Tab.Panel>
<Tab.Panel key={"stat-states"}>
<StateStatComponent
distribution={distributionStateData}
totalIssuesCount={totalIssuesCount}
isEditable={isEditable}
handleFiltersUpdate={handleFiltersUpdate}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
);
});

View File

@ -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<IModule> = {
lead_id: "",
@ -69,7 +65,7 @@ type Props = {
};
// TODO: refactor this component
export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
const { moduleId, handleClose, isArchived } = props;
// states
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
@ -79,8 +75,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = 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<Props> = 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<Props> = 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<Props> = 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<Props> = observer((props) => {
<span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span>
</div>
</div>
{/**
* NOTE: Render this section when estimate points of he projects is enabled and the estimate system is points
*/}
{isEstimatePointValid && (
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<LayersIcon className="h-4 w-4" />
<span className="text-base">Points</span>
</div>
<div className="flex h-7 w-3/5 items-center">
<span className="px-1.5 text-sm text-custom-text-300">{issueEstimatePointCount}</span>
</div>
</div>
)}
</div>
{workspaceSlug && projectId && moduleDetails?.id && (
<ModuleAnalyticsProgress
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
moduleId={moduleDetails?.id}
/>
)}
<div className="flex flex-col">
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 px-1.5 py-5">
<Disclosure defaultOpen>
{({ open }) => (
<div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}>
<Disclosure.Button
className="flex w-full items-center justify-between gap-2 p-1.5"
disabled={!isStartValid || !isEndValid}
>
<div className="flex items-center justify-start gap-2 text-sm">
<span className="font-medium text-custom-text-200">Progress</span>
</div>
<div className="flex items-center gap-2.5">
{progressPercentage ? (
<span className="flex h-5 w-9 items-center justify-center rounded bg-amber-500/20 text-xs font-medium text-amber-500">
{progressPercentage ? `${progressPercentage}%` : ""}
</span>
) : (
""
)}
{isStartValid && isEndValid ? (
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
) : (
<div className="flex items-center gap-1">
<AlertCircle height={14} width={14} className="text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
{moduleDetails?.start_date && moduleDetails?.target_date
? "This module isn't active yet."
: "Invalid date. Please enter valid date."}
</span>
</div>
)}
</div>
</Disclosure.Button>
<Transition show={open}>
<Disclosure.Panel>
<div className="flex flex-col gap-3">
{moduleDetails.start_date && moduleDetails.target_date ? (
<div className=" h-full w-full pt-4">
<div className="flex items-start gap-4 py-2 text-xs">
<div className="flex items-center gap-3 text-custom-text-100">
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
</div>
<div className="relative h-40 w-full max-w-80">
<ProgressChart
distribution={moduleDetails.distribution?.completion_chart ?? {}}
startDate={moduleDetails.start_date}
endDate={moduleDetails.target_date}
totalIssues={moduleDetails.total_issues}
/>
</div>
</div>
) : (
""
)}
{moduleDetails.total_issues > 0 && (
<div className="h-full w-full border-t border-custom-border-200 pt-5">
<SidebarProgressStats
distribution={moduleDetails.distribution}
groupedIssues={{
backlog: moduleDetails.backlog_issues,
unstarted: moduleDetails.unstarted_issues,
started: moduleDetails.started_issues,
completed: moduleDetails.completed_issues,
cancelled: moduleDetails.cancelled_issues,
}}
totalIssues={moduleDetails.total_issues}
module={moduleDetails}
isPeekView={Boolean(peekModule)}
filters={issueFilters}
handleFiltersUpdate={handleFiltersUpdate}
/>
</div>
)}
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 px-1.5 py-5">
{/* 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*/}
<Disclosure defaultOpen={!!moduleDetails?.link_module?.length}>

View File

@ -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";

View File

@ -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<Props> = 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)",
}}
>
<ModuleDetailsSidebar
<ModuleAnalyticsSidebar
moduleId={peekModule?.toString() ?? ""}
handleClose={handleClose}
isArchived={isArchived}

View File

@ -4,7 +4,7 @@ import sortBy from "lodash/sortBy";
import { action, computed, observable, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// types
import { ICycle, CycleDateCheckData } from "@plane/types";
import { ICycle, CycleDateCheckData, TCyclePlotType } from "@plane/types";
// helpers
import { orderCycles, shouldFilterCycle } from "@/helpers/cycle.helper";
import { getDate } from "@/helpers/date-time.helper";
@ -22,6 +22,7 @@ export interface ICycleStore {
// observables
fetchedMap: Record<string, boolean>;
cycleMap: Record<string, ICycle>;
plotType: Record<string, TCyclePlotType>;
activeCycleIdMap: Record<string, boolean>;
// 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<any>;
setPlotType: (cycleId: string, plotType: TCyclePlotType) => void;
// fetch
fetchWorkspaceCycles: (workspaceSlug: string) => Promise<ICycle[]>;
fetchAllCycles: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>;
@ -69,6 +72,7 @@ export class CycleStore implements ICycleStore {
// observables
loader: boolean = false;
cycleMap: Record<string, ICycle> = {};
plotType: Record<string, TCyclePlotType> = {};
activeCycleIdMap: Record<string, boolean> = {};
//loaders
fetchedMap: Record<string, boolean> = {};
@ -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

View File

@ -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<string, boolean>;
plotType: Record<string, TModulePlotType>;
// observables
moduleMap: Record<string, IModule>;
// 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<IModule[]>;
fetchModules: (workspaceSlug: string, projectId: string) => Promise<undefined | IModule[]>;
@ -72,6 +75,7 @@ export class ModulesStore implements IModuleStore {
// observables
loader: boolean = false;
moduleMap: Record<string, IModule> = {};
plotType: Record<string, TModulePlotType> = {};
//loaders
fetchedMap: Record<string, boolean> = {};
// 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