mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of https://github.com/makeplane/plane into refactor/publish-project
This commit is contained in:
commit
158d99119c
@ -11,10 +11,6 @@ from rest_framework import serializers
|
||||
|
||||
|
||||
class EstimateSerializer(BaseSerializer):
|
||||
workspace_detail = WorkspaceLiteSerializer(
|
||||
read_only=True, source="workspace"
|
||||
)
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
|
||||
class Meta:
|
||||
model = Estimate
|
||||
@ -48,10 +44,6 @@ class EstimatePointSerializer(BaseSerializer):
|
||||
|
||||
class EstimateReadSerializer(BaseSerializer):
|
||||
points = EstimatePointSerializer(read_only=True, many=True)
|
||||
workspace_detail = WorkspaceLiteSerializer(
|
||||
read_only=True, source="workspace"
|
||||
)
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
|
||||
class Meta:
|
||||
model = Estimate
|
||||
|
@ -4,6 +4,7 @@ from django.urls import path
|
||||
from plane.app.views import (
|
||||
ProjectEstimatePointEndpoint,
|
||||
BulkEstimatePointEndpoint,
|
||||
EstimatePointEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@ -34,4 +35,23 @@ urlpatterns = [
|
||||
),
|
||||
name="bulk-create-estimate-points",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/",
|
||||
EstimatePointEndpoint.as_view(
|
||||
{
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="estimate-points",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/<estimate_point_id>/",
|
||||
EstimatePointEndpoint.as_view(
|
||||
{
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="estimate-points",
|
||||
),
|
||||
]
|
||||
|
@ -190,6 +190,7 @@ from .external.base import (
|
||||
from .estimate.base import (
|
||||
ProjectEstimatePointEndpoint,
|
||||
BulkEstimatePointEndpoint,
|
||||
EstimatePointEndpoint,
|
||||
)
|
||||
|
||||
from .inbox.base import InboxViewSet, InboxIssueViewSet
|
||||
|
@ -33,7 +33,7 @@ class AnalyticsEndpoint(BaseAPIView):
|
||||
"state__group",
|
||||
"labels__id",
|
||||
"assignees__id",
|
||||
"estimate_point",
|
||||
"estimate_point__value",
|
||||
"issue_cycle__cycle_id",
|
||||
"issue_module__module_id",
|
||||
"priority",
|
||||
@ -381,9 +381,9 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
open_estimate_sum = open_issues_queryset.aggregate(
|
||||
sum=Sum("estimate_point")
|
||||
sum=Sum("point")
|
||||
)["sum"]
|
||||
total_estimate_sum = base_issues.aggregate(sum=Sum("estimate_point"))[
|
||||
total_estimate_sum = base_issues.aggregate(sum=Sum("point"))[
|
||||
"sum"
|
||||
]
|
||||
|
||||
|
@ -1,3 +1,6 @@
|
||||
import random
|
||||
import string
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
@ -5,7 +8,7 @@ from rest_framework import status
|
||||
# Module imports
|
||||
from ..base import BaseViewSet, BaseAPIView
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Project, Estimate, EstimatePoint
|
||||
from plane.db.models import Project, Estimate, EstimatePoint, Issue
|
||||
from plane.app.serializers import (
|
||||
EstimateSerializer,
|
||||
EstimatePointSerializer,
|
||||
@ -13,6 +16,12 @@ from plane.app.serializers import (
|
||||
)
|
||||
from plane.utils.cache import invalidate_cache
|
||||
|
||||
|
||||
def generate_random_name(length=10):
|
||||
letters = string.ascii_lowercase
|
||||
return "".join(random.choice(letters) for i in range(length))
|
||||
|
||||
|
||||
class ProjectEstimatePointEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
@ -49,13 +58,17 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
serializer = EstimateReadSerializer(estimates, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False)
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/estimates/", url_params=True, user=False
|
||||
)
|
||||
def create(self, request, slug, project_id):
|
||||
if not request.data.get("estimate", False):
|
||||
return Response(
|
||||
{"error": "Estimate is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
estimate = request.data.get('estimate')
|
||||
estimate_name = estimate.get("name", generate_random_name())
|
||||
estimate_type = estimate.get("type", 'categories')
|
||||
last_used = estimate.get("last_used", False)
|
||||
estimate = Estimate.objects.create(
|
||||
name=estimate_name, project_id=project_id, last_used=last_used, type=estimate_type
|
||||
)
|
||||
|
||||
estimate_points = request.data.get("estimate_points", [])
|
||||
|
||||
@ -67,14 +80,6 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
estimate_serializer = EstimateSerializer(
|
||||
data=request.data.get("estimate")
|
||||
)
|
||||
if not estimate_serializer.is_valid():
|
||||
return Response(
|
||||
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
estimate = estimate_serializer.save(project_id=project_id)
|
||||
estimate_points = EstimatePoint.objects.bulk_create(
|
||||
[
|
||||
EstimatePoint(
|
||||
@ -93,17 +98,8 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
estimate_point_serializer = EstimatePointSerializer(
|
||||
estimate_points, many=True
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"estimate": estimate_serializer.data,
|
||||
"estimate_points": estimate_point_serializer.data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
serializer = EstimateReadSerializer(estimate)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def retrieve(self, request, slug, project_id, estimate_id):
|
||||
estimate = Estimate.objects.get(
|
||||
@ -115,13 +111,10 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False)
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/estimates/", url_params=True, user=False
|
||||
)
|
||||
def partial_update(self, request, slug, project_id, estimate_id):
|
||||
if not request.data.get("estimate", False):
|
||||
return Response(
|
||||
{"error": "Estimate is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not len(request.data.get("estimate_points", [])):
|
||||
return Response(
|
||||
@ -131,15 +124,10 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
|
||||
estimate = Estimate.objects.get(pk=estimate_id)
|
||||
|
||||
estimate_serializer = EstimateSerializer(
|
||||
estimate, data=request.data.get("estimate"), partial=True
|
||||
)
|
||||
if not estimate_serializer.is_valid():
|
||||
return Response(
|
||||
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
estimate = estimate_serializer.save()
|
||||
if request.data.get("estimate"):
|
||||
estimate.name = request.data.get("estimate").get("name", estimate.name)
|
||||
estimate.type = request.data.get("estimate").get("type", estimate.type)
|
||||
estimate.save()
|
||||
|
||||
estimate_points_data = request.data.get("estimate_points", [])
|
||||
|
||||
@ -165,29 +153,113 @@ class BulkEstimatePointEndpoint(BaseViewSet):
|
||||
estimate_point.value = estimate_point_data[0].get(
|
||||
"value", estimate_point.value
|
||||
)
|
||||
estimate_point.key = estimate_point_data[0].get(
|
||||
"key", estimate_point.key
|
||||
)
|
||||
updated_estimate_points.append(estimate_point)
|
||||
|
||||
EstimatePoint.objects.bulk_update(
|
||||
updated_estimate_points,
|
||||
["value"],
|
||||
["key", "value"],
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
estimate_point_serializer = EstimatePointSerializer(
|
||||
estimate_points, many=True
|
||||
)
|
||||
estimate_serializer = EstimateReadSerializer(estimate)
|
||||
return Response(
|
||||
{
|
||||
"estimate": estimate_serializer.data,
|
||||
"estimate_points": estimate_point_serializer.data,
|
||||
},
|
||||
estimate_serializer.data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False)
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/estimates/", url_params=True, user=False
|
||||
)
|
||||
def destroy(self, request, slug, project_id, estimate_id):
|
||||
estimate = Estimate.objects.get(
|
||||
pk=estimate_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
estimate.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class EstimatePointEndpoint(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def create(self, request, slug, project_id, estimate_id):
|
||||
# TODO: add a key validation if the same key already exists
|
||||
if not request.data.get("key") or not request.data.get("value"):
|
||||
return Response(
|
||||
{"error": "Key and value are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
key = request.data.get("key", 0)
|
||||
value = request.data.get("value", "")
|
||||
estimate_point = EstimatePoint.objects.create(
|
||||
estimate_id=estimate_id,
|
||||
project_id=project_id,
|
||||
key=key,
|
||||
value=value,
|
||||
)
|
||||
serializer = EstimatePointSerializer(estimate_point).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
|
||||
def partial_update(self, request, slug, project_id, estimate_id, estimate_point_id):
|
||||
# TODO: add a key validation if the same key already exists
|
||||
estimate_point = EstimatePoint.objects.get(
|
||||
pk=estimate_point_id,
|
||||
estimate_id=estimate_id,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
serializer = EstimatePointSerializer(
|
||||
estimate_point, data=request.data, partial=True
|
||||
)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def destroy(
|
||||
self, request, slug, project_id, estimate_id, estimate_point_id
|
||||
):
|
||||
new_estimate_id = request.GET.get("new_estimate_id", None)
|
||||
estimate_points = EstimatePoint.objects.filter(
|
||||
estimate_id=estimate_id,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
# update all the issues with the new estimate
|
||||
if new_estimate_id:
|
||||
_ = Issue.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
estimate_id=estimate_point_id,
|
||||
).update(estimate_id=new_estimate_id)
|
||||
|
||||
# delete the estimate point
|
||||
old_estimate_point = EstimatePoint.objects.filter(
|
||||
pk=estimate_point_id
|
||||
).first()
|
||||
|
||||
# rearrange the estimate points
|
||||
updated_estimate_points = []
|
||||
for estimate_point in estimate_points:
|
||||
if estimate_point.key > old_estimate_point.key:
|
||||
estimate_point.key -= 1
|
||||
updated_estimate_points.append(estimate_point)
|
||||
|
||||
EstimatePoint.objects.bulk_update(
|
||||
updated_estimate_points,
|
||||
["key"],
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
old_estimate_point.delete()
|
||||
|
||||
return Response(
|
||||
EstimatePointSerializer(updated_estimate_points, many=True).data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
260
apiserver/plane/db/migrations/0067_issue_estimate.py
Normal file
260
apiserver/plane/db/migrations/0067_issue_estimate.py
Normal file
@ -0,0 +1,260 @@
|
||||
# # Generated by Django 4.2.7 on 2024-05-24 09:47
|
||||
# Python imports
|
||||
import uuid
|
||||
from uuid import uuid4
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import plane.db.models.deploy_board
|
||||
|
||||
|
||||
def issue_estimate_point(apps, schema_editor):
|
||||
Issue = apps.get_model("db", "Issue")
|
||||
Project = apps.get_model("db", "Project")
|
||||
EstimatePoint = apps.get_model("db", "EstimatePoint")
|
||||
IssueActivity = apps.get_model("db", "IssueActivity")
|
||||
updated_estimate_point = []
|
||||
updated_issue_activity = []
|
||||
|
||||
# loop through all the projects
|
||||
for project in Project.objects.filter(estimate__isnull=False):
|
||||
estimate_points = EstimatePoint.objects.filter(
|
||||
estimate=project.estimate, project=project
|
||||
)
|
||||
|
||||
for issue_activity in IssueActivity.objects.filter(
|
||||
field="estimate_point", project=project
|
||||
):
|
||||
if issue_activity.new_value:
|
||||
new_identifier = estimate_points.filter(
|
||||
key=issue_activity.new_value
|
||||
).first().id
|
||||
issue_activity.new_identifier = new_identifier
|
||||
new_value = estimate_points.filter(
|
||||
key=issue_activity.new_value
|
||||
).first().value
|
||||
issue_activity.new_value = new_value
|
||||
|
||||
if issue_activity.old_value:
|
||||
old_identifier = estimate_points.filter(
|
||||
key=issue_activity.old_value
|
||||
).first().id
|
||||
issue_activity.old_identifier = old_identifier
|
||||
old_value = estimate_points.filter(
|
||||
key=issue_activity.old_value
|
||||
).first().value
|
||||
issue_activity.old_value = old_value
|
||||
updated_issue_activity.append(issue_activity)
|
||||
|
||||
for issue in Issue.objects.filter(
|
||||
point__isnull=False, project=project
|
||||
):
|
||||
# get the estimate id for the corresponding estimate point in the issue
|
||||
estimate = estimate_points.filter(key=issue.point).first()
|
||||
issue.estimate_point = estimate
|
||||
updated_estimate_point.append(issue)
|
||||
|
||||
Issue.objects.bulk_update(
|
||||
updated_estimate_point, ["estimate_point"], batch_size=1000
|
||||
)
|
||||
IssueActivity.objects.bulk_update(
|
||||
updated_issue_activity,
|
||||
["new_value", "old_value", "new_identifier", "old_identifier"],
|
||||
batch_size=1000,
|
||||
)
|
||||
|
||||
|
||||
def last_used_estimate(apps, schema_editor):
|
||||
Project = apps.get_model("db", "Project")
|
||||
Estimate = apps.get_model("db", "Estimate")
|
||||
|
||||
# Get all estimate ids used in projects
|
||||
estimate_ids = Project.objects.filter(estimate__isnull=False).values_list(
|
||||
"estimate", flat=True
|
||||
)
|
||||
|
||||
# Update all matching estimates
|
||||
Estimate.objects.filter(id__in=estimate_ids).update(last_used=True)
|
||||
|
||||
|
||||
def populate_deploy_board(apps, schema_editor):
|
||||
DeployBoard = apps.get_model("db", "DeployBoard")
|
||||
ProjectDeployBoard = apps.get_model("db", "ProjectDeployBoard")
|
||||
|
||||
DeployBoard.objects.bulk_create(
|
||||
[
|
||||
DeployBoard(
|
||||
entity_identifier=deploy_board.project_id,
|
||||
project_id=deploy_board.project_id,
|
||||
entity_name="project",
|
||||
anchor=uuid4().hex,
|
||||
is_comments_enabled=deploy_board.comments,
|
||||
is_reactions_enabled=deploy_board.reactions,
|
||||
inbox=deploy_board.inbox,
|
||||
is_votes_enabled=deploy_board.votes,
|
||||
view_props=deploy_board.views,
|
||||
workspace_id=deploy_board.workspace_id,
|
||||
created_at=deploy_board.created_at,
|
||||
updated_at=deploy_board.updated_at,
|
||||
created_by_id=deploy_board.created_by_id,
|
||||
updated_by_id=deploy_board.updated_by_id,
|
||||
)
|
||||
for deploy_board in ProjectDeployBoard.objects.all()
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0066_account_id_token_cycle_logo_props_module_logo_props"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="DeployBoard",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="Created At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("entity_identifier", models.UUIDField(null=True)),
|
||||
(
|
||||
"entity_name",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("project", "Project"),
|
||||
("issue", "Issue"),
|
||||
("module", "Module"),
|
||||
("cycle", "Task"),
|
||||
("page", "Page"),
|
||||
("view", "View"),
|
||||
],
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
(
|
||||
"anchor",
|
||||
models.CharField(
|
||||
db_index=True,
|
||||
default=plane.db.models.deploy_board.get_anchor,
|
||||
max_length=255,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("is_comments_enabled", models.BooleanField(default=False)),
|
||||
("is_reactions_enabled", models.BooleanField(default=False)),
|
||||
("is_votes_enabled", models.BooleanField(default=False)),
|
||||
("view_props", models.JSONField(default=dict)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"inbox",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="board_inbox",
|
||||
to="db.inbox",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_%(class)s",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="workspace_%(class)s",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Deploy Board",
|
||||
"verbose_name_plural": "Deploy Boards",
|
||||
"db_table": "deploy_boards",
|
||||
"ordering": ("-created_at",),
|
||||
"unique_together": {("entity_name", "entity_identifier")},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="estimate",
|
||||
name="last_used",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
# Rename the existing field
|
||||
migrations.RenameField(
|
||||
model_name="issue",
|
||||
old_name="estimate_point",
|
||||
new_name="point",
|
||||
),
|
||||
# Add a new field with the original name as a foreign key
|
||||
migrations.AddField(
|
||||
model_name="issue",
|
||||
name="estimate_point",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="issue_estimates",
|
||||
to="db.EstimatePoint",
|
||||
blank=True,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="estimate",
|
||||
name="type",
|
||||
field=models.CharField(default="categories", max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="estimatepoint",
|
||||
name="value",
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.RunPython(issue_estimate_point),
|
||||
migrations.RunPython(last_used_estimate),
|
||||
migrations.RunPython(populate_deploy_board),
|
||||
]
|
@ -80,7 +80,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
anchor=self.kwargs.get("anchor"),
|
||||
entity_name="project",
|
||||
)
|
||||
if project_deploy_board.comments:
|
||||
if project_deploy_board.is_comments_enabled:
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
@ -111,7 +111,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
anchor=anchor, entity_name="project"
|
||||
)
|
||||
|
||||
if not project_deploy_board.comments:
|
||||
if not project_deploy_board.is_comments_enabled:
|
||||
return Response(
|
||||
{"error": "Comments are not enabled for this project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@ -155,7 +155,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
anchor=anchor, entity_name="project"
|
||||
)
|
||||
|
||||
if not project_deploy_board.comments:
|
||||
if not project_deploy_board.is_comments_enabled:
|
||||
return Response(
|
||||
{"error": "Comments are not enabled for this project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@ -186,7 +186,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
anchor=anchor, entity_name="project"
|
||||
)
|
||||
|
||||
if not project_deploy_board.comments:
|
||||
if not project_deploy_board.is_comments_enabled:
|
||||
return Response(
|
||||
{"error": "Comments are not enabled for this project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@ -221,7 +221,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
)
|
||||
if project_deploy_board.reactions:
|
||||
if project_deploy_board.is_reactions_enabled:
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
@ -240,7 +240,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
|
||||
anchor=anchor, entity_name="project"
|
||||
)
|
||||
|
||||
if not project_deploy_board.reactions:
|
||||
if not project_deploy_board.is_reactions_enabled:
|
||||
return Response(
|
||||
{"error": "Reactions are not enabled for this project board"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@ -282,7 +282,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
|
||||
anchor=anchor, entity_name="project"
|
||||
)
|
||||
|
||||
if not project_deploy_board.reactions:
|
||||
if not project_deploy_board.is_reactions_enabled:
|
||||
return Response(
|
||||
{"error": "Reactions are not enabled for this project board"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@ -320,7 +320,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
|
||||
project_deploy_board = DeployBoard.objects.get(
|
||||
anchor=self.kwargs.get("anchor"), entity_name="project"
|
||||
)
|
||||
if project_deploy_board.reactions:
|
||||
if project_deploy_board.is_reactions_enabled:
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
@ -339,7 +339,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
|
||||
anchor=anchor, entity_name="project"
|
||||
)
|
||||
|
||||
if not project_deploy_board.reactions:
|
||||
if not project_deploy_board.is_reactions_enabled:
|
||||
return Response(
|
||||
{"error": "Reactions are not enabled for this board"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@ -380,7 +380,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
|
||||
project_deploy_board = DeployBoard.objects.get(
|
||||
anchor=anchor, entity_name="project"
|
||||
)
|
||||
if not project_deploy_board.reactions:
|
||||
if not project_deploy_board.is_reactions_enabled:
|
||||
return Response(
|
||||
{"error": "Reactions are not enabled for this board"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@ -422,7 +422,7 @@ class IssueVotePublicViewSet(BaseViewSet):
|
||||
workspace__slug=self.kwargs.get("anchor"),
|
||||
entity_name="project",
|
||||
)
|
||||
if project_deploy_board.votes:
|
||||
if project_deploy_board.is_votes_enabled:
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
|
2
packages/types/src/analytics.d.ts
vendored
2
packages/types/src/analytics.d.ts
vendored
@ -54,7 +54,7 @@ export type TXAxisValues =
|
||||
| "state__group"
|
||||
| "labels__id"
|
||||
| "assignees__id"
|
||||
| "estimate_point"
|
||||
| "estimate_point__value"
|
||||
| "issue_cycle__cycle_id"
|
||||
| "issue_module__module_id"
|
||||
| "priority"
|
||||
|
@ -24,3 +24,16 @@ export enum EIssueCommentAccessSpecifier {
|
||||
EXTERNAL = "EXTERNAL",
|
||||
INTERNAL = "INTERNAL",
|
||||
}
|
||||
|
||||
// estimates
|
||||
export enum EEstimateSystem {
|
||||
POINTS = "points",
|
||||
CATEGORIES = "categories",
|
||||
TIME = "time",
|
||||
}
|
||||
|
||||
export enum EEstimateUpdateStages {
|
||||
CREATE = "create",
|
||||
EDIT = "edit",
|
||||
SWITCH = "switch",
|
||||
}
|
||||
|
95
packages/types/src/estimate.d.ts
vendored
95
packages/types/src/estimate.d.ts
vendored
@ -1,40 +1,77 @@
|
||||
export interface IEstimate {
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
description: string;
|
||||
id: string;
|
||||
name: string;
|
||||
project: string;
|
||||
project_detail: IProject;
|
||||
updated_at: Date;
|
||||
updated_by: string;
|
||||
points: IEstimatePoint[];
|
||||
workspace: string;
|
||||
workspace_detail: IWorkspace;
|
||||
}
|
||||
import { EEstimateSystem, EEstimateUpdateStages } from "./enums";
|
||||
|
||||
export interface IEstimatePoint {
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
description: string;
|
||||
estimate: string;
|
||||
id: string;
|
||||
key: number;
|
||||
project: string;
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
value: string;
|
||||
workspace: string;
|
||||
id: string | undefined;
|
||||
key: number | undefined;
|
||||
value: string | undefined;
|
||||
description: string | undefined;
|
||||
workspace: string | undefined;
|
||||
project: string | undefined;
|
||||
estimate: string | undefined;
|
||||
created_at: Date | undefined;
|
||||
updated_at: Date | undefined;
|
||||
created_by: string | undefined;
|
||||
updated_by: string | undefined;
|
||||
}
|
||||
|
||||
export type TEstimateSystemKeys =
|
||||
| EEstimateSystem.POINTS
|
||||
| EEstimateSystem.CATEGORIES
|
||||
| EEstimateSystem.TIME;
|
||||
|
||||
export interface IEstimate {
|
||||
id: string | undefined;
|
||||
name: string | undefined;
|
||||
description: string | undefined;
|
||||
type: TEstimateSystemKeys | undefined; // categories, points, time
|
||||
points: IEstimatePoint[] | undefined;
|
||||
workspace: string | undefined;
|
||||
project: string | undefined;
|
||||
last_used: boolean | undefined;
|
||||
created_at: Date | undefined;
|
||||
updated_at: Date | undefined;
|
||||
created_by: string | undefined;
|
||||
updated_by: string | undefined;
|
||||
}
|
||||
|
||||
export interface IEstimateFormData {
|
||||
estimate: {
|
||||
name: string;
|
||||
description: string;
|
||||
estimate?: {
|
||||
name?: string;
|
||||
type?: string;
|
||||
last_used?: boolean;
|
||||
};
|
||||
estimate_points: {
|
||||
id?: string;
|
||||
id?: string | undefined;
|
||||
key: number;
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type TEstimatePointsObject = {
|
||||
id?: string | undefined;
|
||||
key: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type TTemplateValues = {
|
||||
title: string;
|
||||
values: TEstimatePointsObject[];
|
||||
hide?: boolean;
|
||||
};
|
||||
|
||||
export type TEstimateSystem = {
|
||||
name: string;
|
||||
templates: Record<string, TTemplateValues>;
|
||||
is_available: boolean;
|
||||
is_ee: boolean;
|
||||
};
|
||||
|
||||
export type TEstimateSystems = {
|
||||
[K in TEstimateSystemKeys]: TEstimateSystem;
|
||||
};
|
||||
|
||||
// update estimates
|
||||
export type TEstimateUpdateStageKeys =
|
||||
| EEstimateUpdateStages.CREATE
|
||||
| EEstimateUpdateStages.EDIT
|
||||
| EEstimateUpdateStages.SWITCH;
|
||||
|
1
packages/types/src/index.d.ts
vendored
1
packages/types/src/index.d.ts
vendored
@ -15,7 +15,6 @@ export * from "./importer";
|
||||
export * from "./inbox";
|
||||
export * from "./analytics";
|
||||
export * from "./api_token";
|
||||
export * from "./app";
|
||||
export * from "./auth";
|
||||
export * from "./calendar";
|
||||
export * from "./instance";
|
||||
|
@ -24,7 +24,7 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
||||
"@blueprintjs/core": "^4.16.3",
|
||||
"@blueprintjs/popover2": "^1.13.3",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@headlessui/react": "^2.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"clsx": "^2.0.0",
|
||||
"emoji-picker-react": "^4.5.16",
|
||||
@ -33,7 +33,7 @@
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-popper": "^2.3.0",
|
||||
"sonner": "^1.4.2",
|
||||
"sonner": "^1.4.41",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -13,5 +13,6 @@ export * from "./loader";
|
||||
export * from "./control-link";
|
||||
export * from "./toast";
|
||||
export * from "./drag-handle";
|
||||
export * from "./typography";
|
||||
export * from "./drop-indicator";
|
||||
export * from "./sortable";
|
||||
|
@ -1,6 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { Draggable } from "./draggable";
|
||||
import { Sortable } from "./sortable";
|
||||
|
||||
const meta: Meta<typeof Sortable> = {
|
||||
@ -13,7 +12,7 @@ type Story = StoryObj<typeof Sortable>;
|
||||
|
||||
const data = [
|
||||
{ id: "1", name: "John Doe" },
|
||||
{ id: "2", name: "Jane Doe 2" },
|
||||
{ id: "2", name: "Satish" },
|
||||
{ id: "3", name: "Alice" },
|
||||
{ id: "4", name: "Bob" },
|
||||
{ id: "5", name: "Charlie" },
|
||||
|
@ -8,7 +8,7 @@ type Props<T> = {
|
||||
onChange: (data: T[]) => void;
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
containerClassName?: string;
|
||||
id: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const moveItem = <T,>(
|
||||
@ -17,7 +17,7 @@ const moveItem = <T,>(
|
||||
destination: T & Record<symbol, string>,
|
||||
keyExtractor: (item: T, index: number) => string
|
||||
) => {
|
||||
const sourceIndex = data.indexOf(source);
|
||||
const sourceIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(source, 0));
|
||||
if (sourceIndex === -1) return data;
|
||||
|
||||
const destinationIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(destination, 0));
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useRef, Fragment } from "react";
|
||||
import React, { useEffect, useState, useRef, Fragment, Ref } from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { useRouter } from "next/router";
|
||||
import { Controller, useForm } from "react-hook-form"; // services
|
||||
@ -196,7 +196,7 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
|
||||
<Popover.Panel
|
||||
as="div"
|
||||
className={`fixed z-10 flex w-full min-w-[50rem] max-w-full flex-col space-y-4 overflow-hidden rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 shadow ${className}`}
|
||||
ref={setPopperElement}
|
||||
ref={setPopperElement as Ref<HTMLDivElement>}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Fragment, ReactNode, useRef, useState } from "react";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Check, ChevronDown, Search, Triangle } from "lucide-react";
|
||||
@ -7,7 +6,12 @@ import { Combobox } from "@headlessui/react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppRouter, useEstimate } from "@/hooks/store";
|
||||
import {
|
||||
useAppRouter,
|
||||
useEstimate,
|
||||
useProjectEstimates,
|
||||
// useEstimate
|
||||
} from "@/hooks/store";
|
||||
import { useDropdown } from "@/hooks/use-dropdown";
|
||||
// components
|
||||
import { DropdownButton } from "./buttons";
|
||||
@ -19,10 +23,10 @@ type Props = TDropdownProps & {
|
||||
button?: ReactNode;
|
||||
dropdownArrow?: boolean;
|
||||
dropdownArrowClassName?: string;
|
||||
onChange: (val: string | null) => void;
|
||||
onChange: (val: string | undefined) => void;
|
||||
onClose?: () => void;
|
||||
projectId: string;
|
||||
value: string | null;
|
||||
value: string | undefined;
|
||||
};
|
||||
|
||||
type DropdownOptions =
|
||||
@ -76,19 +80,29 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
// store hooks
|
||||
const { workspaceSlug } = useAppRouter();
|
||||
const { fetchProjectEstimates, getProjectActiveEstimateDetails, getEstimatePointValue } = useEstimate();
|
||||
const activeEstimate = getProjectActiveEstimateDetails(projectId);
|
||||
|
||||
const options: DropdownOptions = sortBy(activeEstimate?.points ?? [], "key")?.map((point) => ({
|
||||
value: point.id,
|
||||
query: `${point?.value}`,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Triangle className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="flex-grow truncate">{point.value}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
const { currentActiveEstimateId, getProjectEstimates } = useProjectEstimates();
|
||||
const { estimatePointIds, estimatePointById } = useEstimate(
|
||||
currentActiveEstimateId ? currentActiveEstimateId : undefined
|
||||
);
|
||||
|
||||
const options: DropdownOptions = (estimatePointIds ?? [])
|
||||
?.map((estimatePoint) => {
|
||||
const currentEstimatePoint = estimatePointById(estimatePoint);
|
||||
if (currentEstimatePoint)
|
||||
return {
|
||||
value: currentEstimatePoint.id,
|
||||
query: `${currentEstimatePoint?.value}`,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Triangle className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="flex-grow truncate">{currentEstimatePoint.value}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
else undefined;
|
||||
})
|
||||
.filter((estimatePointDropdownOption) => estimatePointDropdownOption != undefined) as DropdownOptions;
|
||||
options?.unshift({
|
||||
value: null,
|
||||
query: "No estimate",
|
||||
@ -103,10 +117,10 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const selectedEstimate = value !== null ? getEstimatePointValue(value, projectId) : null;
|
||||
const selectedEstimate = value && estimatePointById ? estimatePointById(value) : undefined;
|
||||
|
||||
const onOpen = async () => {
|
||||
if (!activeEstimate && workspaceSlug) await fetchProjectEstimates(workspaceSlug, projectId);
|
||||
if (!currentActiveEstimateId && workspaceSlug) await getProjectEstimates(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({
|
||||
@ -120,7 +134,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
setQuery,
|
||||
});
|
||||
|
||||
const dropdownOnChange = (val: string | null) => {
|
||||
const dropdownOnChange = (val: string | undefined) => {
|
||||
onChange(val);
|
||||
handleClose();
|
||||
};
|
||||
@ -164,13 +178,13 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
className={buttonClassName}
|
||||
isActive={isOpen}
|
||||
tooltipHeading="Estimate"
|
||||
tooltipContent={selectedEstimate !== null ? selectedEstimate : placeholder}
|
||||
tooltipContent={selectedEstimate ? selectedEstimate?.value : placeholder}
|
||||
showTooltip={showTooltip}
|
||||
variant={buttonVariant}
|
||||
>
|
||||
{!hideIcon && <Triangle className="h-3 w-3 flex-shrink-0" />}
|
||||
{(selectedEstimate || placeholder) && BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||
<span className="flex-grow truncate">{selectedEstimate !== null ? selectedEstimate : placeholder}</span>
|
||||
<span className="flex-grow truncate">{selectedEstimate ? selectedEstimate?.value : placeholder}</span>
|
||||
)}
|
||||
{dropdownArrow && (
|
||||
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
||||
@ -204,20 +218,14 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<Combobox.Option key={option.value} value={option.value}>
|
||||
{({ active, selected }) => (
|
||||
<div
|
||||
className={`flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${active ? `!hover:bg-custom-background-80` : ``} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`}
|
||||
>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Ref, useState } from "react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Popover } from "@headlessui/react";
|
||||
// popper
|
||||
@ -60,7 +60,7 @@ export const ComicBoxButton: React.FC<Props> = (props) => {
|
||||
<Popover.Panel
|
||||
as="div"
|
||||
className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100 p-5 relative min-w-80"
|
||||
ref={setPopperElement}
|
||||
ref={setPopperElement as Ref<HTMLDivElement>}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
static
|
||||
|
@ -1,292 +0,0 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// types
|
||||
import { IEstimate, IEstimateFormData } from "@plane/types";
|
||||
// ui
|
||||
import { Button, Input, TextArea, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
|
||||
// helpers
|
||||
import { checkDuplicates } from "@/helpers/array.helper";
|
||||
// hooks
|
||||
import { useEstimate } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
data?: IEstimate;
|
||||
};
|
||||
|
||||
const defaultValues = {
|
||||
name: "",
|
||||
description: "",
|
||||
value1: "",
|
||||
value2: "",
|
||||
value3: "",
|
||||
value4: "",
|
||||
value5: "",
|
||||
value6: "",
|
||||
};
|
||||
|
||||
type FormValues = typeof defaultValues;
|
||||
|
||||
export const CreateUpdateEstimateModal: React.FC<Props> = observer((props) => {
|
||||
const { handleClose, data, isOpen } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store hooks
|
||||
const { createEstimate, updateEstimate } = useEstimate();
|
||||
// form info
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
} = useForm<FormValues>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleCreateEstimate = async (payload: IEstimateFormData) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
await createEstimate(workspaceSlug.toString(), projectId.toString(), payload)
|
||||
.then(() => {
|
||||
onClose();
|
||||
})
|
||||
.catch((err) => {
|
||||
const error = err?.error;
|
||||
const errorString = Array.isArray(error) ? error[0] : error;
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message:
|
||||
errorString ?? err.status === 400
|
||||
? "Estimate with that name already exists. Please try again with another name."
|
||||
: "Estimate could not be created. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateEstimate = async (payload: IEstimateFormData) => {
|
||||
if (!workspaceSlug || !projectId || !data) return;
|
||||
|
||||
await updateEstimate(workspaceSlug.toString(), projectId.toString(), data.id, payload)
|
||||
.then(() => {
|
||||
onClose();
|
||||
})
|
||||
.catch((err) => {
|
||||
const error = err?.error;
|
||||
const errorString = Array.isArray(error) ? error[0] : error;
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: errorString ?? "Estimate could not be updated. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: FormValues) => {
|
||||
if (!formData.name || formData.name === "") {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Estimate title cannot be empty.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
formData.value1 === "" ||
|
||||
formData.value2 === "" ||
|
||||
formData.value3 === "" ||
|
||||
formData.value4 === "" ||
|
||||
formData.value5 === "" ||
|
||||
formData.value6 === ""
|
||||
) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Estimate point cannot be empty.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
formData.value1.length > 20 ||
|
||||
formData.value2.length > 20 ||
|
||||
formData.value3.length > 20 ||
|
||||
formData.value4.length > 20 ||
|
||||
formData.value5.length > 20 ||
|
||||
formData.value6.length > 20
|
||||
) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Estimate point cannot have more than 20 characters.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
checkDuplicates([
|
||||
formData.value1,
|
||||
formData.value2,
|
||||
formData.value3,
|
||||
formData.value4,
|
||||
formData.value5,
|
||||
formData.value6,
|
||||
])
|
||||
) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Estimate points cannot have duplicate values.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: IEstimateFormData = {
|
||||
estimate: {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
},
|
||||
estimate_points: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const point = {
|
||||
key: i,
|
||||
value: formData[`value${i + 1}` as keyof FormValues],
|
||||
};
|
||||
|
||||
if (data)
|
||||
payload.estimate_points.push({
|
||||
id: data.points[i].id,
|
||||
...point,
|
||||
});
|
||||
else payload.estimate_points.push({ ...point });
|
||||
}
|
||||
|
||||
if (data) await handleUpdateEstimate(payload);
|
||||
else await handleCreateEstimate(payload);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data)
|
||||
reset({
|
||||
...defaultValues,
|
||||
...data,
|
||||
value1: data.points[0]?.value,
|
||||
value2: data.points[1]?.value,
|
||||
value3: data.points[2]?.value,
|
||||
value4: data.points[3]?.value,
|
||||
value5: data.points[4]?.value,
|
||||
value6: data.points[5]?.value,
|
||||
});
|
||||
else reset({ ...defaultValues });
|
||||
}, [data, reset]);
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-5 p-5">
|
||||
<div className="text-xl font-medium text-custom-text-200">{data ? "Update" : "Create"} Estimate</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="name"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder="Title"
|
||||
className="w-full text-base"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TextArea
|
||||
id="description"
|
||||
name="description"
|
||||
value={value}
|
||||
placeholder="Description"
|
||||
onChange={onChange}
|
||||
className="w-full text-base resize-none min-h-24"
|
||||
hasError={Boolean(errors?.description)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* list of all the points */}
|
||||
{/* since they are all the same, we can use a loop to render them */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{Array(6)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div className="flex items-center" key={i}>
|
||||
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
|
||||
<span className="rounded-lg px-2 text-sm text-custom-text-200">{i + 1}</span>
|
||||
<span className="rounded-r-lg bg-custom-background-100">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`value${i + 1}` as keyof FormValues}
|
||||
rules={{
|
||||
maxLength: {
|
||||
value: 20,
|
||||
message: "Estimate point must at most be of 20 characters",
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
ref={ref}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
id={`value${i + 1}`}
|
||||
name={`value${i + 1}`}
|
||||
placeholder={`Point ${i + 1}`}
|
||||
className="w-full rounded-l-none"
|
||||
hasError={Boolean(errors[`value${i + 1}` as keyof FormValues])}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{data ? (isSubmitting ? "Updating" : "Update Estimate") : isSubmitting ? "Creating" : "Create Estimate"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
2
web/components/estimates/create/index.ts
Normal file
2
web/components/estimates/create/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./modal";
|
||||
export * from "./stage-one";
|
134
web/components/estimates/create/modal.tsx
Normal file
134
web/components/estimates/create/modal.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import { FC, useEffect, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { IEstimateFormData, TEstimateSystemKeys, TEstimatePointsObject } from "@plane/types";
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
|
||||
import { EstimateCreateStageOne, EstimatePointCreateRoot } from "@/components/estimates";
|
||||
// constants
|
||||
import { EEstimateSystem, ESTIMATE_SYSTEMS } from "@/constants/estimates";
|
||||
// hooks
|
||||
import { useProjectEstimates } from "@/hooks/store";
|
||||
|
||||
type TCreateEstimateModal = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const CreateEstimateModal: FC<TCreateEstimateModal> = observer((props) => {
|
||||
// props
|
||||
const { workspaceSlug, projectId, isOpen, handleClose } = props;
|
||||
// hooks
|
||||
const { createEstimate } = useProjectEstimates();
|
||||
// states
|
||||
const [estimateSystem, setEstimateSystem] = useState<TEstimateSystemKeys>(EEstimateSystem.POINTS);
|
||||
const [estimatePoints, setEstimatePoints] = useState<TEstimatePointsObject[] | undefined>(undefined);
|
||||
const [buttonLoader, setButtonLoader] = useState(false);
|
||||
|
||||
const handleUpdatePoints = (newPoints: TEstimatePointsObject[] | undefined) => setEstimatePoints(newPoints);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setEstimateSystem(EEstimateSystem.POINTS);
|
||||
setEstimatePoints(undefined);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleCreateEstimate = async () => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !estimatePoints) return;
|
||||
setButtonLoader(true);
|
||||
const payload: IEstimateFormData = {
|
||||
estimate: {
|
||||
name: ESTIMATE_SYSTEMS[estimateSystem]?.name,
|
||||
type: estimateSystem,
|
||||
last_used: true,
|
||||
},
|
||||
estimate_points: estimatePoints,
|
||||
};
|
||||
await createEstimate(workspaceSlug, projectId, payload);
|
||||
|
||||
setButtonLoader(false);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Estimate created",
|
||||
message: "A new estimate has been added in your project.",
|
||||
});
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
setButtonLoader(false);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Estimate creation failed",
|
||||
message: "We were unable to create the new estimate, please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// derived values
|
||||
const renderEstimateStepsCount = useMemo(() => (estimatePoints ? "2" : "1"), [estimatePoints]);
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<div className="relative space-y-6 py-5">
|
||||
{/* heading */}
|
||||
<div className="relative flex justify-between items-center gap-2 px-5">
|
||||
<div className="relative flex items-center gap-1">
|
||||
{estimatePoints && (
|
||||
<div
|
||||
onClick={() => {
|
||||
setEstimateSystem(EEstimateSystem.POINTS);
|
||||
handleUpdatePoints(undefined);
|
||||
}}
|
||||
className="flex-shrink-0 cursor-pointer w-5 h-5 flex justify-center items-center"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xl font-medium text-custom-text-100">New Estimate System</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">Step {renderEstimateStepsCount} of 2</div>
|
||||
</div>
|
||||
|
||||
{/* estimate steps */}
|
||||
<div className="px-5">
|
||||
{!estimatePoints && (
|
||||
<EstimateCreateStageOne
|
||||
estimateSystem={estimateSystem}
|
||||
handleEstimateSystem={setEstimateSystem}
|
||||
handleEstimatePoints={(templateType: string) =>
|
||||
handleUpdatePoints(ESTIMATE_SYSTEMS[estimateSystem].templates[templateType].values)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{estimatePoints && (
|
||||
<>
|
||||
<EstimatePointCreateRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
estimateId={undefined}
|
||||
estimateType={estimateSystem}
|
||||
estimatePoints={estimatePoints}
|
||||
setEstimatePoints={setEstimatePoints}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex justify-end items-center gap-3 px-5 pt-5 border-t border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose} disabled={buttonLoader}>
|
||||
Cancel
|
||||
</Button>
|
||||
{estimatePoints && (
|
||||
<Button variant="primary" size="sm" onClick={handleCreateEstimate} disabled={buttonLoader}>
|
||||
{buttonLoader ? `Creating` : `Create Estimate`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
101
web/components/estimates/create/stage-one.tsx
Normal file
101
web/components/estimates/create/stage-one.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { FC } from "react";
|
||||
import { Crown, Info } from "lucide-react";
|
||||
import { TEstimateSystemKeys } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { RadioInput } from "@/components/estimates";
|
||||
// constants
|
||||
import { ESTIMATE_SYSTEMS } from "@/constants/estimates";
|
||||
|
||||
type TEstimateCreateStageOne = {
|
||||
estimateSystem: TEstimateSystemKeys;
|
||||
handleEstimateSystem: (value: TEstimateSystemKeys) => void;
|
||||
handleEstimatePoints: (value: string) => void;
|
||||
};
|
||||
|
||||
export const EstimateCreateStageOne: FC<TEstimateCreateStageOne> = (props) => {
|
||||
const { estimateSystem, handleEstimateSystem, handleEstimatePoints } = props;
|
||||
|
||||
const currentEstimateSystem = ESTIMATE_SYSTEMS[estimateSystem] || undefined;
|
||||
|
||||
if (!currentEstimateSystem) return <></>;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="sm:flex sm:items-center sm:space-x-10 sm:space-y-0 gap-2 mb-2">
|
||||
<RadioInput
|
||||
options={Object.keys(ESTIMATE_SYSTEMS).map((system) => {
|
||||
const currentSystem = system as TEstimateSystemKeys;
|
||||
return {
|
||||
label: !ESTIMATE_SYSTEMS[currentSystem]?.is_available ? (
|
||||
<div className="relative flex items-center gap-2 cursor-no-drop text-custom-text-300">
|
||||
{ESTIMATE_SYSTEMS[currentSystem]?.name}
|
||||
<Tooltip tooltipContent={"Coming soon"}>
|
||||
<Info size={12} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : ESTIMATE_SYSTEMS[currentSystem]?.is_ee ? (
|
||||
<div className="relative flex items-center gap-2 cursor-no-drop text-custom-text-300">
|
||||
{ESTIMATE_SYSTEMS[currentSystem]?.name}
|
||||
<Tooltip tooltipContent={"upgrade"}>
|
||||
<Crown size={12} className="text-amber-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div>{ESTIMATE_SYSTEMS[currentSystem]?.name}</div>
|
||||
),
|
||||
value: system,
|
||||
disabled: !ESTIMATE_SYSTEMS[currentSystem]?.is_available || ESTIMATE_SYSTEMS[currentSystem]?.is_ee,
|
||||
};
|
||||
})}
|
||||
name="estimate-radio-input"
|
||||
label="Choose an estimate system"
|
||||
labelClassName="text-sm font-medium text-custom-text-200 mb-1.5"
|
||||
wrapperClassName="relative flex flex-wrap gap-14"
|
||||
fieldClassName="relative flex items-center gap-1.5"
|
||||
buttonClassName="size-4"
|
||||
selected={estimateSystem}
|
||||
onChange={(value) => handleEstimateSystem(value as TEstimateSystemKeys)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ESTIMATE_SYSTEMS[estimateSystem]?.is_available && !ESTIMATE_SYSTEMS[estimateSystem]?.is_ee && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium text-custom-text-200">Start from scratch</div>
|
||||
<button
|
||||
className="border border-custom-border-200 rounded-md p-3 py-2.5 text-left space-y-1 w-full block hover:bg-custom-background-90"
|
||||
onClick={() => handleEstimatePoints("custom")}
|
||||
>
|
||||
<p className="text-base font-medium">Custom</p>
|
||||
<p className="text-xs text-custom-text-300">
|
||||
Add your own <span className="lowercase">{currentEstimateSystem.name}</span> from scratch
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium text-custom-text-200">Choose a template</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{Object.keys(currentEstimateSystem.templates).map((name) =>
|
||||
currentEstimateSystem.templates[name]?.hide ? null : (
|
||||
<button
|
||||
key={name}
|
||||
className="border border-custom-border-200 rounded-md p-3 py-2.5 text-left space-y-1 hover:bg-custom-background-90"
|
||||
onClick={() => handleEstimatePoints(name)}
|
||||
>
|
||||
<p className="text-base font-medium">{currentEstimateSystem.templates[name]?.title}</p>
|
||||
<p className="text-xs text-custom-text-300">
|
||||
{currentEstimateSystem.templates[name]?.values?.map((template) => template?.value)?.join(", ")}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
//
|
@ -1,79 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// types
|
||||
import { IEstimate } from "@plane/types";
|
||||
// ui
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { AlertModalCore } from "@/components/core";
|
||||
// hooks
|
||||
import { useEstimate } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
data: IEstimate | null;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const DeleteEstimateModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, handleClose, data } = props;
|
||||
// states
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store hooks
|
||||
const { deleteEstimate } = useEstimate();
|
||||
|
||||
const handleEstimateDelete = async () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
|
||||
const estimateId = data?.id!;
|
||||
|
||||
await deleteEstimate(workspaceSlug.toString(), projectId.toString(), estimateId)
|
||||
.then(() => {
|
||||
handleClose();
|
||||
})
|
||||
.catch((err) => {
|
||||
const error = err?.error;
|
||||
const errorString = Array.isArray(error) ? error[0] : error;
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: errorString ?? "Estimate could not be deleted. Please try again",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsDeleteLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsDeleteLoading(false);
|
||||
}, [isOpen]);
|
||||
|
||||
const onClose = () => {
|
||||
setIsDeleteLoading(false);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertModalCore
|
||||
handleClose={onClose}
|
||||
handleSubmit={handleEstimateDelete}
|
||||
isSubmitting={isDeleteLoading}
|
||||
isOpen={isOpen}
|
||||
title="Delete Estimate"
|
||||
content={
|
||||
<>
|
||||
Are you sure you want to delete estimate-{" "}
|
||||
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>
|
||||
{""}? All of the data related to the estiamte will be permanently removed. This action cannot be undone.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
1
web/components/estimates/delete/index.ts
Normal file
1
web/components/estimates/delete/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./modal";
|
81
web/components/estimates/delete/modal.tsx
Normal file
81
web/components/estimates/delete/modal.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
|
||||
// hooks
|
||||
import { useEstimate, useProject, useProjectEstimates } from "@/hooks/store";
|
||||
|
||||
type TDeleteEstimateModal = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
estimateId: string | undefined;
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const DeleteEstimateModal: FC<TDeleteEstimateModal> = observer((props) => {
|
||||
// props
|
||||
const { workspaceSlug, projectId, estimateId, isOpen, handleClose } = props;
|
||||
// hooks
|
||||
const { areEstimateEnabledByProjectId, deleteEstimate } = useProjectEstimates();
|
||||
const { asJson: estimate } = useEstimate(estimateId);
|
||||
const { updateProject } = useProject();
|
||||
// states
|
||||
const [buttonLoader, setButtonLoader] = useState(false);
|
||||
|
||||
const handleDeleteEstimate = async () => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !estimateId) return;
|
||||
setButtonLoader(true);
|
||||
|
||||
await deleteEstimate(workspaceSlug, projectId, estimateId);
|
||||
if (areEstimateEnabledByProjectId(projectId)) {
|
||||
await updateProject(workspaceSlug, projectId, { estimate: null });
|
||||
}
|
||||
setButtonLoader(false);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Estimate deleted",
|
||||
message: "Estimate has been removed from your project.",
|
||||
});
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
setButtonLoader(false);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Estimate creation failed",
|
||||
message: "We were unable to delete the estimate, please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<div className="relative space-y-6 py-5">
|
||||
{/* heading */}
|
||||
<div className="relative flex justify-between items-center gap-2 px-5">
|
||||
<div className="text-xl font-medium text-custom-text-100">Delete Estimate System</div>
|
||||
</div>
|
||||
|
||||
{/* estimate steps */}
|
||||
<div className="px-5">
|
||||
<div className="text-base text-custom-text-200">
|
||||
Deleting the estimate <span className="font-bold text-custom-text-100">{estimate?.name}</span>
|
||||
system will remove it from all issues permanently. This action cannot be undone. If you add estimates
|
||||
again, you will need to update all the issues.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex justify-end items-center gap-3 px-5 pt-5 border-t border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose} disabled={buttonLoader}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={handleDeleteEstimate} disabled={buttonLoader}>
|
||||
{buttonLoader ? "Deleting" : "Delete Estimate"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
42
web/components/estimates/empty-screen.tsx
Normal file
42
web/components/estimates/empty-screen.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { FC } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@plane/ui";
|
||||
// public images
|
||||
import EstimateEmptyDarkImage from "@/public/empty-state/estimates/dark.svg";
|
||||
import EstimateEmptyLightImage from "@/public/empty-state/estimates/light.svg";
|
||||
|
||||
type TEstimateEmptyScreen = {
|
||||
onButtonClick: () => void;
|
||||
};
|
||||
|
||||
export const EstimateEmptyScreen: FC<TEstimateEmptyScreen> = (props) => {
|
||||
// props
|
||||
const { onButtonClick } = props;
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const emptyScreenImage = resolvedTheme === "light" ? EstimateEmptyLightImage : EstimateEmptyDarkImage;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col justify-center items-center text-center gap-8 border border-custom-border-300 rounded bg-custom-background-90 py-10">
|
||||
<div className="flex-shrink-0 w-[120px] h-[120px] overflow-hidden relative flex justify-center items-center">
|
||||
<Image
|
||||
src={emptyScreenImage}
|
||||
alt="Empty estimate image"
|
||||
width={100}
|
||||
height={100}
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-xl font-semibold text-custom-text-100">No estimate systems yet</h3>
|
||||
<p className="text-sm text-custom-text-300">
|
||||
Create a set of estimates to communicate the amount of work per issue.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={onButtonClick}>Add Estimate System</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
50
web/components/estimates/estimate-disable-switch.tsx
Normal file
50
web/components/estimates/estimate-disable-switch.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProject, useProjectEstimates } from "@/hooks/store";
|
||||
|
||||
type TEstimateDisableSwitch = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
export const EstimateDisableSwitch: FC<TEstimateDisableSwitch> = observer((props) => {
|
||||
const { workspaceSlug, projectId, isAdmin } = props;
|
||||
// hooks
|
||||
const { updateProject, currentProjectDetails } = useProject();
|
||||
const { currentActiveEstimateId } = useProjectEstimates();
|
||||
|
||||
const currentProjectActiveEstimate = currentProjectDetails?.estimate || undefined;
|
||||
|
||||
const disableEstimate = async () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
try {
|
||||
await updateProject(workspaceSlug, projectId, {
|
||||
estimate: currentProjectActiveEstimate ? null : currentActiveEstimateId,
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: currentProjectActiveEstimate ? "Estimates have been disabled" : "Estimates have been enabled",
|
||||
});
|
||||
} catch (err) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Estimate could not be disabled. Please try again",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleSwitch
|
||||
value={Boolean(currentProjectActiveEstimate)}
|
||||
onChange={disableEstimate}
|
||||
disabled={!isAdmin}
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
});
|
34
web/components/estimates/estimate-list-item-buttons.tsx
Normal file
34
web/components/estimates/estimate-list-item-buttons.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Pen, Trash } from "lucide-react";
|
||||
|
||||
type TEstimateListItem = {
|
||||
estimateId: string;
|
||||
isAdmin: boolean;
|
||||
isEstimateEnabled: boolean;
|
||||
isEditable: boolean;
|
||||
onEditClick?: (estimateId: string) => void;
|
||||
onDeleteClick?: (estimateId: string) => void;
|
||||
};
|
||||
|
||||
export const EstimateListItemButtons: FC<TEstimateListItem> = observer((props) => {
|
||||
const { estimateId, isAdmin, isEditable, onEditClick, onDeleteClick } = props;
|
||||
if (!isAdmin || !isEditable) return <></>;
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-1">
|
||||
<button
|
||||
className="relative flex-shrink-0 w-6 h-6 flex justify-center items-center rounded cursor-pointer transition-colors overflow-hidden hover:bg-custom-background-80"
|
||||
onClick={() => onEditClick && onEditClick(estimateId)}
|
||||
>
|
||||
<Pen size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="relative flex-shrink-0 w-6 h-6 flex justify-center items-center rounded cursor-pointer transition-colors overflow-hidden hover:bg-custom-background-80"
|
||||
onClick={() => onDeleteClick && onDeleteClick(estimateId)}
|
||||
>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,114 +1,46 @@
|
||||
import React from "react";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { IEstimate } from "@plane/types";
|
||||
import { Button, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { orderArrayBy } from "@/helpers/array.helper";
|
||||
import { useProject } from "@/hooks/store";
|
||||
// ui
|
||||
//icons
|
||||
// helpers
|
||||
// types
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useEstimate, useProjectEstimates } from "@/hooks/store";
|
||||
import { EstimateListItemButtons } from "./estimate-list-item-buttons";
|
||||
|
||||
type Props = {
|
||||
estimate: IEstimate;
|
||||
editEstimate: (estimate: IEstimate) => void;
|
||||
deleteEstimate: (estimateId: string) => void;
|
||||
type TEstimateListItem = {
|
||||
estimateId: string;
|
||||
isAdmin: boolean;
|
||||
isEstimateEnabled: boolean;
|
||||
isEditable: boolean;
|
||||
onEditClick?: (estimateId: string) => void;
|
||||
onDeleteClick?: (estimateId: string) => void;
|
||||
};
|
||||
|
||||
export const EstimateListItem: React.FC<Props> = observer((props) => {
|
||||
const { estimate, editEstimate, deleteEstimate } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store hooks
|
||||
const { currentProjectDetails, updateProject } = useProject();
|
||||
export const EstimateListItem: FC<TEstimateListItem> = observer((props) => {
|
||||
const { estimateId, isAdmin, isEstimateEnabled, isEditable } = props;
|
||||
// hooks
|
||||
const { estimateById } = useProjectEstimates();
|
||||
const { estimatePointIds, estimatePointById } = useEstimate(estimateId);
|
||||
const currentEstimate = estimateById(estimateId);
|
||||
|
||||
const handleUseEstimate = async () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
await updateProject(workspaceSlug.toString(), projectId.toString(), {
|
||||
estimate: estimate.id,
|
||||
}).catch((err) => {
|
||||
const error = err?.error;
|
||||
const errorString = Array.isArray(error) ? error[0] : error;
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: errorString ?? "Estimate points could not be used. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
// derived values
|
||||
const estimatePointValues = estimatePointIds?.map((estimatePointId) => {
|
||||
const estimatePoint = estimatePointById(estimatePointId);
|
||||
if (estimatePoint) return estimatePoint.value;
|
||||
});
|
||||
|
||||
if (!currentEstimate) return <></>;
|
||||
return (
|
||||
<>
|
||||
<div className="gap-2 border-b border-custom-border-100 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h6 className="flex w-[40vw] items-center gap-2 truncate text-sm font-medium">
|
||||
{estimate.name}
|
||||
{currentProjectDetails?.estimate && currentProjectDetails?.estimate === estimate.id && (
|
||||
<span className="rounded bg-green-500/20 px-2 py-0.5 text-xs text-green-500">In use</span>
|
||||
)}
|
||||
</h6>
|
||||
<p className="font-sm w-[40vw] truncate text-[14px] font-normal text-custom-text-200">
|
||||
{estimate.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentProjectDetails?.estimate !== estimate?.id && estimate?.points?.length > 0 && (
|
||||
<Button variant="neutral-primary" onClick={handleUseEstimate} size="sm">
|
||||
Use
|
||||
</Button>
|
||||
)}
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
editEstimate(estimate);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<span>Edit estimate</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
{currentProjectDetails?.estimate !== estimate.id && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
deleteEstimate(estimate.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span>Delete estimate</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
{estimate?.points?.length > 0 ? (
|
||||
<div className="flex text-xs text-custom-text-200">
|
||||
Estimate points (
|
||||
<span className="flex gap-1">
|
||||
{orderArrayBy(estimate.points, "key").map((point, index) => (
|
||||
<h6 key={point.id} className="text-custom-text-200">
|
||||
{point.value}
|
||||
{index !== estimate.points.length - 1 && ","}{" "}
|
||||
</h6>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-xs text-custom-text-200">No estimate points</p>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"relative border-b border-custom-border-200 flex justify-between items-center gap-3 py-3.5",
|
||||
isAdmin && isEditable && isEstimateEnabled ? `text-custom-text-100` : `text-custom-text-200`
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-base">{currentEstimate?.name}</h3>
|
||||
<p className="text-xs">{(estimatePointValues || [])?.join(", ")}</p>
|
||||
</div>
|
||||
</>
|
||||
<EstimateListItemButtons {...props} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
35
web/components/estimates/estimate-list.tsx
Normal file
35
web/components/estimates/estimate-list.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { EstimateListItem } from "@/components/estimates";
|
||||
|
||||
type TEstimateList = {
|
||||
estimateIds: string[] | undefined;
|
||||
isAdmin: boolean;
|
||||
isEstimateEnabled?: boolean;
|
||||
isEditable?: boolean;
|
||||
onEditClick?: (estimateId: string) => void;
|
||||
onDeleteClick?: (estimateId: string) => void;
|
||||
};
|
||||
|
||||
export const EstimateList: FC<TEstimateList> = observer((props) => {
|
||||
const { estimateIds, isAdmin, isEstimateEnabled = false, isEditable = false, onEditClick, onDeleteClick } = props;
|
||||
|
||||
if (!estimateIds || estimateIds?.length <= 0) return <></>;
|
||||
return (
|
||||
<div>
|
||||
{estimateIds &&
|
||||
estimateIds.map((estimateId) => (
|
||||
<EstimateListItem
|
||||
key={estimateId}
|
||||
estimateId={estimateId}
|
||||
isAdmin={isAdmin}
|
||||
isEstimateEnabled={isEstimateEnabled}
|
||||
isEditable={isEditable}
|
||||
onEditClick={onEditClick}
|
||||
onDeleteClick={onDeleteClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
9
web/components/estimates/estimate-search.tsx
Normal file
9
web/components/estimates/estimate-search.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
export const EstimateSearch: FC = observer(() => {
|
||||
// hooks
|
||||
const {} = {};
|
||||
|
||||
return <div>Estimate Search</div>;
|
||||
});
|
@ -1,121 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { IEstimate } from "@plane/types";
|
||||
// store hooks
|
||||
import { Button, Loader, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { CreateUpdateEstimateModal, DeleteEstimateModal, EstimateListItem } from "@/components/estimates";
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { orderArrayBy } from "@/helpers/array.helper";
|
||||
import { useEstimate, useProject } from "@/hooks/store";
|
||||
// components
|
||||
// ui
|
||||
// types
|
||||
// helpers
|
||||
// constants
|
||||
|
||||
export const EstimatesList: React.FC = observer(() => {
|
||||
// states
|
||||
const [estimateFormOpen, setEstimateFormOpen] = useState(false);
|
||||
const [estimateToDelete, setEstimateToDelete] = useState<string | null>(null);
|
||||
const [estimateToUpdate, setEstimateToUpdate] = useState<IEstimate | undefined>();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store hooks
|
||||
const { updateProject, currentProjectDetails } = useProject();
|
||||
const { projectEstimates, getProjectEstimateById } = useEstimate();
|
||||
|
||||
const editEstimate = (estimate: IEstimate) => {
|
||||
setEstimateFormOpen(true);
|
||||
// Order the points array by key before updating the estimate to update state
|
||||
setEstimateToUpdate({
|
||||
...estimate,
|
||||
points: orderArrayBy(estimate.points, "key"),
|
||||
});
|
||||
};
|
||||
|
||||
const disableEstimates = () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
updateProject(workspaceSlug.toString(), projectId.toString(), { estimate: null }).catch((err) => {
|
||||
const error = err?.error;
|
||||
const errorString = Array.isArray(error) ? error[0] : error;
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: errorString ?? "Estimate could not be disabled. Please try again",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateEstimateModal
|
||||
isOpen={estimateFormOpen}
|
||||
data={estimateToUpdate}
|
||||
handleClose={() => {
|
||||
setEstimateFormOpen(false);
|
||||
setEstimateToUpdate(undefined);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteEstimateModal
|
||||
isOpen={!!estimateToDelete}
|
||||
handleClose={() => setEstimateToDelete(null)}
|
||||
data={getProjectEstimateById(estimateToDelete!)}
|
||||
/>
|
||||
|
||||
<section className="flex items-center justify-between border-b border-custom-border-100 py-3.5">
|
||||
<h3 className="text-xl font-medium">Estimates</h3>
|
||||
<div className="col-span-12 space-y-5 sm:col-span-7">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setEstimateFormOpen(true);
|
||||
setEstimateToUpdate(undefined);
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
Add Estimate
|
||||
</Button>
|
||||
{currentProjectDetails?.estimate && (
|
||||
<Button variant="neutral-primary" onClick={disableEstimates} size="sm">
|
||||
Disable Estimates
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{projectEstimates ? (
|
||||
projectEstimates.length > 0 ? (
|
||||
<section className="h-full overflow-y-auto bg-custom-background-100">
|
||||
{projectEstimates.map((estimate) => (
|
||||
<EstimateListItem
|
||||
key={estimate.id}
|
||||
estimate={estimate}
|
||||
editEstimate={(estimate) => editEstimate(estimate)}
|
||||
deleteEstimate={(estimateId) => setEstimateToDelete(estimateId)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<div className="h-full w-full py-8">
|
||||
<EmptyState type={EmptyStateType.PROJECT_SETTINGS_ESTIMATE} />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="mt-5 space-y-5">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
@ -1,4 +1,25 @@
|
||||
export * from "./create-update-estimate-modal";
|
||||
export * from "./delete-estimate-modal";
|
||||
export * from "./root";
|
||||
|
||||
export * from "./empty-screen";
|
||||
export * from "./loader-screen";
|
||||
export * from "./radio-select";
|
||||
|
||||
export * from "./estimate-search";
|
||||
export * from "./estimate-disable-switch";
|
||||
|
||||
// estimates
|
||||
export * from "./estimate-list";
|
||||
export * from "./estimate-list-item";
|
||||
export * from "./estimates-list";
|
||||
export * from "./estimate-list-item-buttons";
|
||||
|
||||
// create
|
||||
export * from "./create";
|
||||
|
||||
// update
|
||||
export * from "./update";
|
||||
|
||||
// delete
|
||||
export * from "./delete";
|
||||
|
||||
// estimate points
|
||||
export * from "./points";
|
||||
|
11
web/components/estimates/loader-screen.tsx
Normal file
11
web/components/estimates/loader-screen.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { FC } from "react";
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const EstimateLoaderScreen: FC = () => (
|
||||
<Loader className="mt-5 space-y-5">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
);
|
139
web/components/estimates/points/create-root.tsx
Normal file
139
web/components/estimates/points/create-root.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { Dispatch, FC, SetStateAction, useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { TEstimatePointsObject, TEstimateSystemKeys } from "@plane/types";
|
||||
import { Button, Sortable } from "@plane/ui";
|
||||
// components
|
||||
import { EstimatePointCreate, EstimatePointItemPreview } from "@/components/estimates/points";
|
||||
// constants
|
||||
import { maxEstimatesCount } from "@/constants/estimates";
|
||||
|
||||
type TEstimatePointCreateRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
estimateId: string | undefined;
|
||||
estimateType: TEstimateSystemKeys;
|
||||
estimatePoints: TEstimatePointsObject[];
|
||||
setEstimatePoints: Dispatch<SetStateAction<TEstimatePointsObject[] | undefined>>;
|
||||
};
|
||||
|
||||
export const EstimatePointCreateRoot: FC<TEstimatePointCreateRoot> = observer((props) => {
|
||||
// props
|
||||
const { workspaceSlug, projectId, estimateId, estimateType, estimatePoints, setEstimatePoints } = props;
|
||||
// states
|
||||
const [estimatePointCreate, setEstimatePointCreate] = useState<TEstimatePointsObject[] | undefined>(undefined);
|
||||
|
||||
const handleEstimatePoint = useCallback(
|
||||
(mode: "add" | "remove" | "update", value: TEstimatePointsObject) => {
|
||||
switch (mode) {
|
||||
case "add":
|
||||
setEstimatePoints((prevValue) => {
|
||||
prevValue = prevValue ? [...prevValue] : [];
|
||||
return [...prevValue, value];
|
||||
});
|
||||
break;
|
||||
case "update":
|
||||
setEstimatePoints((prevValue) => {
|
||||
prevValue = prevValue ? [...prevValue] : [];
|
||||
return prevValue.map((item) => (item.key === value.key ? { ...item, value: value.value } : item));
|
||||
});
|
||||
break;
|
||||
case "remove":
|
||||
setEstimatePoints((prevValue) => {
|
||||
prevValue = prevValue ? [...prevValue] : [];
|
||||
return prevValue.filter((item) => item.key !== value.key);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[setEstimatePoints]
|
||||
);
|
||||
|
||||
const handleEstimatePointCreate = (mode: "add" | "remove", value: TEstimatePointsObject) => {
|
||||
switch (mode) {
|
||||
case "add":
|
||||
setEstimatePointCreate((prevValue) => {
|
||||
prevValue = prevValue ? [...prevValue] : [];
|
||||
return [...prevValue, value];
|
||||
});
|
||||
break;
|
||||
case "remove":
|
||||
setEstimatePointCreate((prevValue) => {
|
||||
prevValue = prevValue ? [...prevValue] : [];
|
||||
return prevValue.filter((item) => item.key !== value.key);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEstimatePoints = (updatedEstimatedOrder: TEstimatePointsObject[]) => {
|
||||
const updatedEstimateKeysOrder = updatedEstimatedOrder.map((item, index) => ({ ...item, key: index + 1 }));
|
||||
setEstimatePoints(() => updatedEstimateKeysOrder);
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-custom-text-200 capitalize">{estimateType}</div>
|
||||
|
||||
<div>
|
||||
<Sortable
|
||||
data={estimatePoints}
|
||||
render={(value: TEstimatePointsObject) => (
|
||||
<EstimatePointItemPreview
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
estimateId={estimateId}
|
||||
estimateType={estimateType}
|
||||
estimatePointId={value?.id}
|
||||
estimatePoints={estimatePoints}
|
||||
estimatePoint={value}
|
||||
handleEstimatePointValueUpdate={(estimatePointValue: string) =>
|
||||
handleEstimatePoint("update", { ...value, value: estimatePointValue })
|
||||
}
|
||||
handleEstimatePointValueRemove={() => handleEstimatePoint("remove", value)}
|
||||
/>
|
||||
)}
|
||||
onChange={(data: TEstimatePointsObject[]) => handleDragEstimatePoints(data)}
|
||||
keyExtractor={(item: TEstimatePointsObject) => item?.id?.toString() || item.value.toString()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{estimatePointCreate &&
|
||||
estimatePointCreate.map((estimatePoint) => (
|
||||
<EstimatePointCreate
|
||||
key={estimatePoint?.key}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
estimateId={estimateId}
|
||||
estimateType={estimateType}
|
||||
estimatePoints={estimatePoints}
|
||||
handleEstimatePointValue={(estimatePointValue: string) =>
|
||||
handleEstimatePoint("add", { ...estimatePoint, value: estimatePointValue })
|
||||
}
|
||||
closeCallBack={() => handleEstimatePointCreate("remove", estimatePoint)}
|
||||
/>
|
||||
))}
|
||||
{estimatePoints && estimatePoints.length + (estimatePointCreate?.length || 0) <= maxEstimatesCount && (
|
||||
<Button
|
||||
variant="link-primary"
|
||||
size="sm"
|
||||
prependIcon={<Plus />}
|
||||
onClick={() =>
|
||||
handleEstimatePointCreate("add", {
|
||||
id: undefined,
|
||||
key: estimatePoints.length + (estimatePointCreate?.length || 0) + 1,
|
||||
value: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
Add {estimateType}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
167
web/components/estimates/points/create.tsx
Normal file
167
web/components/estimates/points/create.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import { FC, FormEvent, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Check, Info, X } from "lucide-react";
|
||||
import { TEstimatePointsObject, TEstimateSystemKeys } from "@plane/types";
|
||||
import { Spinner, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
||||
// constants
|
||||
import { EEstimateSystem } from "@/constants/estimates";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { isEstimatePointValuesRepeated } from "@/helpers/estimates";
|
||||
// hooks
|
||||
import { useEstimate } from "@/hooks/store";
|
||||
|
||||
type TEstimatePointCreate = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
estimateId: string | undefined;
|
||||
estimateType: TEstimateSystemKeys;
|
||||
estimatePoints: TEstimatePointsObject[];
|
||||
handleEstimatePointValue?: (estimateValue: string) => void;
|
||||
closeCallBack: () => void;
|
||||
};
|
||||
|
||||
export const EstimatePointCreate: FC<TEstimatePointCreate> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
estimateId,
|
||||
estimateType,
|
||||
estimatePoints,
|
||||
handleEstimatePointValue,
|
||||
closeCallBack,
|
||||
} = props;
|
||||
// hooks
|
||||
const { creteEstimatePoint } = useEstimate(estimateId);
|
||||
// states
|
||||
const [estimateInputValue, setEstimateInputValue] = useState("");
|
||||
const [loader, setLoader] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleSuccess = (value: string) => {
|
||||
handleEstimatePointValue && handleEstimatePointValue(value);
|
||||
setEstimateInputValue("");
|
||||
closeCallBack();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setEstimateInputValue("");
|
||||
closeCallBack();
|
||||
};
|
||||
|
||||
const handleCreate = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setError(undefined);
|
||||
|
||||
if (estimateInputValue) {
|
||||
const currentEstimateType: EEstimateSystem | undefined = estimateType;
|
||||
let isEstimateValid = false;
|
||||
|
||||
const currentEstimatePointValues = estimatePoints
|
||||
.map((point) => point?.value || undefined)
|
||||
.filter((value) => value != undefined) as string[];
|
||||
const isRepeated =
|
||||
(estimateType && isEstimatePointValuesRepeated(currentEstimatePointValues, estimateType, estimateInputValue)) ||
|
||||
false;
|
||||
|
||||
if (!isRepeated) {
|
||||
if (currentEstimateType && [(EEstimateSystem.TIME, EEstimateSystem.POINTS)].includes(currentEstimateType)) {
|
||||
if (estimateInputValue && Number(estimateInputValue) && Number(estimateInputValue) >= 0) {
|
||||
isEstimateValid = true;
|
||||
}
|
||||
} else if (currentEstimateType && currentEstimateType === EEstimateSystem.CATEGORIES) {
|
||||
if (estimateInputValue && estimateInputValue.length > 0) {
|
||||
isEstimateValid = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEstimateValid) {
|
||||
if (estimateId != undefined) {
|
||||
try {
|
||||
setLoader(true);
|
||||
|
||||
const payload = {
|
||||
key: estimatePoints?.length + 1,
|
||||
value: estimateInputValue,
|
||||
};
|
||||
await creteEstimatePoint(workspaceSlug, projectId, payload);
|
||||
|
||||
setLoader(false);
|
||||
setError(undefined);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Estimate point created",
|
||||
message: "The estimate point has been created successfully.",
|
||||
});
|
||||
handleClose();
|
||||
} catch {
|
||||
setLoader(false);
|
||||
setError("We are unable to process your request, please try again.");
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Estimate point creation failed",
|
||||
message: "We were unable to create the new estimate point, please try again.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
handleSuccess(estimateInputValue);
|
||||
}
|
||||
} else {
|
||||
setLoader(false);
|
||||
setError(
|
||||
[EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(estimateType)
|
||||
? "Estimate point needs to be a numeric value."
|
||||
: "Estimate point needs to be a character value."
|
||||
);
|
||||
}
|
||||
} else setError("Estimate value already exists.");
|
||||
} else setError("Estimate value cannot be empty.");
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleCreate} className="relative flex items-center gap-2 text-base">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full border rounded flex items-center",
|
||||
error ? `border-red-500` : `border-custom-border-200`
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={estimateInputValue}
|
||||
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"
|
||||
placeholder="Enter estimate point"
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<>
|
||||
<Tooltip tooltipContent={error} position="bottom">
|
||||
<div className="flex-shrink-0 w-3.5 h-3.5 overflow-hidden mr-3 relative flex justify-center items-center text-red-500">
|
||||
<Info size={14} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
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"
|
||||
disabled={loader}
|
||||
>
|
||||
{loader ? <Spinner className="w-4 h-4" /> : <Check size={14} />}
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
onClick={handleClose}
|
||||
disabled={loader}
|
||||
>
|
||||
<X size={14} className="text-custom-text-200" />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
});
|
118
web/components/estimates/points/delete.tsx
Normal file
118
web/components/estimates/points/delete.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoveRight, Trash2, X } from "lucide-react";
|
||||
import { TEstimatePointsObject } from "@plane/types";
|
||||
import { Spinner, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { EstimatePointDropdown } from "@/components/estimates/points";
|
||||
// hooks
|
||||
import { useEstimate, useEstimatePoint } from "@/hooks/store";
|
||||
|
||||
type TEstimatePointDelete = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
estimateId: string;
|
||||
estimatePointId: string;
|
||||
callback: () => void;
|
||||
};
|
||||
|
||||
export const EstimatePointDelete: FC<TEstimatePointDelete> = observer((props) => {
|
||||
const { workspaceSlug, projectId, estimateId, estimatePointId, callback } = props;
|
||||
// hooks
|
||||
const { estimatePointIds, estimatePointById, deleteEstimatePoint } = useEstimate(estimateId);
|
||||
const { asJson: estimatePoint } = useEstimatePoint(estimateId, estimatePointId);
|
||||
// states
|
||||
const [loader, setLoader] = useState(false);
|
||||
const [estimateInputValue, setEstimateInputValue] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleClose = () => {
|
||||
setEstimateInputValue("");
|
||||
callback();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!workspaceSlug || !projectId || !projectId) return;
|
||||
|
||||
setError(undefined);
|
||||
|
||||
if (estimateInputValue)
|
||||
try {
|
||||
setLoader(true);
|
||||
|
||||
await deleteEstimatePoint(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
estimatePointId,
|
||||
estimateInputValue === "none" ? undefined : estimateInputValue
|
||||
);
|
||||
|
||||
setLoader(false);
|
||||
setError(undefined);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Estimate point updated",
|
||||
message: "The estimate point has been updated successfully.",
|
||||
});
|
||||
handleClose();
|
||||
} catch {
|
||||
setLoader(false);
|
||||
setError("something went wrong. please try again later");
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Estimate point failed to updated",
|
||||
message: "We are unable to process your request, please try again.",
|
||||
});
|
||||
}
|
||||
else setError("please select option");
|
||||
};
|
||||
|
||||
// derived values
|
||||
const selectDropdownOptionIds = estimatePointIds?.filter((pointId) => pointId != estimatePointId) as string[];
|
||||
const selectDropdownOptions = (selectDropdownOptionIds || [])
|
||||
?.map((pointId) => {
|
||||
const estimatePoint = estimatePointById(pointId);
|
||||
if (estimatePoint && estimatePoint?.id)
|
||||
return { id: estimatePoint.id, key: estimatePoint.key, value: estimatePoint.value };
|
||||
})
|
||||
.filter((estimatePoint) => estimatePoint != undefined) as TEstimatePointsObject[];
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2 text-base">
|
||||
<div className="flex-grow relative flex items-center gap-3">
|
||||
<div className="w-full border border-custom-border-200 rounded p-2.5 bg-custom-background-90">
|
||||
{estimatePoint?.value}
|
||||
</div>
|
||||
<div className="text-sm first-letter:relative flex justify-center items-center gap-2 whitespace-nowrap">
|
||||
Mark as <MoveRight size={14} />
|
||||
</div>
|
||||
<EstimatePointDropdown
|
||||
options={selectDropdownOptions}
|
||||
error={error}
|
||||
callback={(estimateId: string) => {
|
||||
setEstimateInputValue(estimateId);
|
||||
setError(undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{loader ? (
|
||||
<div className="w-6 h-6 flex-shrink-0 relative flex justify-center items-center rota">
|
||||
<Spinner className="w-4 h-4" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
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-red-500"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
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"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={14} className="text-custom-text-200" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
7
web/components/estimates/points/index.ts
Normal file
7
web/components/estimates/points/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export * from "./preview";
|
||||
export * from "./create";
|
||||
export * from "./update";
|
||||
export * from "./delete";
|
||||
export * from "./select-dropdown";
|
||||
|
||||
export * from "./create-root";
|
103
web/components/estimates/points/preview.tsx
Normal file
103
web/components/estimates/points/preview.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { FC, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { GripVertical, Pencil, Trash2 } from "lucide-react";
|
||||
import { TEstimatePointsObject, TEstimateSystemKeys } from "@plane/types";
|
||||
// components
|
||||
import { EstimatePointUpdate, EstimatePointDelete } from "@/components/estimates/points";
|
||||
|
||||
type TEstimatePointItemPreview = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
estimateId: string | undefined;
|
||||
estimateType: TEstimateSystemKeys;
|
||||
estimatePointId: string | undefined;
|
||||
estimatePoint: TEstimatePointsObject;
|
||||
estimatePoints: TEstimatePointsObject[];
|
||||
handleEstimatePointValueUpdate?: (estimateValue: string) => void;
|
||||
handleEstimatePointValueRemove?: () => void;
|
||||
};
|
||||
|
||||
export const EstimatePointItemPreview: FC<TEstimatePointItemPreview> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
estimateId,
|
||||
estimateType,
|
||||
estimatePointId,
|
||||
estimatePoint,
|
||||
estimatePoints,
|
||||
handleEstimatePointValueUpdate,
|
||||
handleEstimatePointValueRemove,
|
||||
} = props;
|
||||
// state
|
||||
const [estimatePointEditToggle, setEstimatePointEditToggle] = useState(false);
|
||||
const [estimatePointDeleteToggle, setEstimatePointDeleteToggle] = useState(false);
|
||||
// ref
|
||||
const EstimatePointValueRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!estimatePointEditToggle && !estimatePointDeleteToggle)
|
||||
EstimatePointValueRef?.current?.addEventListener("dblclick", () => setEstimatePointEditToggle(true));
|
||||
}, [estimatePointDeleteToggle, estimatePointEditToggle]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!estimatePointEditToggle && !estimatePointDeleteToggle && (
|
||||
<div className="border border-custom-border-200 rounded relative flex items-center px-2.5 gap-2 text-base my-1">
|
||||
<div 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">
|
||||
<GripVertical size={14} className="text-custom-text-200" />
|
||||
</div>
|
||||
<div ref={EstimatePointValueRef} className="py-2.5 w-full">
|
||||
{estimatePoint?.value ? (
|
||||
`${estimatePoint?.value}`
|
||||
) : (
|
||||
<span className="text-custom-text-400">Enter estimate point</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
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"
|
||||
onClick={() => setEstimatePointEditToggle(true)}
|
||||
>
|
||||
<Pencil size={14} className="text-custom-text-200" />
|
||||
</div>
|
||||
<div
|
||||
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"
|
||||
onClick={() =>
|
||||
estimateId && estimatePointId
|
||||
? setEstimatePointDeleteToggle(true)
|
||||
: handleEstimatePointValueRemove && handleEstimatePointValueRemove()
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} className="text-custom-text-200" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{estimatePoint && estimatePointEditToggle && (
|
||||
<EstimatePointUpdate
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
estimateId={estimateId}
|
||||
estimateType={estimateType}
|
||||
estimatePointId={estimatePointId}
|
||||
estimatePoints={estimatePoints}
|
||||
estimatePoint={estimatePoint}
|
||||
handleEstimatePointValueUpdate={(estimatePointValue: string) =>
|
||||
handleEstimatePointValueUpdate && handleEstimatePointValueUpdate(estimatePointValue)
|
||||
}
|
||||
closeCallBack={() => setEstimatePointEditToggle(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{estimateId && estimatePointId && estimatePointDeleteToggle && (
|
||||
<EstimatePointDelete
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
estimateId={estimateId}
|
||||
estimatePointId={estimatePointId}
|
||||
callback={() => estimateId && setEstimatePointDeleteToggle(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
127
web/components/estimates/points/select-dropdown.tsx
Normal file
127
web/components/estimates/points/select-dropdown.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { FC, useRef, Fragment, useState } from "react";
|
||||
import { Info, Check, ChevronDown } from "lucide-react";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { TEstimatePointsObject } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import useDynamicDropdownPosition from "@/hooks/use-dynamic-dropdown";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
|
||||
type TEstimatePointDropdown = {
|
||||
options: TEstimatePointsObject[];
|
||||
error: string | undefined;
|
||||
callback: (estimateId: string) => void;
|
||||
};
|
||||
|
||||
export const EstimatePointDropdown: FC<TEstimatePointDropdown> = (props) => {
|
||||
const { options, error, callback } = props;
|
||||
// states
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [selectedOption, setSelectedOption] = useState<string | undefined>(undefined);
|
||||
// ref
|
||||
const dropdownContainerRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useDynamicDropdownPosition(isDropdownOpen, () => setIsDropdownOpen(false), buttonRef, dropdownRef);
|
||||
useOutsideClickDetector(dropdownContainerRef, () => setIsDropdownOpen(false));
|
||||
|
||||
// derived values
|
||||
const selectedValue = selectedOption
|
||||
? selectedOption === "none"
|
||||
? {
|
||||
id: undefined,
|
||||
key: undefined,
|
||||
value: "None",
|
||||
}
|
||||
: options.find((option) => option?.id === selectedOption)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div ref={dropdownContainerRef} className="w-full relative">
|
||||
<Listbox
|
||||
as="div"
|
||||
value={selectedOption}
|
||||
onChange={(selectedOption) => {
|
||||
setSelectedOption(selectedOption);
|
||||
callback(selectedOption);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="w-full flex-shrink-0 text-left"
|
||||
>
|
||||
<Listbox.Button
|
||||
type="button"
|
||||
ref={buttonRef}
|
||||
onClick={() => setIsDropdownOpen((prev) => !prev)}
|
||||
className={cn(
|
||||
"relative w-full rounded border flex items-center gap-3 p-2.5",
|
||||
error ? `border-red-500` : `border-custom-border-200`
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(`w-full text-sm text-left`, !selectedValue ? "text-custom-text-300" : "text-custom-text-100")}
|
||||
>
|
||||
{selectedValue?.value || "Select an estimate point"}
|
||||
</div>
|
||||
<ChevronDown className={`size-3 ${true ? "stroke-onboarding-text-400" : "stroke-onboarding-text-100"}`} />
|
||||
{error && (
|
||||
<>
|
||||
<Tooltip tooltipContent={error} position="bottom">
|
||||
<div className="flex-shrink-0 w-3.5 h-3.5 overflow-hidden relative flex justify-center items-center text-red-500">
|
||||
<Info size={14} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
show={isDropdownOpen}
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Listbox.Options
|
||||
ref={dropdownRef}
|
||||
className="fixed z-10 mt-1 h-fit w-48 sm:w-60 overflow-y-auto rounded-md border border-custom-border-200 bg-custom-background-100 shadow-sm focus:outline-none"
|
||||
>
|
||||
<div className="p-1.5">
|
||||
<Listbox.Option
|
||||
value={"none"}
|
||||
className={cn(
|
||||
`cursor-pointer select-none truncate rounded px-1 py-1.5 hover:bg-custom-background-90`,
|
||||
selectedOption === "none" ? "text-custom-text-100" : "text-custom-text-300"
|
||||
)}
|
||||
>
|
||||
<div className="relative flex items-center text-wrap gap-2 px-1 py-0.5">
|
||||
<div className="text-sm font-medium w-full line-clamp-1">None</div>
|
||||
{selectedOption === "none" && <Check size={12} />}
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
{options.map((option) => (
|
||||
<Listbox.Option
|
||||
key={option?.key}
|
||||
value={option?.id}
|
||||
className={cn(
|
||||
`cursor-pointer select-none truncate rounded px-1 py-1.5 hover:bg-custom-background-90`,
|
||||
selectedOption === option?.id ? "text-custom-text-100" : "text-custom-text-300"
|
||||
)}
|
||||
>
|
||||
<div className="relative flex items-center text-wrap gap-2 px-1 py-0.5">
|
||||
<div className="text-sm font-medium w-full line-clamp-1">{option.value}</div>
|
||||
{selectedOption === option?.id && <Check size={12} />}
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</div>
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
};
|
179
web/components/estimates/points/update.tsx
Normal file
179
web/components/estimates/points/update.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import { FC, FormEvent, useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Check, Info, X } from "lucide-react";
|
||||
import { TEstimatePointsObject, TEstimateSystemKeys } from "@plane/types";
|
||||
import { Spinner, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
||||
// constants
|
||||
import { EEstimateSystem } from "@/constants/estimates";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { isEstimatePointValuesRepeated } from "@/helpers/estimates";
|
||||
// hooks
|
||||
import { useEstimatePoint } from "@/hooks/store";
|
||||
|
||||
type TEstimatePointUpdate = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
estimateId: string | undefined;
|
||||
estimatePointId: string | undefined;
|
||||
estimateType: TEstimateSystemKeys;
|
||||
estimatePoints: TEstimatePointsObject[];
|
||||
estimatePoint: TEstimatePointsObject;
|
||||
handleEstimatePointValueUpdate: (estimateValue: string) => void;
|
||||
closeCallBack: () => void;
|
||||
};
|
||||
|
||||
export const EstimatePointUpdate: FC<TEstimatePointUpdate> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
estimateId,
|
||||
estimatePointId,
|
||||
estimateType,
|
||||
estimatePoints,
|
||||
estimatePoint,
|
||||
handleEstimatePointValueUpdate,
|
||||
closeCallBack,
|
||||
} = props;
|
||||
// hooks
|
||||
const { updateEstimatePoint } = useEstimatePoint(estimateId, estimatePointId);
|
||||
// states
|
||||
const [loader, setLoader] = useState(false);
|
||||
const [estimateInputValue, setEstimateInputValue] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (estimateInputValue === undefined && estimatePoint) setEstimateInputValue(estimatePoint?.value || "");
|
||||
}, [estimateInputValue, estimatePoint]);
|
||||
|
||||
const handleSuccess = (value: string) => {
|
||||
handleEstimatePointValueUpdate(value);
|
||||
setEstimateInputValue("");
|
||||
closeCallBack();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setEstimateInputValue("");
|
||||
closeCallBack();
|
||||
};
|
||||
|
||||
const handleUpdate = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setError(undefined);
|
||||
|
||||
if (estimateInputValue) {
|
||||
const currentEstimateType: EEstimateSystem | undefined = estimateType;
|
||||
let isEstimateValid = false;
|
||||
|
||||
const currentEstimatePointValues = estimatePoints
|
||||
.map((point) => (point?.id != estimatePoint?.id ? point?.value : undefined))
|
||||
.filter((value) => value != undefined) as string[];
|
||||
const isRepeated =
|
||||
(estimateType && isEstimatePointValuesRepeated(currentEstimatePointValues, estimateType, estimateInputValue)) ||
|
||||
false;
|
||||
|
||||
if (!isRepeated) {
|
||||
if (currentEstimateType && [(EEstimateSystem.TIME, EEstimateSystem.POINTS)].includes(currentEstimateType)) {
|
||||
if (estimateInputValue && Number(estimateInputValue) && Number(estimateInputValue) >= 0) {
|
||||
isEstimateValid = true;
|
||||
}
|
||||
} else if (currentEstimateType && currentEstimateType === EEstimateSystem.CATEGORIES) {
|
||||
if (estimateInputValue && estimateInputValue.length > 0) {
|
||||
isEstimateValid = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEstimateValid) {
|
||||
if (estimateId != undefined) {
|
||||
if (estimateInputValue === estimatePoint.value) {
|
||||
setLoader(false);
|
||||
setError(undefined);
|
||||
handleClose();
|
||||
} else
|
||||
try {
|
||||
setLoader(true);
|
||||
|
||||
const payload = {
|
||||
value: estimateInputValue,
|
||||
};
|
||||
await updateEstimatePoint(workspaceSlug, projectId, payload);
|
||||
|
||||
setLoader(false);
|
||||
setError(undefined);
|
||||
handleClose();
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Estimate modified",
|
||||
message: "The estimate point has been updated in your project.",
|
||||
});
|
||||
} catch {
|
||||
setLoader(false);
|
||||
setError("We are unable to process your request, please try again.");
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Estimate modification failed",
|
||||
message: "We were unable to modify the estimate, please try again",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
handleSuccess(estimateInputValue);
|
||||
}
|
||||
} else {
|
||||
setLoader(false);
|
||||
setError(
|
||||
[EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(estimateType)
|
||||
? "Estimate point needs to be a numeric value."
|
||||
: "Estimate point needs to be a character value."
|
||||
);
|
||||
}
|
||||
} else setError("Estimate value already exists.");
|
||||
} else setError("Estimate value cannot be empty.");
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleUpdate} className="relative flex items-center gap-2 text-base">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full border rounded flex items-center",
|
||||
error ? `border-red-500` : `border-custom-border-200`
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={estimateInputValue}
|
||||
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"
|
||||
placeholder="Enter estimate point"
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<>
|
||||
<Tooltip tooltipContent={error} position="bottom">
|
||||
<div className="flex-shrink-0 w-3.5 h-3.5 overflow-hidden mr-3 relative flex justify-center items-center text-red-500">
|
||||
<Info size={14} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
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"
|
||||
disabled={loader}
|
||||
>
|
||||
{loader ? <Spinner className="w-4 h-4" /> : <Check size={14} />}
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
onClick={handleClose}
|
||||
disabled={loader}
|
||||
>
|
||||
<X size={14} className="text-custom-text-200" />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
});
|
85
web/components/estimates/radio-select.tsx
Normal file
85
web/components/estimates/radio-select.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import React from "react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
type RadioInputProps = {
|
||||
name?: string;
|
||||
label: string | React.ReactNode | undefined;
|
||||
wrapperClassName?: string;
|
||||
fieldClassName?: string;
|
||||
buttonClassName?: string;
|
||||
labelClassName?: string;
|
||||
ariaLabel?: string;
|
||||
options: { label: string | React.ReactNode; value: string; disabled?: boolean }[];
|
||||
vertical?: boolean;
|
||||
selected: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const RadioInput = ({
|
||||
name = "radio-input",
|
||||
label: inputLabel,
|
||||
labelClassName: inputLabelClassName = "",
|
||||
wrapperClassName: inputWrapperClassName = "",
|
||||
fieldClassName: inputFieldClassName = "",
|
||||
buttonClassName: inputButtonClassName = "",
|
||||
options,
|
||||
vertical,
|
||||
selected,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
className,
|
||||
}: RadioInputProps) => {
|
||||
const wrapperClass = vertical ? "flex flex-col gap-1" : "flex gap-2";
|
||||
|
||||
const setSelected = (value: string) => {
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
let aria = ariaLabel ? ariaLabel.toLowerCase().replace(" ", "-") : "";
|
||||
if (!aria && typeof inputLabel === "string") {
|
||||
aria = inputLabel.toLowerCase().replace(" ", "-");
|
||||
} else {
|
||||
aria = "radio-input";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={cn(`mb-2`, inputLabelClassName)}>{inputLabel}</div>
|
||||
<div className={cn(`${wrapperClass}`, inputWrapperClassName)}>
|
||||
{options.map(({ value, label, disabled }, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => !disabled && setSelected(value)}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
disabled ? `bg-custom-background-200 border-custom-border-200 cursor-not-allowed` : ``,
|
||||
inputFieldClassName
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id={`${name}_${index}`}
|
||||
name={name}
|
||||
className={cn(
|
||||
`group flex size-5 items-center justify-center rounded-full border border-custom-border-400 bg-custom-background-500 cursor-pointer`,
|
||||
selected === value ? `bg-custom-primary-200 border-custom-primary-100 ` : ``,
|
||||
disabled ? `bg-custom-background-200 border-custom-border-200 cursor-not-allowed` : ``,
|
||||
inputButtonClassName
|
||||
)}
|
||||
type="radio"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
checked={selected === value}
|
||||
/>
|
||||
<label htmlFor={`${name}_${index}`} className="text-base cursor-pointer">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RadioInput;
|
118
web/components/estimates/root.tsx
Normal file
118
web/components/estimates/root.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import {
|
||||
EstimateLoaderScreen,
|
||||
EstimateEmptyScreen,
|
||||
EstimateDisableSwitch,
|
||||
CreateEstimateModal,
|
||||
UpdateEstimateModal,
|
||||
DeleteEstimateModal,
|
||||
EstimateList,
|
||||
} from "@/components/estimates";
|
||||
// hooks
|
||||
import { useProject, useProjectEstimates } from "@/hooks/store";
|
||||
|
||||
type TEstimateRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
export const EstimateRoot: FC<TEstimateRoot> = observer((props) => {
|
||||
const { workspaceSlug, projectId, isAdmin } = props;
|
||||
// hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { loader, currentActiveEstimateId, archivedEstimateIds, getProjectEstimates } = useProjectEstimates();
|
||||
// states
|
||||
const [isEstimateCreateModalOpen, setIsEstimateCreateModalOpen] = useState(false);
|
||||
const [estimateToUpdate, setEstimateToUpdate] = useState<string | undefined>();
|
||||
const [estimateToDelete, setEstimateToDelete] = useState<string | undefined>();
|
||||
|
||||
const { isLoading: isSWRLoading } = useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_ESTIMATES_${workspaceSlug}_${projectId}` : null,
|
||||
async () => workspaceSlug && projectId && getProjectEstimates(workspaceSlug, projectId)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
{loader === "init-loader" || isSWRLoading ? (
|
||||
<EstimateLoaderScreen />
|
||||
) : (
|
||||
<div className="space-y-12">
|
||||
{/* header */}
|
||||
<div className="text-xl font-medium text-custom-text-100 border-b border-custom-border-200 py-3.5">
|
||||
Estimates
|
||||
</div>
|
||||
|
||||
{/* current active estimate section */}
|
||||
{currentActiveEstimateId ? (
|
||||
<div className="">
|
||||
{/* estimates activated deactivated section */}
|
||||
<div className="relative border-b border-custom-border-200 pb-4 flex justify-between items-center gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-medium text-custom-text-100">Enable estimates for my project</h3>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
They help you in communicating complexity and workload of the team.
|
||||
</p>
|
||||
</div>
|
||||
<EstimateDisableSwitch workspaceSlug={workspaceSlug} projectId={projectId} isAdmin={isAdmin} />
|
||||
</div>
|
||||
{/* active estimates section */}
|
||||
<EstimateList
|
||||
estimateIds={[currentActiveEstimateId]}
|
||||
isAdmin={isAdmin}
|
||||
isEstimateEnabled={Boolean(currentProjectDetails?.estimate)}
|
||||
isEditable
|
||||
onEditClick={(estimateId: string) => setEstimateToUpdate(estimateId)}
|
||||
onDeleteClick={(estimateId: string) => setEstimateToDelete(estimateId)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EstimateEmptyScreen onButtonClick={() => setIsEstimateCreateModalOpen(true)} />
|
||||
)}
|
||||
|
||||
{/* archived estimates section */}
|
||||
{archivedEstimateIds && archivedEstimateIds.length > 0 && (
|
||||
<div className="">
|
||||
<div className="border-b border-custom-border-200 space-y-1 pb-4">
|
||||
<h3 className="text-lg font-medium text-custom-text-100">Archived estimates</h3>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Estimates have gone through a change, these are the estimates you had in your older versions which
|
||||
were not in use. Read more about them
|
||||
<a href={"#"} target="_blank" className="text-custom-primary-100/80 hover:text-custom-primary-100">
|
||||
here.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<EstimateList estimateIds={archivedEstimateIds} isAdmin={isAdmin} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CRUD modals */}
|
||||
<CreateEstimateModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
isOpen={isEstimateCreateModalOpen}
|
||||
handleClose={() => setIsEstimateCreateModalOpen(false)}
|
||||
/>
|
||||
<UpdateEstimateModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
estimateId={estimateToUpdate ? estimateToUpdate : undefined}
|
||||
isOpen={estimateToUpdate ? true : false}
|
||||
handleClose={() => setEstimateToUpdate(undefined)}
|
||||
/>
|
||||
<DeleteEstimateModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
estimateId={estimateToDelete ? estimateToDelete : undefined}
|
||||
isOpen={estimateToDelete ? true : false}
|
||||
handleClose={() => setEstimateToDelete(undefined)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
2
web/components/estimates/update/index.ts
Normal file
2
web/components/estimates/update/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./modal";
|
||||
export * from "./stage-one";
|
42
web/components/estimates/update/modal.tsx
Normal file
42
web/components/estimates/update/modal.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Button } from "@plane/ui";
|
||||
// components
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@/components/core";
|
||||
import { EstimateUpdateStageOne } from "@/components/estimates";
|
||||
|
||||
type TUpdateEstimateModal = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
estimateId: string | undefined;
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const UpdateEstimateModal: FC<TUpdateEstimateModal> = observer((props) => {
|
||||
// props
|
||||
const { isOpen, handleClose } = props;
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<div className="relative space-y-6 py-5">
|
||||
{/* heading */}
|
||||
<div className="relative flex justify-between items-center gap-2 px-5">
|
||||
<div className="relative flex items-center gap-1">
|
||||
<div className="text-xl font-medium text-custom-text-200">Edit estimate system</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5">
|
||||
<EstimateUpdateStageOne />
|
||||
</div>
|
||||
|
||||
<div className="relative flex justify-end items-center gap-3 px-5 pt-5 border-t border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
42
web/components/estimates/update/stage-one.tsx
Normal file
42
web/components/estimates/update/stage-one.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { FC } from "react";
|
||||
import { Crown } from "lucide-react";
|
||||
import { TEstimateUpdateStageKeys } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// constants
|
||||
import { ESTIMATE_OPTIONS_STAGE_ONE } from "@/constants/estimates";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
type TEstimateUpdateStageOne = {
|
||||
handleEstimateEditType?: (stage: TEstimateUpdateStageKeys) => void;
|
||||
};
|
||||
|
||||
export const EstimateUpdateStageOne: FC<TEstimateUpdateStageOne> = (props) => {
|
||||
const { handleEstimateEditType } = props;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{ESTIMATE_OPTIONS_STAGE_ONE &&
|
||||
ESTIMATE_OPTIONS_STAGE_ONE.map((stage) => (
|
||||
<div
|
||||
key={stage.key}
|
||||
className={cn(
|
||||
"border border-custom-border-300 cursor-pointer space-y-1 p-3 rounded transition-colors",
|
||||
stage?.is_ee ? `bg-custom-background-90` : `hover:bg-custom-background-90`
|
||||
)}
|
||||
onClick={() => !stage?.is_ee && handleEstimateEditType && handleEstimateEditType(stage.key)}
|
||||
>
|
||||
<h3 className="text-base font-medium relative flex items-center gap-2">
|
||||
{stage.title}
|
||||
{stage?.is_ee && (
|
||||
<Tooltip tooltipContent={"upgrade"}>
|
||||
<Crown size={12} className="text-amber-400" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-custom-text-200">{stage.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -17,7 +17,7 @@ import { IssueLabelSelect } from "@/components/issues/select";
|
||||
// helpers
|
||||
import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useEstimate } from "@/hooks/store";
|
||||
import { useProjectEstimates } from "@/hooks/store";
|
||||
|
||||
type TInboxIssueProperties = {
|
||||
projectId: string;
|
||||
@ -29,7 +29,7 @@ type TInboxIssueProperties = {
|
||||
export const InboxIssueProperties: FC<TInboxIssueProperties> = observer((props) => {
|
||||
const { projectId, data, handleData, isVisible = false } = props;
|
||||
// hooks
|
||||
const { areEstimatesEnabledForProject } = useEstimate();
|
||||
const { areEstimateEnabledByProjectId } = useProjectEstimates();
|
||||
// states
|
||||
const [parentIssueModalOpen, setParentIssueModalOpen] = useState(false);
|
||||
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | undefined>(undefined);
|
||||
@ -142,10 +142,10 @@ export const InboxIssueProperties: FC<TInboxIssueProperties> = observer((props)
|
||||
)}
|
||||
|
||||
{/* estimate */}
|
||||
{isVisible && areEstimatesEnabledForProject(projectId) && (
|
||||
{isVisible && projectId && areEstimateEnabledByProjectId(projectId) && (
|
||||
<div className="h-7">
|
||||
<EstimateDropdown
|
||||
value={data?.estimate_point || null}
|
||||
value={data?.estimate_point || undefined}
|
||||
onChange={(estimatePoint) => handleData("estimate_point", estimatePoint)}
|
||||
projectId={projectId}
|
||||
buttonVariant="border-with-text"
|
||||
|
@ -54,7 +54,7 @@ import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"
|
||||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// types
|
||||
import { useEstimate, useIssueDetail, useProject, useProjectState, useUser } from "@/hooks/store";
|
||||
import { useProjectEstimates, useIssueDetail, useProject, useProjectState, useUser } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// components
|
||||
import type { TIssueOperations } from "./root";
|
||||
@ -82,7 +82,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { data: currentUser } = useUser();
|
||||
const { areEstimatesEnabledForCurrentProject } = useEstimate();
|
||||
const { areEstimateEnabledByProjectId } = useProjectEstimates();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
@ -311,15 +311,17 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{areEstimatesEnabledForCurrentProject && (
|
||||
{projectId && areEstimateEnabledByProjectId(projectId) && (
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||
<Triangle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Estimate</span>
|
||||
</div>
|
||||
<EstimateDropdown
|
||||
value={issue?.estimate_point != null ? issue.estimate_point : null}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })}
|
||||
value={issue?.estimate_point ?? undefined}
|
||||
onChange={(val: string | undefined) =>
|
||||
issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })
|
||||
}
|
||||
projectId={projectId}
|
||||
disabled={!isEditable}
|
||||
buttonVariant="transparent-with-text"
|
||||
|
@ -26,7 +26,7 @@ import { cn } from "@/helpers/common.helper";
|
||||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useEstimate, useLabel, useIssues, useProjectState, useProject } from "@/hooks/store";
|
||||
import { useEventTracker, useLabel, useIssues, useProjectState, useProject, useProjectEstimates } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local components
|
||||
import { IssuePropertyLabels } from "../properties/labels";
|
||||
@ -42,6 +42,9 @@ export interface IIssueProperties {
|
||||
}
|
||||
|
||||
export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const { issue, updateIssue, displayProperties, activeLayout, isReadOnly, className } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
@ -53,13 +56,10 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
const {
|
||||
issues: { addCycleToIssue, removeCycleFromIssue },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const { areEstimatesEnabledForCurrentProject } = useEstimate();
|
||||
const { areEstimateEnabledByProjectId } = useProjectEstimates();
|
||||
const { getStateById } = useProjectState();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
const currentLayout = `${activeLayout} layout`;
|
||||
// derived values
|
||||
const stateDetails = getStateById(issue.state_id);
|
||||
@ -220,7 +220,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const handleEstimate = (value: string | null) => {
|
||||
const handleEstimate = (value: string | undefined) => {
|
||||
updateIssue &&
|
||||
updateIssue(issue.project_id, issue.id, { estimate_point: value }).then(() => {
|
||||
captureIssueEvent({
|
||||
@ -394,11 +394,11 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
)}
|
||||
|
||||
{/* estimates */}
|
||||
{areEstimatesEnabledForCurrentProject && (
|
||||
{projectId && areEstimateEnabledByProjectId(projectId?.toString()) && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="estimate">
|
||||
<div className="h-5" onClick={handleEventPropagation}>
|
||||
<EstimateDropdown
|
||||
value={issue.estimate_point}
|
||||
value={issue.estimate_point ?? undefined}
|
||||
onChange={handleEstimate}
|
||||
projectId={issue.project_id}
|
||||
disabled={isReadOnly}
|
||||
|
@ -17,7 +17,7 @@ export const SpreadsheetEstimateColumn: React.FC<Props> = observer((props: Props
|
||||
return (
|
||||
<div className="h-11 border-b-[0.5px] border-custom-border-200">
|
||||
<EstimateDropdown
|
||||
value={issue.estimate_point}
|
||||
value={issue.estimate_point || undefined}
|
||||
onChange={(data) =>
|
||||
onChange(issue, { estimate_point: data }, { changed_property: "estimate_point", change_details: data })
|
||||
}
|
||||
|
@ -28,7 +28,14 @@ import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper"
|
||||
import { getChangedIssuefields, getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||
import { shouldRenderProject } from "@/helpers/project.helper";
|
||||
// hooks
|
||||
import { useAppRouter, useEstimate, useInstance, useIssueDetail, useProject, useWorkspace } from "@/hooks/store";
|
||||
import {
|
||||
useAppRouter,
|
||||
useProjectEstimates,
|
||||
useInstance,
|
||||
useIssueDetail,
|
||||
useProject,
|
||||
useWorkspace,
|
||||
} from "@/hooks/store";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties";
|
||||
// services
|
||||
@ -120,7 +127,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
const { projectId: routeProjectId } = useAppRouter();
|
||||
const { config } = useInstance();
|
||||
const { getProjectById } = useProject();
|
||||
const { areEstimatesEnabledForProject } = useEstimate();
|
||||
const { areEstimateEnabledByProjectId } = useProjectEstimates();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (editorRef.current?.isEditorReadyToDiscard()) {
|
||||
@ -659,14 +666,14 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{areEstimatesEnabledForProject(projectId) && (
|
||||
{projectId && areEstimateEnabledByProjectId(projectId) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<EstimateDropdown
|
||||
value={value}
|
||||
value={value || undefined}
|
||||
onChange={(estimatePoint) => {
|
||||
onChange(estimatePoint);
|
||||
handleFormChange();
|
||||
|
@ -197,14 +197,14 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
<span>Estimate</span>
|
||||
</div>
|
||||
<EstimateDropdown
|
||||
value={issue?.estimate_point !== null ? issue.estimate_point : null}
|
||||
value={issue.estimate_point ?? undefined}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })}
|
||||
projectId={projectId}
|
||||
disabled={disabled}
|
||||
buttonVariant="transparent-with-text"
|
||||
className="w-3/4 flex-grow group"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={`text-sm ${issue?.estimate_point !== null ? "" : "text-custom-text-400"}`}
|
||||
buttonClassName={`text-sm ${issue?.estimate_point !== undefined ? "" : "text-custom-text-400"}`}
|
||||
placeholder="None"
|
||||
hideIcon
|
||||
dropdownArrow
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { Fragment, Ref, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@ -269,8 +269,8 @@ export const WorkspaceSidebarDropdown = observer(() => {
|
||||
>
|
||||
<Menu.Items
|
||||
className="absolute left-0 z-20 mt-1 flex w-52 origin-top-left flex-col divide-y
|
||||
divide-custom-sidebar-border-200 rounded-md border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 text-xs shadow-lg outline-none"
|
||||
ref={setPopperElement}
|
||||
divide-custom-sidebar-border-200 rounded-md border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 text-xs shadow-lg outline-none"
|
||||
ref={setPopperElement as Ref<HTMLDivElement>}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
|
@ -28,7 +28,7 @@ export const ANALYTICS_X_AXIS_VALUES: { value: TXAxisValues; label: string }[] =
|
||||
label: "Assignee",
|
||||
},
|
||||
{
|
||||
value: "estimate_point",
|
||||
value: "estimate_point__value",
|
||||
label: "Estimate point",
|
||||
},
|
||||
{
|
||||
|
144
web/constants/estimates.ts
Normal file
144
web/constants/estimates.ts
Normal file
@ -0,0 +1,144 @@
|
||||
// types
|
||||
import { TEstimateSystems } from "@plane/types";
|
||||
|
||||
export enum EEstimateSystem {
|
||||
POINTS = "points",
|
||||
CATEGORIES = "categories",
|
||||
TIME = "time",
|
||||
}
|
||||
|
||||
export enum EEstimateUpdateStages {
|
||||
CREATE = "create",
|
||||
EDIT = "edit",
|
||||
SWITCH = "switch",
|
||||
}
|
||||
|
||||
export const maxEstimatesCount = 11;
|
||||
|
||||
export const ESTIMATE_SYSTEMS: TEstimateSystems = {
|
||||
points: {
|
||||
name: "Points",
|
||||
templates: {
|
||||
fibonacci: {
|
||||
title: "Fibonacci",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "1" },
|
||||
{ id: undefined, key: 2, value: "2" },
|
||||
{ id: undefined, key: 3, value: "3" },
|
||||
{ id: undefined, key: 4, value: "5" },
|
||||
{ id: undefined, key: 5, value: "8" },
|
||||
{ id: undefined, key: 6, value: "13" },
|
||||
{ id: undefined, key: 7, value: "21" },
|
||||
],
|
||||
},
|
||||
linear: {
|
||||
title: "Linear",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "1" },
|
||||
{ id: undefined, key: 2, value: "2" },
|
||||
{ id: undefined, key: 3, value: "3" },
|
||||
{ id: undefined, key: 4, value: "4" },
|
||||
{ id: undefined, key: 5, value: "5" },
|
||||
{ id: undefined, key: 6, value: "6" },
|
||||
{ id: undefined, key: 7, value: "7" },
|
||||
{ id: undefined, key: 8, value: "8" },
|
||||
{ id: undefined, key: 9, value: "9" },
|
||||
{ id: undefined, key: 10, value: "10" },
|
||||
],
|
||||
},
|
||||
squares: {
|
||||
title: "Squares",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "1" },
|
||||
{ id: undefined, key: 2, value: "4" },
|
||||
{ id: undefined, key: 3, value: "9" },
|
||||
{ id: undefined, key: 4, value: "16" },
|
||||
{ id: undefined, key: 5, value: "25" },
|
||||
{ id: undefined, key: 6, value: "36" },
|
||||
],
|
||||
},
|
||||
custom: {
|
||||
title: "Custom",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "1" },
|
||||
{ id: undefined, key: 2, value: "2" },
|
||||
],
|
||||
hide: true,
|
||||
},
|
||||
},
|
||||
is_available: true,
|
||||
is_ee: false,
|
||||
},
|
||||
categories: {
|
||||
name: "Categories",
|
||||
templates: {
|
||||
t_shirt_sizes: {
|
||||
title: "T-Shirt Sizes",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "XS" },
|
||||
{ id: undefined, key: 2, value: "S" },
|
||||
{ id: undefined, key: 3, value: "M" },
|
||||
{ id: undefined, key: 4, value: "L" },
|
||||
{ id: undefined, key: 5, value: "XL" },
|
||||
{ id: undefined, key: 6, value: "XXL" },
|
||||
],
|
||||
},
|
||||
easy_to_hard: {
|
||||
title: "Easy to hard",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "Easy" },
|
||||
{ id: undefined, key: 2, value: "Medium" },
|
||||
{ id: undefined, key: 3, value: "Hard" },
|
||||
{ id: undefined, key: 4, value: "Very Hard" },
|
||||
],
|
||||
},
|
||||
custom: {
|
||||
title: "Custom",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "Easy" },
|
||||
{ id: undefined, key: 2, value: "Hard" },
|
||||
],
|
||||
hide: true,
|
||||
},
|
||||
},
|
||||
is_available: true,
|
||||
is_ee: false,
|
||||
},
|
||||
time: {
|
||||
name: "Time",
|
||||
templates: {
|
||||
hours: {
|
||||
title: "Hours",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "1" },
|
||||
{ id: undefined, key: 2, value: "2" },
|
||||
{ id: undefined, key: 3, value: "3" },
|
||||
{ id: undefined, key: 4, value: "4" },
|
||||
{ id: undefined, key: 5, value: "5" },
|
||||
{ id: undefined, key: 6, value: "6" },
|
||||
{ id: undefined, key: 7, value: "7" },
|
||||
{ id: undefined, key: 8, value: "8" },
|
||||
{ id: undefined, key: 9, value: "9" },
|
||||
{ id: undefined, key: 10, value: "10" },
|
||||
],
|
||||
},
|
||||
},
|
||||
is_available: false,
|
||||
is_ee: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ESTIMATE_OPTIONS_STAGE_ONE = [
|
||||
{
|
||||
key: EEstimateUpdateStages.EDIT,
|
||||
title: "Add, update or remove estimates",
|
||||
description: "Manage current system either adding, updating or removing the points or categories.",
|
||||
is_ee: true,
|
||||
},
|
||||
{
|
||||
key: EEstimateUpdateStages.SWITCH,
|
||||
title: "Change estimate type",
|
||||
description: "Convert your points system to categories system and vice versa.",
|
||||
is_ee: true,
|
||||
},
|
||||
];
|
33
web/helpers/estimates.ts
Normal file
33
web/helpers/estimates.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { EEstimateSystem } from "@/constants/estimates";
|
||||
|
||||
export const isEstimatePointValuesRepeated = (
|
||||
estimatePoints: string[],
|
||||
estimateType: EEstimateSystem,
|
||||
newEstimatePoint?: string | undefined
|
||||
) => {
|
||||
const currentEstimatePoints = estimatePoints.map((estimatePoint) => estimatePoint.trim());
|
||||
let isRepeated = false;
|
||||
|
||||
if (newEstimatePoint === undefined) {
|
||||
if (estimateType === EEstimateSystem.CATEGORIES) {
|
||||
const points = new Set(currentEstimatePoints);
|
||||
if (points.size != currentEstimatePoints.length) isRepeated = true;
|
||||
} else if ([EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(estimateType)) {
|
||||
currentEstimatePoints.map((point) => {
|
||||
if (Number(point) === Number(newEstimatePoint)) isRepeated = true;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (estimateType === EEstimateSystem.CATEGORIES) {
|
||||
currentEstimatePoints.map((point) => {
|
||||
if (point === newEstimatePoint.trim()) isRepeated = true;
|
||||
});
|
||||
} else if ([EEstimateSystem.POINTS, EEstimateSystem.TIME].includes(estimateType)) {
|
||||
currentEstimatePoints.map((point) => {
|
||||
if (Number(point) === Number(newEstimatePoint.trim())) isRepeated = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return isRepeated;
|
||||
};
|
3
web/hooks/store/estimates/index.ts
Normal file
3
web/hooks/store/estimates/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./use-project-estimate";
|
||||
export * from "./use-estimate";
|
||||
export * from "./use-estimate-point";
|
16
web/hooks/store/estimates/use-estimate-point.ts
Normal file
16
web/hooks/store/estimates/use-estimate-point.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { useContext } from "react";
|
||||
// mobx store
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
// mobx store
|
||||
import { IEstimatePoint } from "@/store/estimates/estimate-point";
|
||||
|
||||
export const useEstimatePoint = (
|
||||
estimateId: string | undefined,
|
||||
estimatePointId: string | undefined
|
||||
): IEstimatePoint => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useEstimatePoint must be used within StoreProvider");
|
||||
if (!estimateId || !estimatePointId) return {} as IEstimatePoint;
|
||||
|
||||
return context.projectEstimate.estimates?.[estimateId]?.estimatePoints?.[estimatePointId] || {};
|
||||
};
|
13
web/hooks/store/estimates/use-estimate.ts
Normal file
13
web/hooks/store/estimates/use-estimate.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { useContext } from "react";
|
||||
// mobx store
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
// mobx store
|
||||
import { IEstimate } from "@/store/estimates/estimate";
|
||||
|
||||
export const useEstimate = (estimateId: string | undefined): IEstimate => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useEstimate must be used within StoreProvider");
|
||||
if (!estimateId) return {} as IEstimate;
|
||||
|
||||
return context.projectEstimate.estimates?.[estimateId] ?? {};
|
||||
};
|
12
web/hooks/store/estimates/use-project-estimate.ts
Normal file
12
web/hooks/store/estimates/use-project-estimate.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { useContext } from "react";
|
||||
// context
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
// mobx store
|
||||
import { IProjectEstimateStore } from "@/store/estimates/project-estimate.store";
|
||||
|
||||
export const useProjectEstimates = (): IProjectEstimateStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useProjectPage must be used within StoreProvider");
|
||||
|
||||
return context.projectEstimate;
|
||||
};
|
@ -3,7 +3,6 @@ export * from "./use-cycle-filter";
|
||||
export * from "./use-cycle";
|
||||
export * from "./use-event-tracker";
|
||||
export * from "./use-dashboard";
|
||||
export * from "./use-estimate";
|
||||
export * from "./use-global-view";
|
||||
export * from "./use-label";
|
||||
export * from "./use-member";
|
||||
@ -33,3 +32,4 @@ export * from "./use-instance";
|
||||
export * from "./use-app-theme";
|
||||
export * from "./use-command-palette";
|
||||
export * from "./use-app-router";
|
||||
export * from "./estimates";
|
||||
|
@ -1,11 +0,0 @@
|
||||
import { useContext } from "react";
|
||||
// mobx store
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
// types
|
||||
import { IEstimateStore } from "@/store/estimate.store";
|
||||
|
||||
export const useEstimate = (): IEstimateStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useEstimate must be used within StoreProvider");
|
||||
return context.estimate;
|
||||
};
|
@ -1,4 +1,4 @@
|
||||
import { useCycle, useEstimate, useLabel, useMember, useModule, useProjectState } from "./store";
|
||||
import { useCycle, useProjectEstimates, useLabel, useMember, useModule, useProjectState } from "./store";
|
||||
|
||||
export const useProjectIssueProperties = () => {
|
||||
const { fetchProjectStates } = useProjectState();
|
||||
@ -8,7 +8,7 @@ export const useProjectIssueProperties = () => {
|
||||
const { fetchProjectLabels } = useLabel();
|
||||
const { fetchAllCycles: fetchProjectAllCycles } = useCycle();
|
||||
const { fetchModules: fetchProjectAllModules } = useModule();
|
||||
const { fetchProjectEstimates } = useEstimate();
|
||||
const { getProjectEstimates } = useProjectEstimates();
|
||||
|
||||
// fetching project states
|
||||
const fetchStates = async (
|
||||
@ -62,7 +62,7 @@ export const useProjectIssueProperties = () => {
|
||||
projectId: string | string[] | undefined
|
||||
) => {
|
||||
if (workspaceSlug && projectId) {
|
||||
await fetchProjectEstimates(workspaceSlug.toString(), projectId.toString());
|
||||
await getProjectEstimates(workspaceSlug.toString(), projectId.toString());
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -1,12 +1,12 @@
|
||||
import useSWR from "swr";
|
||||
import { useCycle, useEstimate, useLabel, useModule, useProjectState } from "./store";
|
||||
import { useCycle, useProjectEstimates, useLabel, useModule, useProjectState } from "./store";
|
||||
|
||||
export const useWorkspaceIssueProperties = (workspaceSlug: string | string[] | undefined) => {
|
||||
const { fetchWorkspaceLabels } = useLabel();
|
||||
|
||||
const { fetchWorkspaceStates } = useProjectState();
|
||||
|
||||
const { fetchWorkspaceEstimates } = useEstimate();
|
||||
const { getWorkspaceEstimates } = useProjectEstimates();
|
||||
|
||||
const { fetchWorkspaceModules } = useModule();
|
||||
|
||||
@ -43,7 +43,7 @@ export const useWorkspaceIssueProperties = (workspaceSlug: string | string[] | u
|
||||
// fetch workspace estimates
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_ESTIMATES_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => fetchWorkspaceEstimates(workspaceSlug.toString()) : null,
|
||||
workspaceSlug ? () => getWorkspaceEstimates(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
};
|
||||
|
@ -9,7 +9,7 @@ import { EmptyState, LogoSpinner } from "@/components/common";
|
||||
import {
|
||||
useEventTracker,
|
||||
useCycle,
|
||||
useEstimate,
|
||||
useProjectEstimates,
|
||||
useLabel,
|
||||
useMember,
|
||||
useModule,
|
||||
@ -44,7 +44,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
|
||||
} = useMember();
|
||||
const { fetchProjectStates } = useProjectState();
|
||||
const { fetchProjectLabels } = useLabel();
|
||||
const { fetchProjectEstimates } = useEstimate();
|
||||
const { getProjectEstimates } = useProjectEstimates();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -80,7 +80,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
|
||||
// fetching project estimates
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_ESTIMATES_${workspaceSlug}_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchProjectEstimates(workspaceSlug.toString(), projectId.toString()) : null,
|
||||
workspaceSlug && projectId ? () => getProjectEstimates(workspaceSlug.toString(), projectId.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetching project cycles
|
||||
|
@ -35,24 +35,24 @@ const nextConfig = {
|
||||
{
|
||||
source: "/accounts/sign-up",
|
||||
destination: "/sign-up",
|
||||
permanent: true
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/sign-in",
|
||||
destination: "/",
|
||||
permanent: true
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/register",
|
||||
destination: "/sign-up",
|
||||
permanent: true
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/login",
|
||||
destination: "/",
|
||||
permanent: true
|
||||
}
|
||||
]
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://app.posthog.com"
|
||||
@ -67,9 +67,9 @@ const nextConfig = {
|
||||
},
|
||||
];
|
||||
if (process.env.NEXT_PUBLIC_ADMIN_BASE_URL || process.env.NEXT_PUBLIC_ADMIN_BASE_PATH) {
|
||||
const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || ""
|
||||
const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || ""
|
||||
const GOD_MODE_BASE_URL = ADMIN_BASE_URL + ADMIN_BASE_PATH
|
||||
const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
|
||||
const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
|
||||
const GOD_MODE_BASE_URL = ADMIN_BASE_URL + ADMIN_BASE_PATH;
|
||||
rewrites.push({
|
||||
source: "/god-mode",
|
||||
destination: `${GOD_MODE_BASE_URL}/`,
|
||||
@ -77,7 +77,7 @@ const nextConfig = {
|
||||
rewrites.push({
|
||||
source: "/god-mode/:path*",
|
||||
destination: `${GOD_MODE_BASE_URL}/:path*`,
|
||||
})
|
||||
});
|
||||
}
|
||||
return rewrites;
|
||||
},
|
||||
|
@ -69,17 +69,17 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/js-cookie": "^3.0.2",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "18.0.6",
|
||||
"@types/node": "18.16.1",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/react": "^18.2.42",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-color": "^3.0.6",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/zxcvbn": "^4.4.4",
|
||||
"eslint-config-custom": "*",
|
||||
"prettier": "^3.2.5",
|
||||
"tailwind-config-custom": "*",
|
||||
"tsconfig": "*",
|
||||
"typescript": "4.7.4"
|
||||
"typescript": "^5.4.2"
|
||||
}
|
||||
}
|
||||
|
@ -1,33 +1,38 @@
|
||||
import { ReactElement } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useRouter } from "next/router";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { EstimatesList } from "@/components/estimates";
|
||||
import { EstimateRoot } from "@/components/estimates";
|
||||
import { ProjectSettingHeader } from "@/components/headers";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useUser, useProject } from "@/hooks/store";
|
||||
// layouts
|
||||
import { AppLayout } from "@/layouts/app-layout";
|
||||
import { ProjectSettingLayout } from "@/layouts/settings-layout";
|
||||
// components
|
||||
// types
|
||||
import { NextPageWithLayout } from "@/lib/types";
|
||||
// constants
|
||||
|
||||
const EstimatesSettingsPage: NextPageWithLayout = observer(() => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
// derived values
|
||||
const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN;
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Estimates` : undefined;
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className={`w-full overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "pointer-events-none opacity-60"}`}>
|
||||
<EstimatesList />
|
||||
<div className={`w-full overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "pointer-events-none opacity-60"}`}>
|
||||
<EstimateRoot workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} isAdmin={isAdmin} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
19
web/public/empty-state/estimates/dark.svg
Normal file
19
web/public/empty-state/estimates/dark.svg
Normal file
@ -0,0 +1,19 @@
|
||||
<svg width="98" height="121" viewBox="0 0 98 121" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.8707 74.5375C16.3076 72.9545 18.6808 73.4171 20.2598 74.5344C22.2919 75.9722 22.873 78.598 23.1728 80.9255C23.6803 84.8657 23.8304 90.4355 28.2986 92.0828C30.363 92.8439 32.7261 92.2154 34.4697 90.9796C36.3742 89.6297 37.5622 87.5534 38.3579 85.4014C39.3213 82.796 39.879 80.0089 40.3517 77.2785C40.8351 74.4863 41.1519 71.664 41.2941 68.8338C41.4333 66.0649 41.3427 63.3142 41.2327 60.5471C41.1467 58.3866 41.1139 56.1179 41.8437 54.052C42.5808 51.9655 44.2945 50.3785 46.5478 50.1692C48.7349 49.966 50.9875 50.8883 52.57 52.3768C56.3668 55.9484 56.5454 61.7791 57.3941 66.5679C57.8399 69.084 58.4917 71.63 59.8579 73.819C60.9816 75.6193 62.5432 77.1286 64.3419 78.2484C67.9699 80.507 72.5929 81.1661 76.6187 79.6144C80.5491 78.0995 83.713 74.7878 85.082 70.8073C85.4201 69.8243 85.6289 68.8088 85.7348 67.7756C85.7841 67.2952 85.0326 67.2986 84.9838 67.7756C84.5336 72.1666 81.715 76.1433 77.8526 78.2259C73.8783 80.3689 69.0654 80.097 65.1824 77.8754C63.2735 76.7832 61.6129 75.2427 60.4591 73.364C59.0598 71.0857 58.4606 68.4301 58.0247 65.8227C57.2373 61.1135 56.8802 55.7066 53.3508 52.0908C50.6136 49.2866 45.5767 48.1613 42.635 51.2848C39.6059 54.501 40.5139 59.682 40.6106 63.6745C40.7523 69.5217 40.1494 75.3825 38.8131 81.0769C38.2314 83.5555 37.5623 86.1115 36.1024 88.2369C34.7681 90.1793 32.5875 91.6848 30.1594 91.6609C27.7378 91.637 26.0364 89.8572 25.1768 87.7354C24.2698 85.4969 24.2104 83.0793 23.8965 80.7163C23.6084 78.5472 23.0835 76.1467 21.4568 74.5592C20.1918 73.3248 18.3152 72.5301 16.5348 72.814C15.6794 72.9505 14.9208 73.3661 14.3396 74.0064C14.0153 74.3636 14.5449 74.8963 14.8707 74.5375Z" fill="#3F76FF"/>
|
||||
<path d="M55.4847 43.7299H38.3499C37.9179 43.7299 37.5664 43.3784 37.5664 42.9464C37.5664 42.5144 37.9179 42.1631 38.3499 42.1631H55.4847C55.9167 42.1631 56.268 42.5144 56.268 42.9464C56.268 43.3784 55.9167 43.7299 55.4847 43.7299Z" fill="#363A3F"/>
|
||||
<path d="M81.9847 89.2953H64.8499C64.4179 89.2953 64.0664 88.9438 64.0664 88.5118C64.0664 88.0798 64.4179 87.7285 64.8499 87.7285H81.9847C82.4167 87.7285 82.768 88.0798 82.768 88.5118C82.768 88.9438 82.4167 89.2953 81.9847 89.2953Z" fill="#363A3F"/>
|
||||
<path d="M96.0179 120.34H78.8831C78.4511 120.34 78.0996 119.989 78.0996 119.557C78.0996 119.125 78.4511 118.773 78.8831 118.773H96.0179C96.4499 118.773 96.8012 119.125 96.8012 119.557C96.8012 119.989 96.4499 120.34 96.0179 120.34Z" fill="#363A3F"/>
|
||||
<path d="M17.9183 26.7064H0.78347C0.351487 26.7064 0 26.3549 0 25.923C0 25.491 0.351492 25.1396 0.78347 25.1396H17.9183C18.3503 25.1396 18.7016 25.491 18.7016 25.923C18.7016 26.3549 18.3503 26.7064 17.9183 26.7064Z" fill="#363A3F"/>
|
||||
<path d="M87.2283 111.247H9.56712C9.29059 111.247 9.06641 111.023 9.06641 110.746V36.6404C9.06641 36.3636 9.2906 36.1396 9.56712 36.1396C9.84364 36.1396 10.0678 36.3636 10.0678 36.6404V66.8777C10.0678 90.8292 29.4843 110.246 53.4358 110.246H87.2283C87.5048 110.246 87.729 110.47 87.729 110.746C87.729 111.023 87.5048 111.247 87.2283 111.247Z" fill="#363A3F"/>
|
||||
<path d="M25.9613 81.4652H8.82644C8.39446 81.4652 8.04297 81.1137 8.04297 80.6817C8.04297 80.2498 8.39446 79.8984 8.82644 79.8984H25.9613C26.3932 79.8984 26.7446 80.2498 26.7446 80.6817C26.7446 81.1137 26.3932 81.4652 25.9613 81.4652Z" fill="#363A3F"/>
|
||||
<path d="M14.8118 76.609C16.0562 76.609 17.065 75.6002 17.065 74.3558C17.065 73.1113 16.0562 72.1025 14.8118 72.1025C13.5674 72.1025 12.5586 73.1113 12.5586 74.3558C12.5586 75.6002 13.5674 76.609 14.8118 76.609Z" fill="#2E2E2E"/>
|
||||
<path d="M29.8431 94.1344C31.0875 94.1344 32.0963 93.1256 32.0963 91.8812C32.0963 90.6367 31.0875 89.6279 29.8431 89.6279C28.5986 89.6279 27.5898 90.6367 27.5898 91.8812C27.5898 93.1256 28.5986 94.1344 29.8431 94.1344Z" fill="#2E2E2E"/>
|
||||
<path d="M46.8587 52.0738C48.1031 52.0738 49.1119 51.065 49.1119 49.8206C49.1119 48.5762 48.1031 47.5674 46.8587 47.5674C45.6143 47.5674 44.6055 48.5762 44.6055 49.8206C44.6055 51.065 45.6143 52.0738 46.8587 52.0738Z" fill="#2E2E2E"/>
|
||||
<path d="M71.8997 82.1178C73.1441 82.1178 74.1529 81.109 74.1529 79.8646C74.1529 78.6201 73.1441 77.6113 71.8997 77.6113C70.6553 77.6113 69.6465 78.6201 69.6465 79.8646C69.6465 81.109 70.6553 82.1178 71.8997 82.1178Z" fill="#2E2E2E"/>
|
||||
<path d="M86.4368 70.1012C87.6812 70.1012 88.69 69.0924 88.69 67.8479C88.69 66.6035 87.6812 65.5947 86.4368 65.5947C85.1924 65.5947 84.1836 66.6035 84.1836 67.8479C84.1836 69.0924 85.1924 70.1012 86.4368 70.1012Z" fill="#2E2E2E"/>
|
||||
<path d="M82.1267 20.0406L86.2476 21.9926C84.5574 19.6339 83.101 15.95 82.3213 13.0186C81.0011 15.7492 78.873 19.0903 76.7664 21.0861L81.1217 19.9656C78.438 33.1184 68.3439 42.5608 56.7847 42.5608L56.6211 43.0359C68.6949 43.0359 79.3865 33.7244 82.1267 20.0406Z" fill="#2E2E2E"/>
|
||||
<path d="M69.6669 0H95.7042C96.8085 0 97.7071 0.8985 97.7071 2.00286C97.7071 3.10723 96.8085 4.00573 95.7042 4.00573H69.6669C68.5626 4.00573 67.6641 3.10723 67.6641 2.00286C67.6641 0.8985 68.5626 0 69.6669 0Z" fill="#3F76FF"/>
|
||||
<path d="M87.2735 115.346C86.999 115.346 86.722 115.285 86.4582 115.162C85.8076 114.856 85.399 114.245 85.3658 113.527L85.0959 107.706C85.0612 106.958 85.4514 106.278 86.1144 105.931C86.7772 105.584 87.5586 105.651 88.153 106.105L92.1177 109.137C92.5944 109.502 92.8668 110.055 92.8648 110.655C92.8631 111.255 92.5873 111.806 92.1084 112.168L88.4136 114.958C88.0726 115.215 87.6757 115.346 87.2735 115.346Z" fill="#363A3F"/>
|
||||
<path d="M14.2019 37.1274C14.2019 37.402 14.1407 37.679 14.017 37.9428C13.7114 38.5934 13.1002 39.0019 12.3824 39.0352L6.56105 39.3051C5.8134 39.3398 5.13372 38.9496 4.78654 38.2865C4.43937 37.6237 4.50684 36.8423 4.96062 36.248L7.99279 32.2833C8.35708 31.8066 8.9106 31.5342 9.51009 31.5361C10.1106 31.5379 10.6616 31.8136 11.0235 32.2926L13.8131 35.9873C14.0703 36.3284 14.2019 36.7252 14.2019 37.1274Z" fill="#363A3F"/>
|
||||
<path d="M46.6797 31.9697C43.7154 31.2596 40.5482 31.9023 38.0143 33.5762C35.3571 35.3315 33.5978 38.1422 33.0023 41.2561C31.7737 47.6804 35.5272 54.0982 41.4594 56.6551C47.6955 59.343 55.114 57.5394 59.6426 52.5507C61.8873 50.078 63.2901 46.9499 63.7125 43.6419C64.1286 40.383 63.654 36.6231 61.6807 33.9032C59.7234 31.2053 56.464 30.3 53.3163 29.8579C50.262 29.4289 46.2102 29.2424 44.5587 32.4752C44.2438 33.0916 44.0958 33.7558 44.1307 34.4471C44.155 34.9281 44.9062 34.931 44.8818 34.4471C44.8055 32.9356 45.8132 31.6726 47.1049 30.9933C48.6018 30.206 50.4074 30.2726 52.04 30.4481C54.9873 30.7649 58.3335 31.3301 60.459 33.5937C62.5762 35.8486 63.2109 39.2849 63.0795 42.2781C62.9423 45.4023 61.8658 48.4773 59.9796 50.976C56.2574 55.9067 49.7451 58.2525 43.754 56.6597C37.9165 55.1076 37.8402 51.6433 37.9365 45.5083C37.9879 42.2354 34.8963 37.0837 37.4205 34.9508C39.8919 32.8626 43.3172 31.9363 46.4801 32.6939C46.9499 32.8065 47.1503 32.0824 46.6797 31.9697Z" fill="#3F76FF"/>
|
||||
</svg>
|
After Width: | Height: | Size: 6.7 KiB |
19
web/public/empty-state/estimates/light.svg
Normal file
19
web/public/empty-state/estimates/light.svg
Normal file
@ -0,0 +1,19 @@
|
||||
<svg width="98" height="121" viewBox="0 0 98 121" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.8707 74.5375C16.3076 72.9545 18.6808 73.4171 20.2598 74.5344C22.2919 75.9722 22.873 78.598 23.1728 80.9255C23.6803 84.8657 23.8304 90.4355 28.2986 92.0828C30.363 92.8439 32.7261 92.2154 34.4697 90.9796C36.3742 89.6297 37.5622 87.5534 38.3579 85.4014C39.3213 82.796 39.879 80.0089 40.3517 77.2785C40.8351 74.4863 41.1519 71.664 41.2941 68.8338C41.4333 66.0649 41.3427 63.3142 41.2327 60.5471C41.1467 58.3866 41.1139 56.1179 41.8437 54.052C42.5808 51.9655 44.2945 50.3785 46.5478 50.1692C48.7349 49.966 50.9875 50.8883 52.57 52.3768C56.3668 55.9484 56.5454 61.7791 57.3941 66.5679C57.8399 69.084 58.4917 71.63 59.8579 73.819C60.9816 75.6193 62.5432 77.1286 64.3419 78.2484C67.9699 80.507 72.5929 81.1661 76.6187 79.6144C80.5491 78.0995 83.713 74.7878 85.082 70.8073C85.4201 69.8243 85.6289 68.8088 85.7348 67.7756C85.7841 67.2952 85.0326 67.2986 84.9838 67.7756C84.5336 72.1666 81.715 76.1433 77.8526 78.2259C73.8783 80.3689 69.0654 80.097 65.1824 77.8754C63.2735 76.7832 61.6129 75.2427 60.4591 73.364C59.0598 71.0857 58.4606 68.4301 58.0247 65.8227C57.2373 61.1135 56.8802 55.7066 53.3508 52.0908C50.6136 49.2866 45.5767 48.1613 42.635 51.2848C39.6059 54.501 40.5139 59.682 40.6106 63.6745C40.7523 69.5217 40.1494 75.3825 38.8131 81.0769C38.2314 83.5555 37.5623 86.1115 36.1024 88.2369C34.7681 90.1793 32.5875 91.6848 30.1594 91.6609C27.7378 91.637 26.0364 89.8572 25.1768 87.7354C24.2698 85.4969 24.2104 83.0793 23.8965 80.7163C23.6084 78.5472 23.0835 76.1467 21.4568 74.5592C20.1918 73.3248 18.3152 72.5301 16.5348 72.814C15.6794 72.9505 14.9208 73.3661 14.3396 74.0064C14.0153 74.3636 14.5449 74.8963 14.8707 74.5375Z" fill="#3F76FF"/>
|
||||
<path d="M55.4847 43.7299H38.3499C37.9179 43.7299 37.5664 43.3784 37.5664 42.9464C37.5664 42.5144 37.9179 42.1631 38.3499 42.1631H55.4847C55.9167 42.1631 56.268 42.5144 56.268 42.9464C56.268 43.3784 55.9167 43.7299 55.4847 43.7299Z" fill="#D9D9E0"/>
|
||||
<path d="M81.9847 89.2953H64.8499C64.4179 89.2953 64.0664 88.9438 64.0664 88.5118C64.0664 88.0798 64.4179 87.7285 64.8499 87.7285H81.9847C82.4167 87.7285 82.768 88.0798 82.768 88.5118C82.768 88.9438 82.4167 89.2953 81.9847 89.2953Z" fill="#D9D9E0"/>
|
||||
<path d="M96.0179 120.34H78.8831C78.4511 120.34 78.0996 119.989 78.0996 119.557C78.0996 119.125 78.4511 118.773 78.8831 118.773H96.0179C96.4499 118.773 96.8012 119.125 96.8012 119.557C96.8012 119.989 96.4499 120.34 96.0179 120.34Z" fill="#D9D9E0"/>
|
||||
<path d="M17.9183 26.7064H0.78347C0.351487 26.7064 0 26.3549 0 25.923C0 25.491 0.351492 25.1396 0.78347 25.1396H17.9183C18.3503 25.1396 18.7016 25.491 18.7016 25.923C18.7016 26.3549 18.3503 26.7064 17.9183 26.7064Z" fill="#D9D9E0"/>
|
||||
<path d="M87.2283 111.247H9.56712C9.29059 111.247 9.06641 111.023 9.06641 110.746V36.6404C9.06641 36.3636 9.2906 36.1396 9.56712 36.1396C9.84364 36.1396 10.0678 36.3636 10.0678 36.6404V66.8777C10.0678 90.8292 29.4843 110.246 53.4358 110.246H87.2283C87.5048 110.246 87.729 110.47 87.729 110.746C87.729 111.023 87.5048 111.247 87.2283 111.247Z" fill="#D9D9E0"/>
|
||||
<path d="M25.9613 81.4652H8.82644C8.39446 81.4652 8.04297 81.1137 8.04297 80.6817C8.04297 80.2498 8.39446 79.8984 8.82644 79.8984H25.9613C26.3932 79.8984 26.7446 80.2498 26.7446 80.6817C26.7446 81.1137 26.3932 81.4652 25.9613 81.4652Z" fill="#D9D9E0"/>
|
||||
<path d="M14.8118 76.609C16.0562 76.609 17.065 75.6002 17.065 74.3558C17.065 73.1113 16.0562 72.1025 14.8118 72.1025C13.5674 72.1025 12.5586 73.1113 12.5586 74.3558C12.5586 75.6002 13.5674 76.609 14.8118 76.609Z" fill="#2E2E2E"/>
|
||||
<path d="M29.8431 94.1344C31.0875 94.1344 32.0963 93.1256 32.0963 91.8812C32.0963 90.6367 31.0875 89.6279 29.8431 89.6279C28.5986 89.6279 27.5898 90.6367 27.5898 91.8812C27.5898 93.1256 28.5986 94.1344 29.8431 94.1344Z" fill="#2E2E2E"/>
|
||||
<path d="M46.8587 52.0738C48.1031 52.0738 49.1119 51.065 49.1119 49.8206C49.1119 48.5762 48.1031 47.5674 46.8587 47.5674C45.6143 47.5674 44.6055 48.5762 44.6055 49.8206C44.6055 51.065 45.6143 52.0738 46.8587 52.0738Z" fill="#2E2E2E"/>
|
||||
<path d="M71.8997 82.1178C73.1441 82.1178 74.1529 81.109 74.1529 79.8646C74.1529 78.6201 73.1441 77.6113 71.8997 77.6113C70.6553 77.6113 69.6465 78.6201 69.6465 79.8646C69.6465 81.109 70.6553 82.1178 71.8997 82.1178Z" fill="#2E2E2E"/>
|
||||
<path d="M86.4368 70.1012C87.6812 70.1012 88.69 69.0924 88.69 67.8479C88.69 66.6035 87.6812 65.5947 86.4368 65.5947C85.1924 65.5947 84.1836 66.6035 84.1836 67.8479C84.1836 69.0924 85.1924 70.1012 86.4368 70.1012Z" fill="#2E2E2E"/>
|
||||
<path d="M82.1267 20.0406L86.2476 21.9926C84.5574 19.6339 83.101 15.95 82.3213 13.0186C81.0011 15.7492 78.873 19.0903 76.7664 21.0861L81.1217 19.9656C78.438 33.1184 68.3439 42.5608 56.7847 42.5608L56.6211 43.0359C68.6949 43.0359 79.3865 33.7244 82.1267 20.0406Z" fill="#2E2E2E"/>
|
||||
<path d="M69.6669 0H95.7042C96.8085 0 97.7071 0.8985 97.7071 2.00286C97.7071 3.10723 96.8085 4.00573 95.7042 4.00573H69.6669C68.5626 4.00573 67.6641 3.10723 67.6641 2.00286C67.6641 0.8985 68.5626 0 69.6669 0Z" fill="#3F76FF"/>
|
||||
<path d="M87.2735 115.346C86.999 115.346 86.722 115.285 86.4582 115.162C85.8076 114.856 85.399 114.245 85.3658 113.527L85.0959 107.706C85.0612 106.958 85.4514 106.278 86.1144 105.931C86.7772 105.584 87.5586 105.651 88.153 106.105L92.1177 109.137C92.5944 109.502 92.8668 110.055 92.8648 110.655C92.8631 111.255 92.5873 111.806 92.1084 112.168L88.4136 114.958C88.0726 115.215 87.6757 115.346 87.2735 115.346Z" fill="#D9D9E0"/>
|
||||
<path d="M14.2019 37.1274C14.2019 37.402 14.1407 37.679 14.017 37.9428C13.7114 38.5934 13.1002 39.0019 12.3824 39.0352L6.56105 39.3051C5.8134 39.3398 5.13372 38.9496 4.78654 38.2865C4.43937 37.6237 4.50684 36.8423 4.96062 36.248L7.99279 32.2833C8.35708 31.8066 8.9106 31.5342 9.51009 31.5361C10.1106 31.5379 10.6616 31.8136 11.0235 32.2926L13.8131 35.9873C14.0703 36.3284 14.2019 36.7252 14.2019 37.1274Z" fill="#D9D9E0"/>
|
||||
<path d="M46.6797 31.9697C43.7154 31.2596 40.5482 31.9023 38.0143 33.5762C35.3571 35.3315 33.5978 38.1422 33.0023 41.2561C31.7737 47.6804 35.5272 54.0982 41.4594 56.6551C47.6955 59.343 55.114 57.5394 59.6426 52.5507C61.8873 50.078 63.2901 46.9499 63.7125 43.6419C64.1286 40.383 63.654 36.6231 61.6807 33.9032C59.7234 31.2053 56.464 30.3 53.3163 29.8579C50.262 29.4289 46.2102 29.2424 44.5587 32.4752C44.2438 33.0916 44.0958 33.7558 44.1307 34.4471C44.155 34.9281 44.9062 34.931 44.8818 34.4471C44.8055 32.9356 45.8132 31.6726 47.1049 30.9933C48.6018 30.206 50.4074 30.2726 52.04 30.4481C54.9873 30.7649 58.3335 31.3301 60.459 33.5937C62.5762 35.8486 63.2109 39.2849 63.0795 42.2781C62.9423 45.4023 61.8658 48.4773 59.9796 50.976C56.2574 55.9067 49.7451 58.2525 43.754 56.6597C37.9165 55.1076 37.8402 51.6433 37.9365 45.5083C37.9879 42.2354 34.8963 37.0837 37.4205 34.9508C39.8919 32.8626 43.3172 31.9363 46.4801 32.6939C46.9499 32.8065 47.1503 32.0824 46.6797 31.9697Z" fill="#3F76FF"/>
|
||||
</svg>
|
After Width: | Height: | Size: 6.7 KiB |
138
web/services/project/estimate.service.ts
Normal file
138
web/services/project/estimate.service.ts
Normal file
@ -0,0 +1,138 @@
|
||||
// types
|
||||
import { IEstimate, IEstimateFormData, IEstimatePoint } from "@plane/types";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
class EstimateService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async fetchWorkspaceEstimates(workspaceSlug: string): Promise<IEstimate[] | undefined> {
|
||||
try {
|
||||
const { data } = await this.get(`/api/workspaces/${workspaceSlug}/estimates/`);
|
||||
return data || undefined;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchProjectEstimates(workspaceSlug: string, projectId: string): Promise<IEstimate[] | undefined> {
|
||||
try {
|
||||
const { data } = await this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/`);
|
||||
return data || undefined;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchEstimateById(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
estimateId: string
|
||||
): Promise<IEstimate | undefined> {
|
||||
try {
|
||||
const { data } = await this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/`
|
||||
);
|
||||
return data || undefined;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createEstimate(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
payload: IEstimateFormData
|
||||
): Promise<IEstimate | undefined> {
|
||||
try {
|
||||
const { data } = await this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/`, payload);
|
||||
return data || undefined;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateEstimate(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
estimateId: string,
|
||||
payload: Partial<IEstimateFormData>
|
||||
): Promise<IEstimate | undefined> {
|
||||
try {
|
||||
const { data } = await this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/`,
|
||||
payload
|
||||
);
|
||||
return data || undefined;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteEstimate(workspaceSlug: string, projectId: string, estimateId: string): Promise<void> {
|
||||
try {
|
||||
await this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/`);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createEstimatePoint(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
estimateId: string,
|
||||
payload: Partial<IEstimatePoint>
|
||||
): Promise<IEstimatePoint | undefined> {
|
||||
try {
|
||||
const { data } = await this.post(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/estimate-points/`,
|
||||
payload
|
||||
);
|
||||
return data || undefined;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateEstimatePoint(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
estimateId: string,
|
||||
estimatePointId: string,
|
||||
payload: Partial<IEstimatePoint>
|
||||
): Promise<IEstimatePoint | undefined> {
|
||||
try {
|
||||
const { data } = await this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/estimate-points/${estimatePointId}/`,
|
||||
payload
|
||||
);
|
||||
return data || undefined;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async removeEstimatePoint(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
estimateId: string,
|
||||
estimatePointId: string,
|
||||
params?: { new_estimate_id: string | undefined }
|
||||
): Promise<IEstimatePoint[] | undefined> {
|
||||
return this.delete(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/estimate-points/${estimatePointId}/`,
|
||||
params
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
const estimateService = new EstimateService();
|
||||
|
||||
export default estimateService;
|
@ -1,5 +1,5 @@
|
||||
export * from "./project.service";
|
||||
export * from "./project-estimate.service";
|
||||
export * from "./estimate.service";
|
||||
export * from "./project-export.service";
|
||||
export * from "./project-member.service";
|
||||
export * from "./project-state.service";
|
||||
|
@ -1,72 +0,0 @@
|
||||
// services
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
import { APIService } from "@/services/api.service";
|
||||
// types
|
||||
import type { IEstimate, IEstimateFormData, IEstimatePoint } from "@plane/types";
|
||||
// helpers
|
||||
|
||||
export class ProjectEstimateService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async createEstimate(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
data: IEstimateFormData
|
||||
): Promise<{
|
||||
estimate: IEstimate;
|
||||
estimate_points: IEstimatePoint[];
|
||||
}> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async patchEstimate(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
estimateId: string,
|
||||
data: IEstimateFormData
|
||||
): Promise<any> {
|
||||
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getEstimateDetails(workspaceSlug: string, projectId: string, estimateId: string): Promise<IEstimate> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getEstimatesList(workspaceSlug: string, projectId: string): Promise<IEstimate[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEstimate(workspaceSlug: string, projectId: string, estimateId: string): Promise<any> {
|
||||
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getWorkspaceEstimatesList(workspaceSlug: string): Promise<IEstimate[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/estimates/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
@ -1,222 +0,0 @@
|
||||
import set from "lodash/set";
|
||||
import { observable, action, makeObservable, runInAction, computed } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import { IEstimate, IEstimateFormData } from "@plane/types";
|
||||
// services
|
||||
import { ProjectEstimateService } from "@/services/project";
|
||||
// store
|
||||
import { RootStore } from "@/store/root.store";
|
||||
|
||||
export interface IEstimateStore {
|
||||
//Loaders
|
||||
fetchedMap: Record<string, boolean>;
|
||||
// observables
|
||||
estimateMap: Record<string, IEstimate>;
|
||||
// computed
|
||||
areEstimatesEnabledForCurrentProject: boolean;
|
||||
projectEstimates: IEstimate[] | null;
|
||||
activeEstimateDetails: IEstimate | null;
|
||||
// computed actions
|
||||
areEstimatesEnabledForProject: (projectId: string) => boolean;
|
||||
getEstimatePointValue: (estimateKey: string | null, projectId: string | null) => string;
|
||||
getProjectEstimateById: (estimateId: string) => IEstimate | null;
|
||||
getProjectActiveEstimateDetails: (projectId: string) => IEstimate | null;
|
||||
// fetch actions
|
||||
fetchProjectEstimates: (workspaceSlug: string, projectId: string) => Promise<IEstimate[]>;
|
||||
fetchWorkspaceEstimates: (workspaceSlug: string) => Promise<IEstimate[]>;
|
||||
// crud actions
|
||||
createEstimate: (workspaceSlug: string, projectId: string, data: IEstimateFormData) => Promise<IEstimate>;
|
||||
updateEstimate: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
estimateId: string,
|
||||
data: IEstimateFormData
|
||||
) => Promise<IEstimate>;
|
||||
deleteEstimate: (workspaceSlug: string, projectId: string, estimateId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class EstimateStore implements IEstimateStore {
|
||||
// observables
|
||||
estimateMap: Record<string, IEstimate> = {};
|
||||
//loaders
|
||||
fetchedMap: Record<string, boolean> = {};
|
||||
// root store
|
||||
rootStore;
|
||||
// services
|
||||
estimateService;
|
||||
|
||||
constructor(_rootStore: RootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
estimateMap: observable,
|
||||
fetchedMap: observable,
|
||||
// computed
|
||||
areEstimatesEnabledForCurrentProject: computed,
|
||||
projectEstimates: computed,
|
||||
activeEstimateDetails: computed,
|
||||
// actions
|
||||
fetchProjectEstimates: action,
|
||||
fetchWorkspaceEstimates: action,
|
||||
createEstimate: action,
|
||||
updateEstimate: action,
|
||||
deleteEstimate: action,
|
||||
});
|
||||
|
||||
// root store
|
||||
this.rootStore = _rootStore;
|
||||
// services
|
||||
this.estimateService = new ProjectEstimateService();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns true if estimates are enabled for current project, false otherwise
|
||||
*/
|
||||
get areEstimatesEnabledForCurrentProject() {
|
||||
const currentProjectDetails = this.rootStore.projectRoot.project.currentProjectDetails;
|
||||
if (!currentProjectDetails) return false;
|
||||
return Boolean(currentProjectDetails?.estimate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns the list of estimates for current project
|
||||
*/
|
||||
get projectEstimates() {
|
||||
const projectId = this.rootStore.router.projectId;
|
||||
const worksapceSlug = this.rootStore.router.workspaceSlug || "";
|
||||
if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[worksapceSlug])) return null;
|
||||
return Object.values(this.estimateMap).filter((estimate) => estimate.project === projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns the active estimate details for current project
|
||||
*/
|
||||
get activeEstimateDetails() {
|
||||
const currentProjectDetails = this.rootStore.projectRoot.project.currentProjectDetails;
|
||||
if (!currentProjectDetails || !currentProjectDetails?.estimate) return null;
|
||||
return this.estimateMap?.[currentProjectDetails?.estimate || ""] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns true if estimates are enabled for a project using project id
|
||||
* @param projectId
|
||||
*/
|
||||
areEstimatesEnabledForProject = computedFn((projectId: string) => {
|
||||
const projectDetails = this.rootStore.projectRoot.project.getProjectById(projectId);
|
||||
if (!projectDetails) return false;
|
||||
return Boolean(projectDetails.estimate) ?? false;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description returns the point value for the given estimate key to display in the UI
|
||||
*/
|
||||
getEstimatePointValue = computedFn((estimateKey: string | null, projectId: string | null) => {
|
||||
if (estimateKey === null) return "None";
|
||||
const activeEstimate = projectId ? this.getProjectActiveEstimateDetails(projectId) : this.activeEstimateDetails;
|
||||
return activeEstimate?.points?.find((point) => point.id === estimateKey)?.value || "None";
|
||||
});
|
||||
|
||||
/**
|
||||
* @description returns the estimate details for the given estimate id
|
||||
* @param estimateId
|
||||
*/
|
||||
getProjectEstimateById = computedFn((estimateId: string) => {
|
||||
if (!this.projectEstimates) return null;
|
||||
const estimateInfo = this.estimateMap?.[estimateId] || null;
|
||||
return estimateInfo;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description returns the estimate details for the given estimate id
|
||||
* @param projectId
|
||||
*/
|
||||
getProjectActiveEstimateDetails = computedFn((projectId: string) => {
|
||||
const projectDetails = this.rootStore.projectRoot.project?.getProjectById(projectId);
|
||||
const worksapceSlug = this.rootStore.router.workspaceSlug || "";
|
||||
if (!projectDetails || !projectDetails?.estimate || !(this.fetchedMap[projectId] || this.fetchedMap[worksapceSlug]))
|
||||
return null;
|
||||
return this.estimateMap?.[projectDetails?.estimate || ""] || null;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description fetches the list of estimates for the given project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
*/
|
||||
fetchProjectEstimates = async (workspaceSlug: string, projectId: string) =>
|
||||
await this.estimateService.getEstimatesList(workspaceSlug, projectId).then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((estimate) => {
|
||||
set(this.estimateMap, estimate.id, estimate);
|
||||
});
|
||||
this.fetchedMap[projectId] = true;
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description fetches the list of estimates for the given project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
*/
|
||||
fetchWorkspaceEstimates = async (workspaceSlug: string) =>
|
||||
await this.estimateService.getWorkspaceEstimatesList(workspaceSlug).then((response) => {
|
||||
runInAction(() => {
|
||||
response.forEach((estimate) => {
|
||||
set(this.estimateMap, estimate.id, estimate);
|
||||
});
|
||||
this.fetchedMap[workspaceSlug] = true;
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description creates a new estimate for the given project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param data
|
||||
*/
|
||||
createEstimate = async (workspaceSlug: string, projectId: string, data: IEstimateFormData) =>
|
||||
await this.estimateService.createEstimate(workspaceSlug, projectId, data).then((response) => {
|
||||
const responseEstimate = {
|
||||
...response.estimate,
|
||||
points: response.estimate_points,
|
||||
};
|
||||
runInAction(() => {
|
||||
set(this.estimateMap, [responseEstimate.id], responseEstimate);
|
||||
});
|
||||
return response.estimate;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description updates the given estimate for the given project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param estimateId
|
||||
* @param data
|
||||
*/
|
||||
updateEstimate = async (workspaceSlug: string, projectId: string, estimateId: string, data: IEstimateFormData) =>
|
||||
await this.estimateService.patchEstimate(workspaceSlug, projectId, estimateId, data).then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.estimateMap, estimateId, {
|
||||
...this.estimateMap[estimateId],
|
||||
...data.estimate,
|
||||
points: [...data.estimate_points],
|
||||
});
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description deletes the given estimate for the given project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param estimateId
|
||||
*/
|
||||
deleteEstimate = async (workspaceSlug: string, projectId: string, estimateId: string) =>
|
||||
await this.estimateService.deleteEstimate(workspaceSlug, projectId, estimateId).then(() => {
|
||||
runInAction(() => {
|
||||
delete this.estimateMap[estimateId];
|
||||
});
|
||||
});
|
||||
}
|
148
web/store/estimates/estimate-point.ts
Normal file
148
web/store/estimates/estimate-point.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import set from "lodash/set";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { IEstimate, IEstimatePoint as IEstimatePointType } from "@plane/types";
|
||||
// services
|
||||
import estimateService from "@/services/project/estimate.service";
|
||||
// store
|
||||
import { RootStore } from "@/store/root.store";
|
||||
|
||||
type TErrorCodes = {
|
||||
status: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export interface IEstimatePoint extends IEstimatePointType {
|
||||
// observables
|
||||
error: TErrorCodes | undefined;
|
||||
// computed
|
||||
asJson: IEstimatePointType;
|
||||
// helper actions
|
||||
updateEstimatePointObject: (estimatePoint: Partial<IEstimatePointType>) => void;
|
||||
// actions
|
||||
updateEstimatePoint: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
payload: Partial<IEstimatePointType>
|
||||
) => Promise<IEstimatePointType | undefined>;
|
||||
}
|
||||
|
||||
export class EstimatePoint implements IEstimatePoint {
|
||||
// data model observables
|
||||
id: string | undefined = undefined;
|
||||
key: number | undefined = undefined;
|
||||
value: string | undefined = undefined;
|
||||
description: string | undefined = undefined;
|
||||
workspace: string | undefined = undefined;
|
||||
project: string | undefined = undefined;
|
||||
estimate: string | undefined = undefined;
|
||||
created_at: Date | undefined = undefined;
|
||||
updated_at: Date | undefined = undefined;
|
||||
created_by: string | undefined = undefined;
|
||||
updated_by: string | undefined = undefined;
|
||||
// observables
|
||||
error: TErrorCodes | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
private store: RootStore,
|
||||
private projectEstimate: IEstimate,
|
||||
private data: IEstimatePointType
|
||||
) {
|
||||
makeObservable(this, {
|
||||
// data model observables
|
||||
id: observable.ref,
|
||||
key: observable.ref,
|
||||
value: observable.ref,
|
||||
description: observable.ref,
|
||||
workspace: observable.ref,
|
||||
project: observable.ref,
|
||||
estimate: observable.ref,
|
||||
created_at: observable.ref,
|
||||
updated_at: observable.ref,
|
||||
created_by: observable.ref,
|
||||
updated_by: observable.ref,
|
||||
// observables
|
||||
error: observable.ref,
|
||||
// computed
|
||||
asJson: computed,
|
||||
// actions
|
||||
updateEstimatePoint: action,
|
||||
});
|
||||
this.id = this.data.id;
|
||||
this.key = this.data.key;
|
||||
this.value = this.data.value;
|
||||
this.description = this.data.description;
|
||||
this.workspace = this.data.workspace;
|
||||
this.project = this.data.project;
|
||||
this.estimate = this.data.estimate;
|
||||
this.created_at = this.data.created_at;
|
||||
this.updated_at = this.data.updated_at;
|
||||
this.created_by = this.data.created_by;
|
||||
this.updated_by = this.data.updated_by;
|
||||
}
|
||||
|
||||
// computed
|
||||
get asJson() {
|
||||
return {
|
||||
id: this.id,
|
||||
key: this.key,
|
||||
value: this.value,
|
||||
description: this.description,
|
||||
workspace: this.workspace,
|
||||
project: this.project,
|
||||
estimate: this.estimate,
|
||||
created_at: this.created_at,
|
||||
updated_at: this.updated_at,
|
||||
created_by: this.created_by,
|
||||
updated_by: this.updated_by,
|
||||
};
|
||||
}
|
||||
|
||||
// helper actions
|
||||
/**
|
||||
* @description updating an estimate point object in local store
|
||||
* @param { Partial<IEstimatePointType> } estimatePoint
|
||||
* @returns { void }
|
||||
*/
|
||||
updateEstimatePointObject = (estimatePoint: Partial<IEstimatePointType>) => {
|
||||
Object.keys(estimatePoint).map((key) => {
|
||||
const estimatePointKey = key as keyof IEstimatePointType;
|
||||
set(this, estimatePointKey, estimatePoint[estimatePointKey]);
|
||||
});
|
||||
};
|
||||
|
||||
// actions
|
||||
/**
|
||||
* @description updating an estimate point
|
||||
* @param { Partial<IEstimatePointType> } payload
|
||||
* @returns { IEstimatePointType | undefined }
|
||||
*/
|
||||
updateEstimatePoint = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
payload: Partial<IEstimatePointType>
|
||||
): Promise<IEstimatePointType | undefined> => {
|
||||
try {
|
||||
if (!this.projectEstimate?.id || !this.id || !payload) return undefined;
|
||||
|
||||
const estimatePoint = await estimateService.updateEstimatePoint(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
this.projectEstimate?.id,
|
||||
this.id,
|
||||
payload
|
||||
);
|
||||
if (estimatePoint) {
|
||||
runInAction(() => {
|
||||
Object.keys(payload).map((key) => {
|
||||
const estimatePointKey = key as keyof IEstimatePointType;
|
||||
set(this, estimatePointKey, estimatePoint[estimatePointKey]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return estimatePoint;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
292
web/store/estimates/estimate.ts
Normal file
292
web/store/estimates/estimate.ts
Normal file
@ -0,0 +1,292 @@
|
||||
import orderBy from "lodash/orderBy";
|
||||
import set from "lodash/set";
|
||||
import unset from "lodash/unset";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import {
|
||||
IEstimate as IEstimateType,
|
||||
IEstimatePoint as IEstimatePointType,
|
||||
TEstimateSystemKeys,
|
||||
IEstimateFormData,
|
||||
TEstimatePointsObject,
|
||||
} from "@plane/types";
|
||||
// services
|
||||
import estimateService from "@/services/project/estimate.service";
|
||||
// store
|
||||
import { IEstimatePoint, EstimatePoint } from "@/store/estimates/estimate-point";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
|
||||
type TErrorCodes = {
|
||||
status: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export interface IEstimate extends Omit<IEstimateType, "points"> {
|
||||
// observables
|
||||
error: TErrorCodes | undefined;
|
||||
estimatePoints: Record<string, IEstimatePoint>;
|
||||
// computed
|
||||
asJson: Omit<IEstimateType, "points">;
|
||||
estimatePointIds: string[] | undefined;
|
||||
estimatePointById: (estimatePointId: string) => IEstimatePointType | undefined;
|
||||
// actions
|
||||
updateEstimateSortOrder: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
payload: TEstimatePointsObject[]
|
||||
) => Promise<IEstimateType | undefined>;
|
||||
updateEstimateSwitch: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
payload: IEstimateFormData
|
||||
) => Promise<IEstimateType | undefined>;
|
||||
creteEstimatePoint: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
payload: Partial<IEstimatePointType>
|
||||
) => Promise<IEstimatePointType | undefined>;
|
||||
deleteEstimatePoint: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
estimatePointId: string,
|
||||
newEstimatePointId: string | undefined
|
||||
) => Promise<IEstimatePointType[] | undefined>;
|
||||
}
|
||||
|
||||
export class Estimate implements IEstimate {
|
||||
// data model observables
|
||||
id: string | undefined = undefined;
|
||||
name: string | undefined = undefined;
|
||||
description: string | undefined = undefined;
|
||||
type: TEstimateSystemKeys | undefined = undefined;
|
||||
workspace: string | undefined = undefined;
|
||||
project: string | undefined = undefined;
|
||||
last_used: boolean | undefined = undefined;
|
||||
created_at: Date | undefined = undefined;
|
||||
updated_at: Date | undefined = undefined;
|
||||
created_by: string | undefined = undefined;
|
||||
updated_by: string | undefined = undefined;
|
||||
// observables
|
||||
error: TErrorCodes | undefined = undefined;
|
||||
estimatePoints: Record<string, IEstimatePoint> = {};
|
||||
|
||||
constructor(
|
||||
private store: RootStore,
|
||||
private data: IEstimateType
|
||||
) {
|
||||
makeObservable(this, {
|
||||
// data model observables
|
||||
id: observable.ref,
|
||||
name: observable.ref,
|
||||
description: observable.ref,
|
||||
type: observable.ref,
|
||||
workspace: observable.ref,
|
||||
project: observable.ref,
|
||||
last_used: observable.ref,
|
||||
created_at: observable.ref,
|
||||
updated_at: observable.ref,
|
||||
created_by: observable.ref,
|
||||
updated_by: observable.ref,
|
||||
// observables
|
||||
error: observable.ref,
|
||||
estimatePoints: observable,
|
||||
// computed
|
||||
asJson: computed,
|
||||
estimatePointIds: computed,
|
||||
// actions
|
||||
updateEstimateSortOrder: action,
|
||||
updateEstimateSwitch: action,
|
||||
creteEstimatePoint: action,
|
||||
deleteEstimatePoint: action,
|
||||
});
|
||||
this.id = this.data.id;
|
||||
this.name = this.data.name;
|
||||
this.description = this.data.description;
|
||||
this.type = this.data.type;
|
||||
this.workspace = this.data.workspace;
|
||||
this.project = this.data.project;
|
||||
this.last_used = this.data.last_used;
|
||||
this.created_at = this.data.created_at;
|
||||
this.updated_at = this.data.updated_at;
|
||||
this.created_by = this.data.created_by;
|
||||
this.updated_by = this.data.updated_by;
|
||||
this.data.points?.forEach((estimationPoint) => {
|
||||
if (estimationPoint.id)
|
||||
set(this.estimatePoints, [estimationPoint.id], new EstimatePoint(this.store, this.data, estimationPoint));
|
||||
});
|
||||
}
|
||||
|
||||
// computed
|
||||
get asJson() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
type: this.type,
|
||||
workspace: this.workspace,
|
||||
project: this.project,
|
||||
last_used: this.last_used,
|
||||
created_at: this.created_at,
|
||||
updated_at: this.updated_at,
|
||||
created_by: this.created_by,
|
||||
updated_by: this.updated_by,
|
||||
};
|
||||
}
|
||||
|
||||
get estimatePointIds() {
|
||||
const { estimatePoints } = this;
|
||||
if (!estimatePoints) return undefined;
|
||||
let currentEstimatePoints = Object.values(estimatePoints).filter(
|
||||
(estimatePoint) => estimatePoint?.estimate === this.id
|
||||
);
|
||||
currentEstimatePoints = orderBy(currentEstimatePoints, ["key"], "asc");
|
||||
const estimatePointIds = currentEstimatePoints.map((estimatePoint) => estimatePoint.id) as string[];
|
||||
return estimatePointIds ?? undefined;
|
||||
}
|
||||
|
||||
estimatePointById = computedFn((estimatePointId: string) => {
|
||||
if (!estimatePointId) return undefined;
|
||||
return this.estimatePoints[estimatePointId] ?? undefined;
|
||||
});
|
||||
|
||||
// actions
|
||||
/**
|
||||
* @description update an estimate sort order
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @param { TEstimatePointsObject[] } payload
|
||||
* @returns { IEstimateType | undefined }
|
||||
*/
|
||||
updateEstimateSortOrder = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
payload: TEstimatePointsObject[]
|
||||
): Promise<IEstimateType | undefined> => {
|
||||
try {
|
||||
if (!this.id || !payload) return;
|
||||
|
||||
const estimate = await estimateService.updateEstimate(workspaceSlug, projectId, this.id, {
|
||||
estimate_points: payload,
|
||||
});
|
||||
runInAction(() => {
|
||||
estimate?.points &&
|
||||
estimate?.points.map((estimatePoint) => {
|
||||
if (estimatePoint.id)
|
||||
set(this.estimatePoints, [estimatePoint.id], new EstimatePoint(this.store, this.data, estimatePoint));
|
||||
});
|
||||
});
|
||||
|
||||
return estimate;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update an estimate sort order
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @param { IEstimateFormData} payload
|
||||
* @returns { IEstimateType | undefined }
|
||||
*/
|
||||
updateEstimateSwitch = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
payload: IEstimateFormData
|
||||
): Promise<IEstimateType | undefined> => {
|
||||
try {
|
||||
if (!this.id || !payload) return;
|
||||
|
||||
const estimate = await estimateService.updateEstimate(workspaceSlug, projectId, this.id, payload);
|
||||
if (estimate) {
|
||||
runInAction(() => {
|
||||
this.name = estimate?.name;
|
||||
this.type = estimate?.type;
|
||||
estimate?.points &&
|
||||
estimate?.points.map((estimatePoint) => {
|
||||
if (estimatePoint.id)
|
||||
this.estimatePoints?.[estimatePoint.id]?.updateEstimatePointObject({
|
||||
key: estimatePoint.key,
|
||||
value: estimatePoint.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return estimate;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description create an estimate point
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @param { Partial<IEstimatePointType> } payload
|
||||
* @returns { IEstimatePointType | undefined }
|
||||
*/
|
||||
creteEstimatePoint = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
payload: Partial<IEstimatePointType>
|
||||
): Promise<IEstimatePointType | undefined> => {
|
||||
try {
|
||||
if (!this.id || !payload) return;
|
||||
|
||||
const estimatePoint = await estimateService.createEstimatePoint(workspaceSlug, projectId, this.id, payload);
|
||||
if (estimatePoint) {
|
||||
runInAction(() => {
|
||||
if (estimatePoint.id) {
|
||||
set(this.estimatePoints, [estimatePoint.id], new EstimatePoint(this.store, this.data, estimatePoint));
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description delete an estimate point
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @param { string } estimatePointId
|
||||
* @param { string | undefined } newEstimatePointId
|
||||
* @returns { void }
|
||||
*/
|
||||
deleteEstimatePoint = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
estimatePointId: string,
|
||||
newEstimatePointId: string | undefined
|
||||
): Promise<IEstimatePointType[] | undefined> => {
|
||||
try {
|
||||
if (!this.id) return;
|
||||
|
||||
const deleteEstimatePoint = await estimateService.removeEstimatePoint(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
this.id,
|
||||
estimatePointId,
|
||||
newEstimatePointId ? { new_estimate_id: newEstimatePointId } : undefined
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
unset(this.estimatePoints, [estimatePointId]);
|
||||
});
|
||||
if (deleteEstimatePoint && deleteEstimatePoint.length > 0) {
|
||||
runInAction(() => {
|
||||
deleteEstimatePoint.map((estimatePoint) => {
|
||||
if (estimatePoint.id)
|
||||
set(this.estimatePoints, [estimatePoint.id], new EstimatePoint(this.store, this.data, estimatePoint));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return deleteEstimatePoint;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
292
web/store/estimates/project-estimate.store.ts
Normal file
292
web/store/estimates/project-estimate.store.ts
Normal file
@ -0,0 +1,292 @@
|
||||
import orderBy from "lodash/orderBy";
|
||||
import set from "lodash/set";
|
||||
import unset from "lodash/unset";
|
||||
import update from "lodash/update";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import { IEstimate as IEstimateType, IEstimateFormData } from "@plane/types";
|
||||
// services
|
||||
import estimateService from "@/services/project/estimate.service";
|
||||
// store
|
||||
import { IEstimate, Estimate } from "@/store/estimates/estimate";
|
||||
import { RootStore } from "@/store/root.store";
|
||||
|
||||
type TEstimateLoader = "init-loader" | "mutation-loader" | undefined;
|
||||
type TErrorCodes = {
|
||||
status: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export interface IProjectEstimateStore {
|
||||
// observables
|
||||
loader: TEstimateLoader;
|
||||
estimates: Record<string, IEstimate>;
|
||||
error: TErrorCodes | undefined;
|
||||
// computed
|
||||
currentActiveEstimateId: string | undefined;
|
||||
archivedEstimateIds: string[] | undefined;
|
||||
areEstimateEnabledByProjectId: (projectId: string) => boolean;
|
||||
estimateIdsByProjectId: (projectId: string) => string[] | undefined;
|
||||
estimateById: (estimateId: string) => IEstimate | undefined;
|
||||
// actions
|
||||
getWorkspaceEstimates: (workspaceSlug: string, loader?: TEstimateLoader) => Promise<IEstimateType[] | undefined>;
|
||||
getProjectEstimates: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loader?: TEstimateLoader
|
||||
) => Promise<IEstimateType[] | undefined>;
|
||||
getEstimateById: (workspaceSlug: string, projectId: string, estimateId: string) => Promise<IEstimateType | undefined>;
|
||||
createEstimate: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
data: IEstimateFormData
|
||||
) => Promise<IEstimateType | undefined>;
|
||||
deleteEstimate: (workspaceSlug: string, projectId: string, estimateId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ProjectEstimateStore implements IProjectEstimateStore {
|
||||
// observables
|
||||
loader: TEstimateLoader = undefined;
|
||||
estimates: Record<string, IEstimate> = {}; // estimate_id -> estimate
|
||||
error: TErrorCodes | undefined = undefined;
|
||||
|
||||
constructor(private store: RootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
loader: observable.ref,
|
||||
estimates: observable,
|
||||
error: observable,
|
||||
// computed
|
||||
currentActiveEstimateId: computed,
|
||||
archivedEstimateIds: computed,
|
||||
// actions
|
||||
getWorkspaceEstimates: action,
|
||||
getProjectEstimates: action,
|
||||
getEstimateById: action,
|
||||
createEstimate: action,
|
||||
deleteEstimate: action,
|
||||
});
|
||||
}
|
||||
|
||||
// computed
|
||||
/**
|
||||
* @description get current active estimate id for a project
|
||||
* @returns { string | undefined }
|
||||
*/
|
||||
get currentActiveEstimateId(): string | undefined {
|
||||
const { projectId } = this.store.router;
|
||||
if (!projectId) return undefined;
|
||||
const currentActiveEstimateId = Object.values(this.estimates || {}).find(
|
||||
(p) => p.project === projectId && p.last_used
|
||||
);
|
||||
return currentActiveEstimateId?.id ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get all archived estimate ids for a project
|
||||
* @returns { string[] | undefined }
|
||||
*/
|
||||
get archivedEstimateIds(): string[] | undefined {
|
||||
const { projectId } = this.store.router;
|
||||
if (!projectId) return undefined;
|
||||
const archivedEstimates = orderBy(
|
||||
Object.values(this.estimates || {}).filter((p) => p.project === projectId && !p.last_used),
|
||||
["created_at"],
|
||||
"desc"
|
||||
);
|
||||
const archivedEstimateIds = archivedEstimates.map((p) => p.id) as string[];
|
||||
return archivedEstimateIds ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description get estimates are enabled in the project or not
|
||||
* @returns { boolean }
|
||||
*/
|
||||
areEstimateEnabledByProjectId = computedFn((projectId: string) => {
|
||||
if (!projectId) return false;
|
||||
const projectDetails = this.store.projectRoot.project.getProjectById(projectId);
|
||||
if (!projectDetails) return false;
|
||||
return Boolean(projectDetails.estimate) || false;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get all estimate ids for a project
|
||||
* @returns { string[] | undefined }
|
||||
*/
|
||||
estimateIdsByProjectId = computedFn((projectId: string) => {
|
||||
if (!projectId) return undefined;
|
||||
const projectEstimatesIds = Object.values(this.estimates || {})
|
||||
.filter((p) => p.project === projectId)
|
||||
.map((p) => p.id) as string[];
|
||||
return projectEstimatesIds ?? undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description get estimate by id
|
||||
* @returns { IEstimate | undefined }
|
||||
*/
|
||||
estimateById = computedFn((estimateId: string) => {
|
||||
if (!estimateId) return undefined;
|
||||
return this.estimates[estimateId] ?? undefined;
|
||||
});
|
||||
|
||||
// actions
|
||||
/**
|
||||
* @description fetch all estimates for a workspace
|
||||
* @param { string } workspaceSlug
|
||||
* @returns { IEstimateType[] | undefined }
|
||||
*/
|
||||
getWorkspaceEstimates = async (
|
||||
workspaceSlug: string,
|
||||
loader: TEstimateLoader = "mutation-loader"
|
||||
): Promise<IEstimateType[] | undefined> => {
|
||||
try {
|
||||
this.error = undefined;
|
||||
if (Object.keys(this.estimates || {}).length <= 0) this.loader = loader ? loader : "init-loader";
|
||||
|
||||
const estimates = await estimateService.fetchWorkspaceEstimates(workspaceSlug);
|
||||
if (estimates && estimates.length > 0) {
|
||||
runInAction(() => {
|
||||
estimates.forEach((estimate) => {
|
||||
if (estimate.id) set(this.estimates, [estimate.id], new Estimate(this.store, estimate));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return estimates;
|
||||
} catch (error) {
|
||||
this.loader = undefined;
|
||||
this.error = {
|
||||
status: "error",
|
||||
message: "Error fetching estimates",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetch all estimates for a project
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @returns { IEstimateType[] | undefined }
|
||||
*/
|
||||
getProjectEstimates = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
loader: TEstimateLoader = "mutation-loader"
|
||||
): Promise<IEstimateType[] | undefined> => {
|
||||
try {
|
||||
this.error = undefined;
|
||||
if (!this.estimateIdsByProjectId(projectId)) this.loader = loader ? loader : "init-loader";
|
||||
|
||||
const estimates = await estimateService.fetchProjectEstimates(workspaceSlug, projectId);
|
||||
if (estimates && estimates.length > 0) {
|
||||
runInAction(() => {
|
||||
estimates.forEach((estimate) => {
|
||||
if (estimate.id) set(this.estimates, [estimate.id], new Estimate(this.store, estimate));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return estimates;
|
||||
} catch (error) {
|
||||
this.loader = undefined;
|
||||
this.error = {
|
||||
status: "error",
|
||||
message: "Error fetching estimates",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update an estimate for a project
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @param { string } estimateId
|
||||
* @returns IEstimateType | undefined
|
||||
*/
|
||||
getEstimateById = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
estimateId: string
|
||||
): Promise<IEstimateType | undefined> => {
|
||||
try {
|
||||
this.error = undefined;
|
||||
|
||||
const estimate = await estimateService.fetchEstimateById(workspaceSlug, projectId, estimateId);
|
||||
if (estimate) {
|
||||
runInAction(() => {
|
||||
if (estimate.id)
|
||||
update(this.estimates, [estimate.id], (estimateStore) => {
|
||||
if (estimateStore) estimateStore.updateEstimate(estimate);
|
||||
else return new Estimate(this.store, estimate);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return estimate;
|
||||
} catch (error) {
|
||||
this.error = {
|
||||
status: "error",
|
||||
message: "Error fetching estimate by id",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description create an estimate for a project
|
||||
* @param { string } workspaceSlug
|
||||
* @param { string } projectId
|
||||
* @param { Partial<IEstimateFormData> } payload
|
||||
* @returns
|
||||
*/
|
||||
createEstimate = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
payload: IEstimateFormData
|
||||
): Promise<IEstimateType | undefined> => {
|
||||
try {
|
||||
this.error = undefined;
|
||||
|
||||
const estimate = await estimateService.createEstimate(workspaceSlug, projectId, payload);
|
||||
if (estimate) {
|
||||
// update estimate_id in current project
|
||||
// await this.store.projectRoot.project.updateProject(workspaceSlug, projectId, {
|
||||
// estimate: estimate.id,
|
||||
// });
|
||||
runInAction(() => {
|
||||
if (estimate.id) set(this.estimates, [estimate.id], new Estimate(this.store, estimate));
|
||||
});
|
||||
}
|
||||
|
||||
return estimate;
|
||||
} catch (error) {
|
||||
this.error = {
|
||||
status: "error",
|
||||
message: "Error creating estimate",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description delete the estimate for a project
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param estimateId
|
||||
*/
|
||||
deleteEstimate = async (workspaceSlug: string, projectId: string, estimateId: string) => {
|
||||
try {
|
||||
await estimateService.deleteEstimate(workspaceSlug, projectId, estimateId);
|
||||
runInAction(() => estimateId && unset(this.estimates, [estimateId]));
|
||||
} catch (error) {
|
||||
this.error = {
|
||||
status: "error",
|
||||
message: "Error deleting estimate",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
@ -346,7 +346,10 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore {
|
||||
// fetching other project modules
|
||||
this.rootIssueDetailStore.rootIssueStore.rootStore.module.fetchModules(workspaceSlug, projectId);
|
||||
// fetching other project estimates
|
||||
this.rootIssueDetailStore.rootIssueStore.rootStore.estimate.fetchProjectEstimates(workspaceSlug, projectId);
|
||||
this.rootIssueDetailStore.rootIssueStore.rootStore.projectEstimate.getProjectEstimates(
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -4,7 +4,7 @@ import { CommandPaletteStore, ICommandPaletteStore } from "./command-palette.sto
|
||||
import { CycleStore, ICycleStore } from "./cycle.store";
|
||||
import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store";
|
||||
import { DashboardStore, IDashboardStore } from "./dashboard.store";
|
||||
import { EstimateStore, IEstimateStore } from "./estimate.store";
|
||||
import { IProjectEstimateStore, ProjectEstimateStore } from "./estimates/project-estimate.store";
|
||||
import { EventTrackerStore, IEventTrackerStore } from "./event-tracker.store";
|
||||
import { GlobalViewStore, IGlobalViewStore } from "./global-view.store";
|
||||
import { IProjectInboxStore, ProjectInboxStore } from "./inbox/project-inbox.store";
|
||||
@ -39,7 +39,6 @@ export class RootStore {
|
||||
issue: IIssueRootStore;
|
||||
state: IStateStore;
|
||||
label: ILabelStore;
|
||||
estimate: IEstimateStore;
|
||||
dashboard: IDashboardStore;
|
||||
projectPages: IProjectPageStore;
|
||||
router: IRouterStore;
|
||||
@ -49,6 +48,7 @@ export class RootStore {
|
||||
instance: IInstanceStore;
|
||||
user: IUserStore;
|
||||
projectInbox: IProjectInboxStore;
|
||||
projectEstimate: IProjectEstimateStore;
|
||||
multipleSelect: IMultipleSelectStore;
|
||||
|
||||
constructor() {
|
||||
@ -66,7 +66,6 @@ export class RootStore {
|
||||
this.issue = new IssueRootStore(this);
|
||||
this.state = new StateStore(this);
|
||||
this.label = new LabelStore(this);
|
||||
this.estimate = new EstimateStore(this);
|
||||
this.dashboard = new DashboardStore(this);
|
||||
this.commandPalette = new CommandPaletteStore();
|
||||
this.theme = new ThemeStore(this);
|
||||
@ -77,6 +76,7 @@ export class RootStore {
|
||||
this.projectInbox = new ProjectInboxStore(this);
|
||||
this.projectPages = new ProjectPageStore(this);
|
||||
this.theme = new ThemeStore(this);
|
||||
this.projectEstimate = new ProjectEstimateStore(this);
|
||||
}
|
||||
|
||||
resetOnSignOut() {
|
||||
@ -95,7 +95,6 @@ export class RootStore {
|
||||
this.issue = new IssueRootStore(this);
|
||||
this.state = new StateStore(this);
|
||||
this.label = new LabelStore(this);
|
||||
this.estimate = new EstimateStore(this);
|
||||
this.dashboard = new DashboardStore(this);
|
||||
this.router = new RouterStore();
|
||||
this.commandPalette = new CommandPaletteStore();
|
||||
@ -105,5 +104,6 @@ export class RootStore {
|
||||
this.projectInbox = new ProjectInboxStore(this);
|
||||
this.projectPages = new ProjectPageStore(this);
|
||||
this.multipleSelect = new MultipleSelectStore();
|
||||
this.projectEstimate = new ProjectEstimateStore(this);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user