[WEB-412] chore: estimates analytics (#4730)

* chore: estimate points in modules and cycle

* chore: burn down chart analytics

* chore: module serializer change

* dev: handled y-axis estimates in analytics, implemented estimate points on modules

* chore: burn down analytics

* chore: state estimate point analytics

* chore: updated the burn down values

* Remove check mark from estimate point edit field in
create estimate flow

---------

Co-authored-by: guru_sainath <gurusainath007@gmail.com>
Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
This commit is contained in:
Bavisetti Narayan 2024-06-07 18:19:45 +05:30 committed by GitHub
parent 23f4d5418f
commit dae291fe3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 458 additions and 111 deletions

View File

@ -784,6 +784,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
def post(self, request, slug, project_id, cycle_id): def post(self, request, slug, project_id, cycle_id):
new_cycle_id = request.data.get("new_cycle_id", False) new_cycle_id = request.data.get("new_cycle_id", False)
plot_type = request.GET.get("plot_type", "issues")
if not new_cycle_id: if not new_cycle_id:
return Response( return Response(
@ -865,6 +866,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
queryset=old_cycle.first(), queryset=old_cycle.first(),
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type=plot_type,
cycle_id=cycle_id, cycle_id=cycle_id,
) )

View File

@ -177,6 +177,8 @@ class ModuleSerializer(DynamicBaseSerializer):
started_issues = serializers.IntegerField(read_only=True) started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True) unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True) backlog_issues = serializers.IntegerField(read_only=True)
total_estimate_points = serializers.IntegerField(read_only=True)
completed_estimate_points = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Module model = Module
@ -201,6 +203,8 @@ class ModuleSerializer(DynamicBaseSerializer):
"external_id", "external_id",
"logo_props", "logo_props",
# computed fields # computed fields
"total_estimate_points",
"completed_estimate_points",
"is_favorite", "is_favorite",
"total_issues", "total_issues",
"cancelled_issues", "cancelled_issues",

View File

@ -177,6 +177,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
) )
def get(self, request, slug, project_id, pk=None): def get(self, request, slug, project_id, pk=None):
plot_type = request.GET.get("plot_type", "issues")
if pk is None: if pk is None:
queryset = ( queryset = (
self.get_queryset() self.get_queryset()
@ -375,6 +376,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
queryset=queryset, queryset=queryset,
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type=plot_type,
cycle_id=pk, cycle_id=pk,
) )

View File

@ -17,8 +17,11 @@ from django.db.models import (
UUIDField, UUIDField,
Value, Value,
When, When,
Subquery,
Sum,
IntegerField,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce, Cast
from django.utils import timezone from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
@ -73,6 +76,89 @@ class CycleViewSet(BaseViewSet):
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
) )
backlog_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="backlog",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_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_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_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_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_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_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
cancelled_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("cancelled_estimate_point")[:1]
)
completed_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="completed",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
completed_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("completed_estimate_points")[:1]
)
total_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
total_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("total_estimate_points")[:1]
)
return self.filter_queryset( return self.filter_queryset(
super() super()
.get_queryset() .get_queryset()
@ -197,12 +283,49 @@ class CycleViewSet(BaseViewSet):
Value([], output_field=ArrayField(UUIDField())), Value([], output_field=ArrayField(UUIDField())),
) )
) )
.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),
Value(0, output_field=IntegerField()),
),
)
.annotate(
total_estimate_points=Coalesce(
Subquery(total_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.order_by("-is_favorite", "name") .order_by("-is_favorite", "name")
.distinct() .distinct()
) )
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
queryset = self.get_queryset().filter(archived_at__isnull=True) 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") cycle_view = request.GET.get("cycle_view", "all")
# Update the order by # Update the order by
@ -233,6 +356,12 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot", "progress_snapshot",
"logo_props", "logo_props",
# meta fields # meta fields
"backlog_estimate_points",
"unstarted_estimate_points",
"started_estimate_points",
"cancelled_estimate_points",
"completed_estimate_points",
"total_estimate_points",
"is_favorite", "is_favorite",
"total_issues", "total_issues",
"cancelled_issues", "cancelled_issues",
@ -335,6 +464,7 @@ class CycleViewSet(BaseViewSet):
queryset=queryset.first(), queryset=queryset.first(),
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type=plot_type,
cycle_id=data[0]["id"], cycle_id=data[0]["id"],
) )
) )
@ -359,6 +489,8 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot", "progress_snapshot",
"logo_props", "logo_props",
# meta fields # meta fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite", "is_favorite",
"total_issues", "total_issues",
"cancelled_issues", "cancelled_issues",
@ -527,6 +659,7 @@ class CycleViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk): def retrieve(self, request, slug, project_id, pk):
plot_type = request.GET.get("plot_type", "issues")
queryset = ( queryset = (
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk) self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
) )
@ -682,6 +815,7 @@ class CycleViewSet(BaseViewSet):
queryset=queryset, queryset=queryset,
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type=plot_type,
cycle_id=pk, cycle_id=pk,
) )
@ -798,6 +932,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
def post(self, request, slug, project_id, cycle_id): def post(self, request, slug, project_id, cycle_id):
new_cycle_id = request.data.get("new_cycle_id", False) new_cycle_id = request.data.get("new_cycle_id", False)
plot_type = request.GET.get("plot_type", "issues")
if not new_cycle_id: if not new_cycle_id:
return Response( return Response(
@ -879,6 +1014,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
queryset=old_cycle.first(), queryset=old_cycle.first(),
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type=plot_type,
cycle_id=cycle_id, cycle_id=cycle_id,
) )

View File

@ -165,6 +165,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
) )
def get(self, request, slug, project_id, pk=None): def get(self, request, slug, project_id, pk=None):
plot_type = request.GET.get("plot_type", "issues")
if pk is None: if pk is None:
queryset = self.get_queryset() queryset = self.get_queryset()
modules = queryset.values( # Required fields modules = queryset.values( # Required fields
@ -323,6 +324,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
queryset=modules, queryset=modules,
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type=plot_type,
module_id=pk, module_id=pk,
) )

View File

@ -16,8 +16,9 @@ from django.db.models import (
Subquery, Subquery,
UUIDField, UUIDField,
Value, Value,
Sum,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce, Cast
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone from django.utils import timezone
@ -128,6 +129,34 @@ class ModuleViewSet(BaseViewSet):
.annotate(cnt=Count("pk")) .annotate(cnt=Count("pk"))
.values("cnt") .values("cnt")
) )
completed_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="completed",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
completed_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("completed_estimate_points")[:1]
)
total_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
total_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("total_estimate_points")[:1]
)
return ( return (
super() super()
.get_queryset() .get_queryset()
@ -182,6 +211,18 @@ class ModuleViewSet(BaseViewSet):
Value(0, output_field=IntegerField()), Value(0, output_field=IntegerField()),
) )
) )
.annotate(
completed_estimate_points=Coalesce(
Subquery(completed_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
total_estimate_points=Coalesce(
Subquery(total_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate( .annotate(
member_ids=Coalesce( member_ids=Coalesce(
ArrayAgg( ArrayAgg(
@ -233,6 +274,8 @@ class ModuleViewSet(BaseViewSet):
"total_issues", "total_issues",
"started_issues", "started_issues",
"unstarted_issues", "unstarted_issues",
"completed_estimate_points",
"total_estimate_points",
"backlog_issues", "backlog_issues",
"created_at", "created_at",
"updated_at", "updated_at",
@ -284,6 +327,8 @@ class ModuleViewSet(BaseViewSet):
"external_id", "external_id",
"logo_props", "logo_props",
# computed fields # computed fields
"completed_estimate_points",
"total_estimate_points",
"total_issues", "total_issues",
"is_favorite", "is_favorite",
"cancelled_issues", "cancelled_issues",
@ -301,6 +346,7 @@ class ModuleViewSet(BaseViewSet):
return Response(modules, status=status.HTTP_200_OK) return Response(modules, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk): def retrieve(self, request, slug, project_id, pk):
plot_type = request.GET.get("plot_type", "burndown")
queryset = ( queryset = (
self.get_queryset() self.get_queryset()
.filter(archived_at__isnull=True) .filter(archived_at__isnull=True)
@ -423,6 +469,7 @@ class ModuleViewSet(BaseViewSet):
queryset=modules, queryset=modules,
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type=plot_type,
module_id=pk, module_id=pk,
) )
@ -469,6 +516,8 @@ class ModuleViewSet(BaseViewSet):
"external_id", "external_id",
"logo_props", "logo_props",
# computed fields # computed fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite", "is_favorite",
"cancelled_issues", "cancelled_issues",
"completed_issues", "completed_issues",

View File

@ -4,18 +4,28 @@ from itertools import groupby
# Django import # Django import
from django.db import models from django.db import models
from django.db.models import Case, CharField, Count, F, Sum, Value, When from django.db.models import (
Case,
CharField,
Count,
F,
Sum,
Value,
When,
IntegerField,
)
from django.db.models.functions import ( from django.db.models.functions import (
Coalesce, Coalesce,
Concat, Concat,
ExtractMonth, ExtractMonth,
ExtractYear, ExtractYear,
TruncDate, TruncDate,
Cast,
) )
from django.utils import timezone from django.utils import timezone
# Module imports # Module imports
from plane.db.models import Issue from plane.db.models import Issue, Project
def annotate_with_monthly_dimension(queryset, field_name, attribute): def annotate_with_monthly_dimension(queryset, field_name, attribute):
@ -87,9 +97,9 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
# Estimate # Estimate
else: else:
queryset = queryset.annotate(estimate=Sum("estimate_point")).order_by( queryset = queryset.annotate(
x_axis estimate=Sum(Cast("estimate_point__value", IntegerField()))
) ).order_by(x_axis)
queryset = ( queryset = (
queryset.annotate(segment=F(segment)) if segment else queryset queryset.annotate(segment=F(segment)) if segment else queryset
) )
@ -110,9 +120,33 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
return sort_data(grouped_data, temp_axis) return sort_data(grouped_data, temp_axis)
def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): def burndown_plot(
queryset,
slug,
project_id,
plot_type,
cycle_id=None,
module_id=None,
):
# Total Issues in Cycle or Module # Total Issues in Cycle or Module
total_issues = queryset.total_issues total_issues = queryset.total_issues
# check whether the estimate is a point or not
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if estimate_type and plot_type == "points":
issue_estimates = Issue.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
estimate_point__isnull=False,
).values_list("estimate_point__value", flat=True)
issue_estimates = [int(value) for value in issue_estimates]
total_estimate_points = sum(issue_estimates)
if cycle_id: if cycle_id:
if queryset.end_date and queryset.start_date: if queryset.end_date and queryset.start_date:
@ -128,6 +162,20 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
chart_data = {str(date): 0 for date in date_range} chart_data = {str(date): 0 for date in date_range}
if plot_type == "points":
completed_issues_estimate_point_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
estimate_point__isnull=False,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.values("date", "estimate_point__value")
.order_by("date")
)
else:
completed_issues_distribution = ( completed_issues_distribution = (
Issue.issue_objects.filter( Issue.issue_objects.filter(
workspace__slug=slug, workspace__slug=slug,
@ -152,6 +200,20 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
chart_data = {str(date): 0 for date in date_range} chart_data = {str(date): 0 for date in date_range}
if plot_type == "points":
completed_issues_estimate_point_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_module__module_id=module_id,
estimate_point__isnull=False,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.values("date", "estimate_point__value")
.order_by("date")
)
else:
completed_issues_distribution = ( completed_issues_distribution = (
Issue.issue_objects.filter( Issue.issue_objects.filter(
workspace__slug=slug, workspace__slug=slug,
@ -166,6 +228,20 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
) )
for date in date_range: for date in date_range:
if plot_type == "points":
cumulative_pending_issues = total_estimate_points
total_completed = 0
total_completed = sum(
int(item["estimate_point__value"])
for item in completed_issues_estimate_point_distribution
if item["date"] is not None and item["date"] <= date
)
cumulative_pending_issues -= total_completed
if date > timezone.now().date():
chart_data[str(date)] = None
else:
chart_data[str(date)] = cumulative_pending_issues
else:
cumulative_pending_issues = total_issues cumulative_pending_issues = total_issues
total_completed = 0 total_completed = 0
total_completed = sum( total_completed = sum(

View File

@ -44,6 +44,8 @@ export interface IModule {
target_date: string | null; target_date: string | null;
total_issues: number; total_issues: number;
unstarted_issues: number; unstarted_issues: number;
total_estimate_points?: number;
completed_estimate_points?: number;
updated_at: string; updated_at: string;
updated_by?: string; updated_by?: string;
archived_at: string | null; archived_at: string | null;

View File

@ -1,26 +1,54 @@
// ui import { observer } from "mobx-react";
import { TYAxisValues } from "@plane/types"; import { TYAxisValues } from "@plane/types";
import { CustomSelect } from "@plane/ui"; import { CustomSelect } from "@plane/ui";
// types
import { ANALYTICS_Y_AXIS_VALUES } from "@/constants/analytics";
// constants // constants
import { ANALYTICS_Y_AXIS_VALUES } from "@/constants/analytics";
import { EEstimateSystem } from "@/constants/estimates";
// hooks
import { useAppRouter, useProjectEstimates } from "@/hooks/store";
type Props = { type Props = {
value: TYAxisValues; value: TYAxisValues;
onChange: () => void; onChange: () => void;
}; };
export const SelectYAxis: React.FC<Props> = ({ value, onChange }) => ( export const SelectYAxis: React.FC<Props> = observer(({ value, onChange }) => {
// hooks
const { projectId } = useAppRouter();
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
const isEstimateEnabled = (analyticsOption: string) => {
if (analyticsOption === "estimate") {
if (
projectId &&
currentActiveEstimateId &&
areEstimateEnabledByProjectId(projectId) &&
estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS
) {
return true;
} else {
return false;
}
}
return true;
};
return (
<CustomSelect <CustomSelect
value={value} value={value}
label={<span>{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}</span>} label={<span>{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}</span>}
onChange={onChange} onChange={onChange}
maxHeight="lg" maxHeight="lg"
> >
{ANALYTICS_Y_AXIS_VALUES.map((item) => ( {ANALYTICS_Y_AXIS_VALUES.map(
(item) =>
isEstimateEnabled(item.value) && (
<CustomSelect.Option key={item.value} value={item.value}> <CustomSelect.Option key={item.value} value={item.value}>
{item.label} {item.label}
</CustomSelect.Option> </CustomSelect.Option>
))} )
)}
</CustomSelect> </CustomSelect>
); );
});

View File

@ -1,4 +1,4 @@
import { FC, FormEvent, useState } from "react"; import { FC, MouseEvent, FocusEvent, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Check, Info, X } from "lucide-react"; import { Check, Info, X } from "lucide-react";
import { TEstimatePointsObject, TEstimateSystemKeys } from "@plane/types"; import { TEstimatePointsObject, TEstimateSystemKeys } from "@plane/types";
@ -49,7 +49,7 @@ export const EstimatePointCreate: FC<TEstimatePointCreate> = observer((props) =>
closeCallBack(); closeCallBack();
}; };
const handleCreate = async (event: FormEvent<HTMLFormElement>) => { const handleCreate = async (event: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLInputElement, Element>) => {
event.preventDefault(); event.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -122,10 +122,10 @@ export const EstimatePointCreate: FC<TEstimatePointCreate> = observer((props) =>
}; };
return ( return (
<form onSubmit={handleCreate} className="relative flex items-center gap-2 text-base"> <form className="relative flex items-center gap-2 text-base">
<div <div
className={cn( className={cn(
"relative w-full border rounded flex items-center", "relative w-full border rounded flex items-center my-1",
error ? `border-red-500` : `border-custom-border-200` error ? `border-red-500` : `border-custom-border-200`
)} )}
> >
@ -136,6 +136,7 @@ export const EstimatePointCreate: FC<TEstimatePointCreate> = observer((props) =>
className="border-none focus:ring-0 focus:border-0 focus:outline-none p-2.5 w-full bg-transparent" className="border-none focus:ring-0 focus:border-0 focus:outline-none p-2.5 w-full bg-transparent"
placeholder="Enter estimate point" placeholder="Enter estimate point"
autoFocus autoFocus
onBlur={(e) => !estimateId && handleCreate(e)}
/> />
{error && ( {error && (
<> <>
@ -148,10 +149,13 @@ export const EstimatePointCreate: FC<TEstimatePointCreate> = observer((props) =>
)} )}
</div> </div>
{estimateId && (
<>
<button <button
type="submit" type="submit"
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer text-green-500" className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer text-green-500"
disabled={loader} disabled={loader}
onClick={handleCreate}
> >
{loader ? <Spinner className="w-4 h-4" /> : <Check size={14} />} {loader ? <Spinner className="w-4 h-4" /> : <Check size={14} />}
</button> </button>
@ -162,6 +166,8 @@ export const EstimatePointCreate: FC<TEstimatePointCreate> = observer((props) =>
> >
<X size={14} className="text-custom-text-200" /> <X size={14} className="text-custom-text-200" />
</button> </button>
</>
)}
</form> </form>
); );
}); });

View File

@ -1,4 +1,4 @@
import { FC, FormEvent, useEffect, useState } from "react"; import { FC, MouseEvent, useEffect, FocusEvent, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Check, Info, X } from "lucide-react"; import { Check, Info, X } from "lucide-react";
import { TEstimatePointsObject, TEstimateSystemKeys } from "@plane/types"; import { TEstimatePointsObject, TEstimateSystemKeys } from "@plane/types";
@ -57,7 +57,7 @@ export const EstimatePointUpdate: FC<TEstimatePointUpdate> = observer((props) =>
closeCallBack(); closeCallBack();
}; };
const handleUpdate = async (event: FormEvent<HTMLFormElement>) => { const handleUpdate = async (event: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLInputElement, Element>) => {
event.preventDefault(); event.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -134,10 +134,10 @@ export const EstimatePointUpdate: FC<TEstimatePointUpdate> = observer((props) =>
}; };
return ( return (
<form onSubmit={handleUpdate} className="relative flex items-center gap-2 text-base"> <form className="relative flex items-center gap-2 text-base">
<div <div
className={cn( className={cn(
"relative w-full border rounded flex items-center", "relative w-full border rounded flex items-center my-1",
error ? `border-red-500` : `border-custom-border-200` error ? `border-red-500` : `border-custom-border-200`
)} )}
> >
@ -147,6 +147,7 @@ export const EstimatePointUpdate: FC<TEstimatePointUpdate> = observer((props) =>
onChange={(e) => setEstimateInputValue(e.target.value)} onChange={(e) => setEstimateInputValue(e.target.value)}
className="border-none focus:ring-0 focus:border-0 focus:outline-none p-2.5 w-full bg-transparent" className="border-none focus:ring-0 focus:border-0 focus:outline-none p-2.5 w-full bg-transparent"
placeholder="Enter estimate point" placeholder="Enter estimate point"
onBlur={(e) => !estimateId && handleUpdate(e)}
autoFocus autoFocus
/> />
{error && ( {error && (
@ -159,11 +160,13 @@ export const EstimatePointUpdate: FC<TEstimatePointUpdate> = observer((props) =>
</> </>
)} )}
</div> </div>
{estimateId && (
<>
<button <button
type="submit" type="submit"
className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer text-green-500" className="rounded-sm w-6 h-6 flex-shrink-0 relative flex justify-center items-center hover:bg-custom-background-80 transition-colors cursor-pointer text-green-500"
disabled={loader} disabled={loader}
onClick={handleUpdate}
> >
{loader ? <Spinner className="w-4 h-4" /> : <Check size={14} />} {loader ? <Spinner className="w-4 h-4" /> : <Check size={14} />}
</button> </button>
@ -174,6 +177,8 @@ export const EstimatePointUpdate: FC<TEstimatePointUpdate> = observer((props) =>
> >
<X size={14} className="text-custom-text-200" /> <X size={14} className="text-custom-text-200" />
</button> </button>
</>
)}
</form> </form>
); );
}); });

View File

@ -10,13 +10,14 @@ import { FavoriteStar } from "@/components/core";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { ModuleQuickActions } from "@/components/modules"; import { ModuleQuickActions } from "@/components/modules";
// constants // constants
import { EEstimateSystem } from "@/constants/estimates";
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker"; import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker";
import { MODULE_STATUS } from "@/constants/module"; import { MODULE_STATUS } from "@/constants/module";
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
// helpers // helpers
import { getDate, renderFormattedDate } from "@/helpers/date-time.helper"; import { getDate, renderFormattedDate } from "@/helpers/date-time.helper";
// hooks // hooks
import { useEventTracker, useMember, useModule, useUser } from "@/hooks/store"; import { useEventTracker, useMember, useModule, useProjectEstimates, useUser } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = { type Props = {
@ -37,6 +38,8 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
const { captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates();
// derived values // derived values
const moduleDetails = getModuleById(moduleId); const moduleDetails = getModuleById(moduleId);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
@ -120,14 +123,30 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
if (!moduleDetails) return null; if (!moduleDetails) return null;
const moduleTotalIssues = /**
moduleDetails.backlog_issues + * NOTE: This completion percentage calculation is based on the total issues count.
* when estimates are available and estimate type is points, we should consider the estimate point count
* when estimates are available and estimate type is not points, then by default we consider the issue count
*/
const isEstimateEnabled =
projectId &&
currentActiveEstimateId &&
areEstimateEnabledByProjectId(projectId.toString()) &&
estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS;
const moduleTotalIssues = isEstimateEnabled
? moduleDetails?.total_estimate_points || 0
: moduleDetails.backlog_issues +
moduleDetails.unstarted_issues + moduleDetails.unstarted_issues +
moduleDetails.started_issues + moduleDetails.started_issues +
moduleDetails.completed_issues + moduleDetails.completed_issues +
moduleDetails.cancelled_issues; moduleDetails.cancelled_issues;
const completionPercentage = (moduleDetails.completed_issues / moduleTotalIssues) * 100; const moduleCompletedIssues = isEstimateEnabled
? moduleDetails?.completed_estimate_points || 0
: moduleDetails.completed_issues;
const completionPercentage = (moduleCompletedIssues / moduleTotalIssues) * 100;
const endDate = getDate(moduleDetails.target_date); const endDate = getDate(moduleDetails.target_date);
const startDate = getDate(moduleDetails.start_date); const startDate = getDate(moduleDetails.start_date);
@ -140,11 +159,11 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
const issueCount = module const issueCount = module
? !moduleTotalIssues || moduleTotalIssues === 0 ? !moduleTotalIssues || moduleTotalIssues === 0
? "0 Issue" ? `0 ${isEstimateEnabled ? `Point` : `Issue`}`
: moduleTotalIssues === moduleDetails.completed_issues : moduleTotalIssues === moduleCompletedIssues
? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? "s" : ""}` ? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? `s` : ``}`
: `${moduleDetails.completed_issues}/${moduleTotalIssues} Issues` : `${moduleCompletedIssues}/${moduleTotalIssues} ${isEstimateEnabled ? `Points` : `Issues`}`
: "0 Issue"; : `0 ${isEstimateEnabled ? `Point` : `Issue`}`;
const moduleLeadDetails = moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined; const moduleLeadDetails = moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined;

View File

@ -8,8 +8,10 @@ import { CircularProgressIndicator } from "@plane/ui";
// components // components
import { ListItem } from "@/components/core/list"; import { ListItem } from "@/components/core/list";
import { ModuleListItemAction } from "@/components/modules"; import { ModuleListItemAction } from "@/components/modules";
// constants
import { EEstimateSystem } from "@/constants/estimates";
// hooks // hooks
import { useModule } from "@/hooks/store"; import { useAppRouter, useModule, useProjectEstimates } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = { type Props = {
@ -26,14 +28,28 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
// store hooks // store hooks
const { getModuleById } = useModule(); const { getModuleById } = useModule();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { projectId } = useAppRouter();
const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates();
// derived values // derived values
const moduleDetails = getModuleById(moduleId); const moduleDetails = getModuleById(moduleId);
if (!moduleDetails) return null; if (!moduleDetails) return null;
const completionPercentage = /**
((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100; * NOTE: This completion percentage calculation is based on the total issues count.
* when estimates are available and estimate type is points, we should consider the estimate point count
* when estimates are available and estimate type is not points, then by default we consider the issue count
*/
const isEstimateEnabled =
projectId &&
currentActiveEstimateId &&
areEstimateEnabledByProjectId(projectId) &&
estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS;
const completionPercentage = isEstimateEnabled
? ((moduleDetails?.completed_estimate_points || 0) / (moduleDetails?.total_estimate_points || 0)) * 100
: ((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100;
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);