Merge branch 'develop' of https://github.com/makeplane/plane into refactor/publish-project

This commit is contained in:
Aaryan Khandelwal 2024-06-06 15:45:57 +05:30
commit 158d99119c
80 changed files with 4312 additions and 1859 deletions

View File

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

View File

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

View File

@ -190,6 +190,7 @@ from .external.base import (
from .estimate.base import (
ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint,
EstimatePointEndpoint,
)
from .inbox.base import InboxViewSet, InboxIssueViewSet

View File

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

View File

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

View 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),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
);
});

View File

@ -0,0 +1,2 @@
export * from "./modal";
export * from "./stage-one";

View 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>
);
});

View 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>
);
};
//

View File

@ -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.
</>
}
/>
);
});

View File

@ -0,0 +1 @@
export * from "./modal";

View 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>
&nbsp;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>
);
});

View 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>
);
};

View 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"
/>
);
});

View 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>
);
});

View File

@ -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>
);
});

View 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>
);
});

View 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>;
});

View File

@ -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>
)}
</>
);
});

View File

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

View 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>
);

View 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>
);
});

View 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>
);
});

View 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>
);
});

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

View 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>
);
});

View 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>
);
};

View 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>
);
});

View 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;

View 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&nbsp;
<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>
);
});

View File

@ -0,0 +1,2 @@
export * from "./modal";
export * from "./stage-one";

View 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>
);
});

View 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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@ -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();

View File

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

View File

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

View File

@ -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
View 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
View 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;
};

View File

@ -0,0 +1,3 @@
export * from "./use-project-estimate";
export * from "./use-estimate";
export * from "./use-estimate-point";

View 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] || {};
};

View 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] ?? {};
};

View 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;
};

View File

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

View File

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

View File

@ -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());
}
};

View File

@ -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 }
);
};

View File

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

View File

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

View File

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

View File

@ -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>
</>
);

View 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

View 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

View 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;

View File

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

View File

@ -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;
});
}
}

View File

@ -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];
});
});
}

View 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;
}
};
}

View 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;
}
};
}

View 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;
}
};
}

View File

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

View File

@ -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);
}
}

1612
yarn.lock

File diff suppressed because it is too large Load Diff