Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c376aaf0a | ||
|
|
24899887b2 | ||
|
|
c6953ff878 | ||
|
|
06be9ab81b | ||
|
|
ed8d00acb1 | ||
|
|
915e374485 | ||
|
|
1d5b93cebd | ||
|
|
df65b8c34a | ||
|
|
4c688b1d25 | ||
|
|
bfc6ed839f | ||
|
|
b68396a4b2 | ||
|
|
b4fc715aba | ||
|
|
33a1b916cb | ||
|
|
2818310619 | ||
|
|
882520b3c7 | ||
|
|
20132e7544 | ||
|
|
0ae57b49d2 | ||
|
|
d347269afb | ||
|
|
a3fd616ec4 | ||
|
|
9eeff158d5 | ||
|
|
ef20b5814e | ||
|
|
14914e8716 | ||
|
|
b738e39a4a |
@@ -39,7 +39,15 @@ class CycleSerializer(BaseSerializer):
|
||||
data.get("start_date", None) is not None
|
||||
and data.get("end_date", None) is not None
|
||||
):
|
||||
project_id = self.initial_data.get("project_id") or self.instance.project_id
|
||||
project_id = self.initial_data.get("project_id") or (
|
||||
self.instance.project_id
|
||||
if self.instance and hasattr(self.instance, "project_id")
|
||||
else None
|
||||
)
|
||||
|
||||
if not project_id:
|
||||
raise serializers.ValidationError("Project ID is required")
|
||||
|
||||
is_start_date_end_date_equal = (
|
||||
True
|
||||
if str(data.get("start_date")) == str(data.get("end_date"))
|
||||
|
||||
@@ -141,8 +141,10 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
if pk:
|
||||
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
|
||||
data = CycleSerializer(
|
||||
queryset, fields=self.fields,
|
||||
expand=self.expand, context={"project": project}
|
||||
queryset,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
).data
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
queryset = self.get_queryset().filter(archived_at__isnull=True)
|
||||
@@ -154,8 +156,11 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
start_date__lte=timezone.now(), end_date__gte=timezone.now()
|
||||
)
|
||||
data = CycleSerializer(
|
||||
queryset, many=True, fields=self.fields,
|
||||
expand=self.expand, context={"project": project}
|
||||
queryset,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
).data
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -166,8 +171,11 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles, many=True, fields=self.fields,
|
||||
expand=self.expand, context={"project": project}
|
||||
cycles,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
).data,
|
||||
)
|
||||
|
||||
@@ -178,8 +186,11 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles, many=True, fields=self.fields,
|
||||
expand=self.expand, context={"project": project}
|
||||
cycles,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
).data,
|
||||
)
|
||||
|
||||
@@ -190,8 +201,11 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles, many=True, fields=self.fields,
|
||||
expand=self.expand, context={"project": project}
|
||||
cycles,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
).data,
|
||||
)
|
||||
|
||||
@@ -204,16 +218,22 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles, many=True, fields=self.fields,
|
||||
expand=self.expand, context={"project": project}
|
||||
cycles,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
).data,
|
||||
)
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles, many=True, fields=self.fields,
|
||||
expand=self.expand, context={"project": project}
|
||||
cycles,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
).data,
|
||||
)
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ from plane.settings.storage import S3Storage
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.host import base_host
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
|
||||
|
||||
class WorkspaceIssueAPIEndpoint(BaseAPIView):
|
||||
@@ -322,6 +323,17 @@ class IssueAPIEndpoint(BaseAPIView):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(serializer.data["id"]),
|
||||
requested_data=request.data,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ProjectAPIEndpoint(BaseAPIView):
|
||||
"""Project Endpoints to create, update, list, retrieve and delete endpoint"""
|
||||
|
||||
@@ -238,7 +239,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except Workspace.DoesNotExist:
|
||||
return Response(
|
||||
@@ -247,7 +248,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
def patch(self, request, slug, pk):
|
||||
@@ -305,7 +306,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except (Project.DoesNotExist, Workspace.DoesNotExist):
|
||||
return Response(
|
||||
@@ -314,7 +315,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
def delete(self, request, slug, pk):
|
||||
|
||||
@@ -352,8 +352,19 @@ class IssueRelationSerializer(BaseSerializer):
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["workspace", "project"]
|
||||
|
||||
|
||||
class RelatedIssueSerializer(BaseSerializer):
|
||||
@@ -383,8 +394,19 @@ class RelatedIssueSerializer(BaseSerializer):
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["workspace", "project"]
|
||||
|
||||
|
||||
class IssueAssigneeSerializer(BaseSerializer):
|
||||
|
||||
@@ -41,6 +41,7 @@ from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ProjectViewSet(BaseViewSet):
|
||||
serializer_class = ProjectListSerializer
|
||||
model = Project
|
||||
@@ -341,7 +342,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except Workspace.DoesNotExist:
|
||||
return Response(
|
||||
@@ -350,7 +351,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
except serializers.ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, pk=None):
|
||||
@@ -419,7 +420,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except (Project.DoesNotExist, Workspace.DoesNotExist):
|
||||
return Response(
|
||||
@@ -428,7 +429,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
except serializers.ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, pk):
|
||||
|
||||
@@ -29,7 +29,7 @@ class WebhookEndpoint(BaseAPIView):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"error": "URL already exists for the workspace"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
raise IntegrityError
|
||||
|
||||
|
||||
@@ -119,7 +119,9 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# Get total members and role
|
||||
total_members=WorkspaceMember.objects.filter(workspace_id=serializer.data["id"]).count()
|
||||
total_members = WorkspaceMember.objects.filter(
|
||||
workspace_id=serializer.data["id"]
|
||||
).count()
|
||||
data = serializer.data
|
||||
data["total_members"] = total_members
|
||||
data["role"] = 20
|
||||
@@ -134,7 +136,7 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"slug": "The workspace with the slug already exists"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
@@ -167,10 +169,9 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
|
||||
role = (
|
||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True)
|
||||
.values("role")
|
||||
)
|
||||
role = WorkspaceMember.objects.filter(
|
||||
workspace=OuterRef("id"), member=request.user, is_active=True
|
||||
).values("role")
|
||||
|
||||
workspace = (
|
||||
Workspace.objects.prefetch_related(
|
||||
|
||||
@@ -307,6 +307,10 @@ def track_labels(
|
||||
|
||||
# Set of newly added labels
|
||||
for added_label in added_labels:
|
||||
# validate uuids
|
||||
if not is_valid_uuid(added_label):
|
||||
continue
|
||||
|
||||
label = Label.objects.get(pk=added_label)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -327,6 +331,10 @@ def track_labels(
|
||||
|
||||
# Set of dropped labels
|
||||
for dropped_label in dropped_labels:
|
||||
# validate uuids
|
||||
if not is_valid_uuid(dropped_label):
|
||||
continue
|
||||
|
||||
label = Label.objects.get(pk=dropped_label)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -373,6 +381,10 @@ def track_assignees(
|
||||
|
||||
bulk_subscribers = []
|
||||
for added_asignee in added_assignees:
|
||||
# validate uuids
|
||||
if not is_valid_uuid(added_asignee):
|
||||
continue
|
||||
|
||||
assignee = User.objects.get(pk=added_asignee)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -406,6 +418,10 @@ def track_assignees(
|
||||
)
|
||||
|
||||
for dropped_assignee in dropped_assginees:
|
||||
# validate uuids
|
||||
if not is_valid_uuid(dropped_assignee):
|
||||
continue
|
||||
|
||||
assignee = User.objects.get(pk=dropped_assignee)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 4.2.17 on 2025-03-04 19:29
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0092_alter_deprecateddashboardwidget_unique_together_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="page",
|
||||
name="moved_to_page",
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="page",
|
||||
name="moved_to_project",
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="pageversion",
|
||||
name="sub_pages_data",
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
]
|
||||
@@ -82,4 +82,4 @@ from .label import Label
|
||||
|
||||
from .device import Device, DeviceSession
|
||||
|
||||
from .sticky import Sticky
|
||||
from .sticky import Sticky
|
||||
@@ -50,6 +50,8 @@ class Page(BaseModel):
|
||||
projects = models.ManyToManyField(
|
||||
"db.Project", related_name="pages", through="db.ProjectPage"
|
||||
)
|
||||
moved_to_page = models.UUIDField(null=True, blank=True)
|
||||
moved_to_project = models.UUIDField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page"
|
||||
@@ -172,6 +174,7 @@ class PageVersion(BaseModel):
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
description_json = models.JSONField(default=dict, blank=True)
|
||||
sub_pages_data = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page Version"
|
||||
|
||||
@@ -109,5 +109,5 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"slug": "The workspace with the slug already exists"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Python imports
|
||||
import logging
|
||||
import time
|
||||
|
||||
# Django imports
|
||||
from django.http import HttpRequest
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.request import Request
|
||||
|
||||
# Module imports
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
|
||||
api_logger = logging.getLogger("plane.api")
|
||||
|
||||
|
||||
class RequestLoggerMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def _should_log_route(self, request: Request | HttpRequest) -> bool:
|
||||
"""
|
||||
Determines whether a route should be logged based on the request and status code.
|
||||
"""
|
||||
# Don't log health checks
|
||||
if request.path == "/" and request.method == "GET":
|
||||
return False
|
||||
return True
|
||||
|
||||
def __call__(self, request):
|
||||
# get the start time
|
||||
start_time = time.time()
|
||||
|
||||
# Get the response
|
||||
response = self.get_response(request)
|
||||
|
||||
# calculate the duration
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Check if logging is required
|
||||
log_true = self._should_log_route(request=request)
|
||||
|
||||
# If logging is not required, return the response
|
||||
if not log_true:
|
||||
return response
|
||||
|
||||
user_id = (
|
||||
request.user.id
|
||||
if getattr(request, "user")
|
||||
and getattr(request.user, "is_authenticated", False)
|
||||
else None
|
||||
)
|
||||
|
||||
user_agent = request.META.get("HTTP_USER_AGENT", "")
|
||||
|
||||
# Log the request information
|
||||
api_logger.info(
|
||||
f"{request.method} {request.get_full_path()} {response.status_code}",
|
||||
extra={
|
||||
"path": request.path,
|
||||
"method": request.method,
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": int(duration * 1000),
|
||||
"remote_addr": get_client_ip(request),
|
||||
"user_agent": user_agent,
|
||||
"user_id": user_id,
|
||||
},
|
||||
)
|
||||
|
||||
# return the response
|
||||
return response
|
||||
@@ -59,6 +59,7 @@ MIDDLEWARE = [
|
||||
"crum.CurrentRequestUserMiddleware",
|
||||
"django.middleware.gzip.GZipMiddleware",
|
||||
"plane.middleware.api_log_middleware.APITokenLogMiddleware",
|
||||
"plane.middleware.logger.RequestLoggerMiddleware",
|
||||
]
|
||||
|
||||
# Rest Framework settings
|
||||
|
||||
@@ -37,26 +37,31 @@ if not os.path.exists(LOG_DIR):
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"disable_existing_loggers": True,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
|
||||
"style": "{",
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
|
||||
"fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": "DEBUG",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
"formatter": "json",
|
||||
}
|
||||
},
|
||||
"loggers": {
|
||||
"django.request": {
|
||||
"plane.api": {"level": "INFO", "handlers": ["console"], "propagate": False},
|
||||
"plane.worker": {"level": "INFO", "handlers": ["console"], "propagate": False},
|
||||
"plane.exception": {
|
||||
"level": "ERROR",
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
"plane": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -26,11 +26,10 @@ if not os.path.exists(LOG_DIR):
|
||||
# Logging configuration
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"disable_existing_loggers": True,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
|
||||
"style": "{",
|
||||
"format": "%(asctime)s [%(process)d] %(levelname)s %(name)s: %(message)s"
|
||||
},
|
||||
"json": {
|
||||
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
|
||||
@@ -40,7 +39,7 @@ LOGGING = {
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
"formatter": "json",
|
||||
"level": "INFO",
|
||||
},
|
||||
"file": {
|
||||
@@ -59,15 +58,19 @@ LOGGING = {
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"django": {"handlers": ["console", "file"], "level": "INFO", "propagate": True},
|
||||
"django.request": {
|
||||
"handlers": ["console", "file"],
|
||||
"level": "INFO",
|
||||
"plane.api": {
|
||||
"level": "DEBUG" if DEBUG else "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane": {
|
||||
"plane.worker": {
|
||||
"level": "DEBUG" if DEBUG else "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.exception": {
|
||||
"level": "DEBUG" if DEBUG else "ERROR",
|
||||
"handlers": ["console", "file"],
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -41,4 +41,4 @@ class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest):
|
||||
response = self.client.post(
|
||||
url, {"name": "Plane", "slug": "pla-ne"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_410_GONE)
|
||||
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
|
||||
|
||||
@@ -8,8 +8,8 @@ from django.conf import settings
|
||||
|
||||
def log_exception(e):
|
||||
# Log the error
|
||||
logger = logging.getLogger("plane")
|
||||
logger.error(e)
|
||||
logger = logging.getLogger("plane.exception")
|
||||
logger.error(str(e))
|
||||
|
||||
if settings.DEBUG:
|
||||
# Print the traceback if in debug mode
|
||||
|
||||
@@ -43,7 +43,7 @@ scout-apm==3.1.0
|
||||
# xlsx generation
|
||||
openpyxl==3.1.2
|
||||
# logging
|
||||
python-json-logger==2.0.7
|
||||
python-json-logger==3.3.0
|
||||
# html parser
|
||||
beautifulsoup4==4.12.3
|
||||
# analytics
|
||||
|
||||
@@ -1,91 +1,97 @@
|
||||
import { TInboxDuplicateIssueDetails, TIssue } from "@plane/types";
|
||||
|
||||
export enum EInboxIssueCurrentTab {
|
||||
OPEN = "open",
|
||||
CLOSED = "closed",
|
||||
OPEN = "open",
|
||||
CLOSED = "closed",
|
||||
}
|
||||
|
||||
export enum EInboxIssueStatus {
|
||||
PENDING = -2,
|
||||
DECLINED = -1,
|
||||
SNOOZED = 0,
|
||||
ACCEPTED = 1,
|
||||
DUPLICATE = 2,
|
||||
PENDING = -2,
|
||||
DECLINED = -1,
|
||||
SNOOZED = 0,
|
||||
ACCEPTED = 1,
|
||||
DUPLICATE = 2,
|
||||
}
|
||||
|
||||
export enum EInboxIssueSource {
|
||||
IN_APP = "IN_APP",
|
||||
FORMS = "FORMS",
|
||||
EMAIL = "EMAIL",
|
||||
}
|
||||
|
||||
export type TInboxIssueCurrentTab = EInboxIssueCurrentTab;
|
||||
export type TInboxIssueStatus = EInboxIssueStatus;
|
||||
export type TInboxIssue = {
|
||||
id: string;
|
||||
status: TInboxIssueStatus;
|
||||
snoozed_till: Date | null;
|
||||
duplicate_to: string | undefined;
|
||||
source: string;
|
||||
issue: TIssue;
|
||||
created_by: string;
|
||||
duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined;
|
||||
id: string;
|
||||
status: TInboxIssueStatus;
|
||||
snoozed_till: Date | null;
|
||||
duplicate_to: string | undefined;
|
||||
source: EInboxIssueSource | undefined;
|
||||
issue: TIssue;
|
||||
created_by: string;
|
||||
duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined;
|
||||
};
|
||||
|
||||
export const INBOX_STATUS: {
|
||||
key: string;
|
||||
status: TInboxIssueStatus;
|
||||
i18n_title: string;
|
||||
i18n_description: () => string;
|
||||
key: string;
|
||||
status: TInboxIssueStatus;
|
||||
i18n_title: string;
|
||||
i18n_description: () => string;
|
||||
}[] = [
|
||||
{
|
||||
key: "pending",
|
||||
i18n_title: "inbox_issue.status.pending.title",
|
||||
status: EInboxIssueStatus.PENDING,
|
||||
i18n_description: () => `inbox_issue.status.pending.description`,
|
||||
},
|
||||
{
|
||||
key: "declined",
|
||||
i18n_title: "inbox_issue.status.declined.title",
|
||||
status: EInboxIssueStatus.DECLINED,
|
||||
i18n_description: () => `inbox_issue.status.declined.description`,
|
||||
},
|
||||
{
|
||||
key: "snoozed",
|
||||
i18n_title: "inbox_issue.status.snoozed.title",
|
||||
status: EInboxIssueStatus.SNOOZED,
|
||||
i18n_description: () => `inbox_issue.status.snoozed.description`,
|
||||
},
|
||||
{
|
||||
key: "accepted",
|
||||
i18n_title: "inbox_issue.status.accepted.title",
|
||||
status: EInboxIssueStatus.ACCEPTED,
|
||||
i18n_description: () => `inbox_issue.status.accepted.description`,
|
||||
},
|
||||
{
|
||||
key: "duplicate",
|
||||
i18n_title: "inbox_issue.status.duplicate.title",
|
||||
status: EInboxIssueStatus.DUPLICATE,
|
||||
i18n_description: () => `inbox_issue.status.duplicate.description`,
|
||||
},
|
||||
{
|
||||
key: "pending",
|
||||
i18n_title: "inbox_issue.status.pending.title",
|
||||
status: EInboxIssueStatus.PENDING,
|
||||
i18n_description: () => `inbox_issue.status.pending.description`,
|
||||
},
|
||||
{
|
||||
key: "declined",
|
||||
i18n_title: "inbox_issue.status.declined.title",
|
||||
status: EInboxIssueStatus.DECLINED,
|
||||
i18n_description: () => `inbox_issue.status.declined.description`,
|
||||
},
|
||||
{
|
||||
key: "snoozed",
|
||||
i18n_title: "inbox_issue.status.snoozed.title",
|
||||
status: EInboxIssueStatus.SNOOZED,
|
||||
i18n_description: () => `inbox_issue.status.snoozed.description`,
|
||||
},
|
||||
{
|
||||
key: "accepted",
|
||||
i18n_title: "inbox_issue.status.accepted.title",
|
||||
status: EInboxIssueStatus.ACCEPTED,
|
||||
i18n_description: () => `inbox_issue.status.accepted.description`,
|
||||
},
|
||||
{
|
||||
key: "duplicate",
|
||||
i18n_title: "inbox_issue.status.duplicate.title",
|
||||
status: EInboxIssueStatus.DUPLICATE,
|
||||
i18n_description: () => `inbox_issue.status.duplicate.description`,
|
||||
},
|
||||
];
|
||||
|
||||
export const INBOX_ISSUE_ORDER_BY_OPTIONS = [
|
||||
{
|
||||
key: "issue__created_at",
|
||||
i18n_label: "inbox_issue.order_by.created_at",
|
||||
},
|
||||
{
|
||||
key: "issue__updated_at",
|
||||
i18n_label: "inbox_issue.order_by.updated_at",
|
||||
},
|
||||
{
|
||||
key: "issue__sequence_id",
|
||||
i18n_label: "inbox_issue.order_by.id",
|
||||
},
|
||||
{
|
||||
key: "issue__created_at",
|
||||
i18n_label: "inbox_issue.order_by.created_at",
|
||||
},
|
||||
{
|
||||
key: "issue__updated_at",
|
||||
i18n_label: "inbox_issue.order_by.updated_at",
|
||||
},
|
||||
{
|
||||
key: "issue__sequence_id",
|
||||
i18n_label: "inbox_issue.order_by.id",
|
||||
},
|
||||
];
|
||||
|
||||
export const INBOX_ISSUE_SORT_BY_OPTIONS = [
|
||||
{
|
||||
key: "asc",
|
||||
i18n_label: "common.sort.asc",
|
||||
},
|
||||
{
|
||||
key: "desc",
|
||||
i18n_label: "common.sort.desc",
|
||||
},
|
||||
{
|
||||
key: "asc",
|
||||
i18n_label: "common.sort.asc",
|
||||
},
|
||||
{
|
||||
key: "desc",
|
||||
i18n_label: "common.sort.desc",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -14,6 +14,7 @@ export * from "./state";
|
||||
export * from "./swr";
|
||||
export * from "./tab-indices";
|
||||
export * from "./user";
|
||||
export * from "./payment";
|
||||
export * from "./workspace";
|
||||
export * from "./stickies";
|
||||
export * from "./cycle";
|
||||
@@ -30,3 +31,4 @@ export * from "./spreadsheet";
|
||||
export * from "./dashboard";
|
||||
export * from "./page";
|
||||
export * from "./emoji";
|
||||
export * from "./subscription";
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { IPaymentProduct, TBillingFrequency, TProductBillingFrequency } from "@plane/types";
|
||||
|
||||
/**
|
||||
* Enum representing different product subscription types
|
||||
*/
|
||||
export enum EProductSubscriptionEnum {
|
||||
FREE = "FREE",
|
||||
ONE = "ONE",
|
||||
PRO = "PRO",
|
||||
BUSINESS = "BUSINESS",
|
||||
ENTERPRISE = "ENTERPRISE",
|
||||
}
|
||||
|
||||
/**
|
||||
* Default billing frequency for each product subscription type
|
||||
*/
|
||||
export const DEFAULT_PRODUCT_BILLING_FREQUENCY: TProductBillingFrequency = {
|
||||
[EProductSubscriptionEnum.FREE]: undefined,
|
||||
[EProductSubscriptionEnum.ONE]: undefined,
|
||||
[EProductSubscriptionEnum.PRO]: "month",
|
||||
[EProductSubscriptionEnum.BUSINESS]: "month",
|
||||
[EProductSubscriptionEnum.ENTERPRISE]: "month",
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscription types that support billing frequency toggle (monthly/yearly)
|
||||
*/
|
||||
export const SUBSCRIPTION_WITH_BILLING_FREQUENCY = [
|
||||
EProductSubscriptionEnum.PRO,
|
||||
EProductSubscriptionEnum.BUSINESS,
|
||||
EProductSubscriptionEnum.ENTERPRISE,
|
||||
];
|
||||
|
||||
/**
|
||||
* Mapping of product subscription types to their respective payment product details
|
||||
* Used to provide information about each product's pricing and features
|
||||
*/
|
||||
export const PLANE_COMMUNITY_PRODUCTS: Record<string, IPaymentProduct> = {
|
||||
[EProductSubscriptionEnum.PRO]: {
|
||||
id: EProductSubscriptionEnum.PRO,
|
||||
name: "Plane Pro",
|
||||
description:
|
||||
"More views, more cycles powers, more pages features, new reports, and better dashboards are waiting to be unlocked.",
|
||||
type: "PRO",
|
||||
prices: [
|
||||
{
|
||||
id: `price_monthly_${EProductSubscriptionEnum.PRO}`,
|
||||
unit_amount: 800,
|
||||
recurring: "month",
|
||||
currency: "usd",
|
||||
workspace_amount: 800,
|
||||
product: EProductSubscriptionEnum.PRO,
|
||||
},
|
||||
{
|
||||
id: `price_yearly_${EProductSubscriptionEnum.PRO}`,
|
||||
unit_amount: 7200,
|
||||
recurring: "year",
|
||||
currency: "usd",
|
||||
workspace_amount: 7200,
|
||||
product: EProductSubscriptionEnum.PRO,
|
||||
},
|
||||
],
|
||||
payment_quantity: 1,
|
||||
is_active: true,
|
||||
},
|
||||
[EProductSubscriptionEnum.BUSINESS]: {
|
||||
id: EProductSubscriptionEnum.BUSINESS,
|
||||
name: "Plane Business",
|
||||
description:
|
||||
"The earliest packaging of Business at $10 a seat a month billed annually, $12 a seat a month billed monthly for Plane Cloud",
|
||||
type: "BUSINESS",
|
||||
prices: [
|
||||
{
|
||||
id: `price_yearly_${EProductSubscriptionEnum.BUSINESS}`,
|
||||
unit_amount: 0,
|
||||
recurring: "year",
|
||||
currency: "usd",
|
||||
workspace_amount: 0,
|
||||
product: EProductSubscriptionEnum.BUSINESS,
|
||||
},
|
||||
{
|
||||
id: `price_monthly_${EProductSubscriptionEnum.BUSINESS}`,
|
||||
unit_amount: 0,
|
||||
recurring: "month",
|
||||
currency: "usd",
|
||||
workspace_amount: 0,
|
||||
product: EProductSubscriptionEnum.BUSINESS,
|
||||
},
|
||||
],
|
||||
payment_quantity: 1,
|
||||
is_active: false,
|
||||
},
|
||||
[EProductSubscriptionEnum.ENTERPRISE]: {
|
||||
id: EProductSubscriptionEnum.ENTERPRISE,
|
||||
name: "Plane Enterprise",
|
||||
description: "",
|
||||
type: "ENTERPRISE",
|
||||
prices: [
|
||||
{
|
||||
id: `price_yearly_${EProductSubscriptionEnum.ENTERPRISE}`,
|
||||
unit_amount: 0,
|
||||
recurring: "year",
|
||||
currency: "usd",
|
||||
workspace_amount: 0,
|
||||
product: EProductSubscriptionEnum.ENTERPRISE,
|
||||
},
|
||||
{
|
||||
id: `price_monthly_${EProductSubscriptionEnum.ENTERPRISE}`,
|
||||
unit_amount: 0,
|
||||
recurring: "month",
|
||||
currency: "usd",
|
||||
workspace_amount: 0,
|
||||
product: EProductSubscriptionEnum.ENTERPRISE,
|
||||
},
|
||||
],
|
||||
payment_quantity: 1,
|
||||
is_active: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* URL for the "Talk to Sales" page where users can contact sales team
|
||||
*/
|
||||
export const TALK_TO_SALES_URL = "https://plane.so/talk-to-sales";
|
||||
|
||||
/**
|
||||
* Mapping of subscription types to their respective upgrade/redirection URLs based on billing frequency
|
||||
* Used for self-hosted installations to redirect users to appropriate upgrade pages
|
||||
*/
|
||||
export const SUBSCRIPTION_REDIRECTION_URLS: Record<EProductSubscriptionEnum, Record<TBillingFrequency, string>> = {
|
||||
[EProductSubscriptionEnum.FREE]: {
|
||||
month: TALK_TO_SALES_URL,
|
||||
year: TALK_TO_SALES_URL,
|
||||
},
|
||||
[EProductSubscriptionEnum.ONE]: {
|
||||
month: TALK_TO_SALES_URL,
|
||||
year: TALK_TO_SALES_URL,
|
||||
},
|
||||
[EProductSubscriptionEnum.PRO]: {
|
||||
month: "https://app.plane.so/upgrade/pro/self-hosted?plan=month",
|
||||
year: "https://app.plane.so/upgrade/pro/self-hosted?plan=year",
|
||||
},
|
||||
[EProductSubscriptionEnum.BUSINESS]: {
|
||||
month: TALK_TO_SALES_URL,
|
||||
year: TALK_TO_SALES_URL,
|
||||
},
|
||||
[EProductSubscriptionEnum.ENTERPRISE]: {
|
||||
month: TALK_TO_SALES_URL,
|
||||
year: TALK_TO_SALES_URL,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping of subscription types to their respective marketing webpage URLs
|
||||
* Used to direct users to learn more about each plan's features and pricing
|
||||
*/
|
||||
export const SUBSCRIPTION_WEBPAGE_URLS: Record<EProductSubscriptionEnum, string> = {
|
||||
[EProductSubscriptionEnum.FREE]: TALK_TO_SALES_URL,
|
||||
[EProductSubscriptionEnum.ONE]: TALK_TO_SALES_URL,
|
||||
[EProductSubscriptionEnum.PRO]: "https://plane.so/pro",
|
||||
[EProductSubscriptionEnum.BUSINESS]: "https://plane.so/business",
|
||||
[EProductSubscriptionEnum.ENTERPRISE]: "https://plane.so/business",
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
export const ENTERPRISE_PLAN_FEATURES = [
|
||||
"Private + managed deployments",
|
||||
"GAC",
|
||||
"LDAP support",
|
||||
"Databases + Formulas",
|
||||
"Unlimited and full Automation Flows",
|
||||
"Full-suite professional services",
|
||||
];
|
||||
|
||||
export const BUSINESS_PLAN_FEATURES = [
|
||||
"Project Templates",
|
||||
"Workflows + Approvals",
|
||||
"Decision + Loops Automation",
|
||||
"Custom Reports",
|
||||
"Nested Pages",
|
||||
"Intake Forms",
|
||||
];
|
||||
|
||||
export const PRO_PLAN_FEATURES = [
|
||||
"Dashboards + Reports",
|
||||
"Full Time Tracking + Bulk Ops",
|
||||
"Teamspaces",
|
||||
"Trigger And Action",
|
||||
"Wikis",
|
||||
"Popular integrations",
|
||||
];
|
||||
|
||||
export const ONE_PLAN_FEATURES = [
|
||||
"OIDC + SAML for SSO",
|
||||
"Active Cycles",
|
||||
"Real-time collab + public views and page",
|
||||
"Link pages in issues and vice-versa",
|
||||
"Time-tracking + limited bulk ops",
|
||||
"Docker, Kubernetes and more",
|
||||
];
|
||||
|
||||
export const FREE_PLAN_UPGRADE_FEATURES = [
|
||||
"OIDC + SAML for SSO",
|
||||
"Time Tracking and Bulk Ops",
|
||||
"Integrations",
|
||||
"Public Views and Pages",
|
||||
];
|
||||
@@ -7,7 +7,7 @@ import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custo
|
||||
import { TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
const { getAssetSrc } = props;
|
||||
const { getAssetSrc, restore: restoreImageFn } = props;
|
||||
|
||||
return Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
@@ -66,6 +66,9 @@ export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => async () => await getAssetSrc(path),
|
||||
restoreImage: (src) => async () => {
|
||||
await restoreImageFn(src);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -151,11 +151,11 @@
|
||||
/* end font size and style */
|
||||
|
||||
/* layout config */
|
||||
#page-header-container {
|
||||
container-name: page-header-container;
|
||||
#page-toolbar-container {
|
||||
container-name: page-toolbar-container;
|
||||
container-type: inline-size;
|
||||
|
||||
.page-header-content {
|
||||
.page-toolbar-content {
|
||||
--header-width: var(--normal-content-width);
|
||||
|
||||
&.wide-layout {
|
||||
@@ -186,23 +186,23 @@
|
||||
}
|
||||
|
||||
/* keep a static padding of 96px for wide layouts for container width >912px and <1344px */
|
||||
@container page-header-container (min-width: 912px) and (max-width: 1344px) {
|
||||
.page-header-content.wide-layout {
|
||||
@container page-toolbar-container (min-width: 912px) and (max-width: 1344px) {
|
||||
.page-toolbar-content.wide-layout {
|
||||
padding-left: var(--wide-content-margin) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* keep a static padding of 96px for wide layouts for container width <912px */
|
||||
@container page-header-container (max-width: 912) {
|
||||
.page-header-content.wide-layout {
|
||||
@container page-toolbar-container (max-width: 912) {
|
||||
.page-toolbar-content.wide-layout {
|
||||
padding-left: var(--wide-content-margin) !important;
|
||||
}
|
||||
}
|
||||
/* end layout config */
|
||||
|
||||
/* keep a static padding of 20px for wide layouts for container width <760px */
|
||||
@container page-header-container (max-width: 760px) {
|
||||
.page-header-content {
|
||||
@container page-toolbar-container (max-width: 760px) {
|
||||
.page-toolbar-content {
|
||||
padding-left: var(--normal-content-margin) !important;
|
||||
}
|
||||
}
|
||||
@@ -211,7 +211,7 @@
|
||||
/* keep a static padding of 96px for wide layouts for container width >912px and <1344px */
|
||||
@container page-content-container (min-width: 912px) and (max-width: 1344px) {
|
||||
.editor-container.wide-layout,
|
||||
.page-title-container {
|
||||
.page-header-container {
|
||||
padding-left: var(--wide-content-margin);
|
||||
padding-right: var(--wide-content-margin);
|
||||
}
|
||||
@@ -220,7 +220,7 @@
|
||||
/* keep a static padding of 20px for wide layouts for container width <912px */
|
||||
@container page-content-container (max-width: 912px) {
|
||||
.editor-container.wide-layout,
|
||||
.page-title-container {
|
||||
.page-header-container {
|
||||
padding-left: var(--normal-content-margin);
|
||||
padding-right: var(--normal-content-margin);
|
||||
}
|
||||
@@ -229,7 +229,7 @@
|
||||
/* keep a static padding of 20px for normal layouts for container width <760px */
|
||||
@container page-content-container (max-width: 760px) {
|
||||
.editor-container:not(.wide-layout),
|
||||
.page-title-container {
|
||||
.page-header-container {
|
||||
padding-left: var(--normal-content-margin);
|
||||
padding-right: var(--normal-content-margin);
|
||||
}
|
||||
|
||||
@@ -867,7 +867,8 @@
|
||||
"deleting": "Mazání",
|
||||
"pending": "Čekající",
|
||||
"invite": "Pozvat",
|
||||
"view": "Pohled"
|
||||
"view": "Pohled",
|
||||
"deactivated_user": "Deaktivovaný uživatel"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1738,12 +1739,15 @@
|
||||
"title": "Povolit odhady pro můj projekt",
|
||||
"description": "Pomáhají vám komunikovat složitost a pracovní zátěž týmu.",
|
||||
"no_estimate": "Bez odhadu",
|
||||
"new": "Nový systém odhadů",
|
||||
"create": {
|
||||
"custom": "Vlastní",
|
||||
"start_from_scratch": "Začít od nuly",
|
||||
"choose_template": "Vybrat šablonu",
|
||||
"choose_estimate_system": "Vybrat systém odhadů",
|
||||
"enter_estimate_point": "Zadat odhad"
|
||||
"enter_estimate_point": "Zadat odhad",
|
||||
"step": "Krok {step} z {total}",
|
||||
"label": "Vytvořit odhad"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1792,6 +1796,25 @@
|
||||
"already_exists": "Hodnota odhadu již existuje.",
|
||||
"unsaved_changes": "Máte neuložené změny. Před kliknutím na hotovo je prosím uložte",
|
||||
"remove_empty": "Odhad nemůže být prázdný. Zadejte hodnotu do každého pole nebo odstraňte ta, pro která nemáte hodnoty."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Body",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Lineární",
|
||||
"squares": "Čtverce",
|
||||
"custom": "Vlastní"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Kategorie",
|
||||
"t_shirt_sizes": "Velikosti triček",
|
||||
"easy_to_hard": "Od snadného po těžké",
|
||||
"custom": "Vlastní"
|
||||
},
|
||||
"time": {
|
||||
"label": "Čas",
|
||||
"hours": "Hodiny"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -862,7 +862,8 @@
|
||||
"deleting": "Wird gelöscht",
|
||||
"pending": "Ausstehend",
|
||||
"invite": "Einladen",
|
||||
"view": "Ansicht"
|
||||
"view": "Ansicht",
|
||||
"deactivated_user": "Deaktivierter Benutzer"
|
||||
},
|
||||
"chart": {
|
||||
"x_axis": "X-Achse",
|
||||
@@ -1714,12 +1715,15 @@
|
||||
"title": "Schätzungen für mein Projekt aktivieren",
|
||||
"description": "Sie helfen dir, die Komplexität und Arbeitsbelastung des Teams zu kommunizieren.",
|
||||
"no_estimate": "Keine Schätzung",
|
||||
"new": "Neues Schätzungssystem",
|
||||
"create": {
|
||||
"custom": "Benutzerdefiniert",
|
||||
"start_from_scratch": "Von Grund auf neu",
|
||||
"choose_template": "Vorlage wählen",
|
||||
"choose_estimate_system": "Schätzungssystem wählen",
|
||||
"enter_estimate_point": "Schätzung eingeben"
|
||||
"enter_estimate_point": "Schätzung eingeben",
|
||||
"step": "Schritt {step} von {total}",
|
||||
"label": "Schätzung erstellen"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1768,6 +1772,25 @@
|
||||
"already_exists": "Der Schätzungswert existiert bereits.",
|
||||
"unsaved_changes": "Du hast ungespeicherte Änderungen. Bitte speichere sie, bevor du auf Fertig klickst",
|
||||
"remove_empty": "Die Schätzung darf nicht leer sein. Gib einen Wert in jedes Feld ein oder entferne die Felder, für die du keine Werte hast."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Punkte",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Linear",
|
||||
"squares": "Quadrate",
|
||||
"custom": "Benutzerdefiniert"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Kategorien",
|
||||
"t_shirt_sizes": "T-Shirt-Größen",
|
||||
"easy_to_hard": "Einfach bis schwer",
|
||||
"custom": "Benutzerdefiniert"
|
||||
},
|
||||
"time": {
|
||||
"label": "Zeit",
|
||||
"hours": "Stunden"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -702,7 +702,8 @@
|
||||
"deleting": "Deleting",
|
||||
"pending": "Pending",
|
||||
"invite": "Invite",
|
||||
"view": "View"
|
||||
"view": "View",
|
||||
"deactivated_user": "Deactivated user"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1573,12 +1574,15 @@
|
||||
"title": "Enable estimates for my project",
|
||||
"description": "They help you in communicating complexity and workload of the team.",
|
||||
"no_estimate": "No estimate",
|
||||
"new": "New estimate system",
|
||||
"create": {
|
||||
"custom": "Custom",
|
||||
"start_from_scratch": "Start from scratch",
|
||||
"choose_template": "Choose a template",
|
||||
"choose_estimate_system": "Choose an estimate system",
|
||||
"enter_estimate_point": "Enter estimate"
|
||||
"enter_estimate_point": "Enter estimate",
|
||||
"step": "Step {step} of {total}",
|
||||
"label": "Create estimate"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1627,6 +1631,25 @@
|
||||
"already_exists": "Estimate value already exists.",
|
||||
"unsaved_changes": "You have some unsaved changes, Please save them before clicking on done",
|
||||
"remove_empty": "Estimate can't be empty. Enter a value in each field or remove those you don't have values for."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Points",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Linear",
|
||||
"squares": "Squares",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Categories",
|
||||
"t_shirt_sizes": "T-Shirt Sizes",
|
||||
"easy_to_hard": "Easy to hard",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"time": {
|
||||
"label": "Time",
|
||||
"hours": "Hours"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -872,7 +872,8 @@
|
||||
"deleting": "Eliminando",
|
||||
"pending": "Pendiente",
|
||||
"invite": "Invitar",
|
||||
"view": "Ver"
|
||||
"view": "Ver",
|
||||
"deactivated_user": "Usuario desactivado"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1742,12 +1743,15 @@
|
||||
"title": "Activar estimaciones para mi proyecto",
|
||||
"description": "Te ayudan a comunicar la complejidad y la carga de trabajo del equipo.",
|
||||
"no_estimate": "Sin estimación",
|
||||
"new": "Nuevo sistema de estimación",
|
||||
"create": {
|
||||
"custom": "Personalizado",
|
||||
"start_from_scratch": "Comenzar desde cero",
|
||||
"choose_template": "Elegir una plantilla",
|
||||
"choose_estimate_system": "Elegir un sistema de estimación",
|
||||
"enter_estimate_point": "Ingresar estimación"
|
||||
"enter_estimate_point": "Ingresar estimación",
|
||||
"step": "Paso {step} de {total}",
|
||||
"label": "Crear estimación"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1796,6 +1800,25 @@
|
||||
"already_exists": "El valor de la estimación ya existe.",
|
||||
"unsaved_changes": "Tienes cambios sin guardar. Por favor guárdalos antes de hacer clic en Hecho",
|
||||
"remove_empty": "La estimación no puede estar vacía. Ingresa un valor en cada campo o elimina aquellos para los que no tienes valores."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Puntos",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Lineal",
|
||||
"squares": "Cuadrados",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Categorías",
|
||||
"t_shirt_sizes": "Tallas de camiseta",
|
||||
"easy_to_hard": "Fácil a difícil",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tiempo",
|
||||
"hours": "Horas"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -870,7 +870,8 @@
|
||||
"deleting": "Suppression",
|
||||
"pending": "En attente",
|
||||
"invite": "Inviter",
|
||||
"view": "Afficher"
|
||||
"view": "Afficher",
|
||||
"deactivated_user": "Utilisateur désactivé"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1740,12 +1741,15 @@
|
||||
"title": "Activer les estimations pour mon projet",
|
||||
"description": "Elles vous aident à communiquer la complexité et la charge de travail de l'équipe.",
|
||||
"no_estimate": "Sans estimation",
|
||||
"new": "Nouveau système d'estimation",
|
||||
"create": {
|
||||
"custom": "Personnalisé",
|
||||
"start_from_scratch": "Commencer depuis zéro",
|
||||
"choose_template": "Choisir un modèle",
|
||||
"choose_estimate_system": "Choisir un système d'estimation",
|
||||
"enter_estimate_point": "Saisir une estimation"
|
||||
"enter_estimate_point": "Saisir une estimation",
|
||||
"step": "Étape {step} de {total}",
|
||||
"label": "Créer une estimation"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1794,6 +1798,25 @@
|
||||
"already_exists": "La valeur de l'estimation existe déjà.",
|
||||
"unsaved_changes": "Vous avez des modifications non enregistrées. Veuillez les enregistrer avant de cliquer sur Terminé",
|
||||
"remove_empty": "L'estimation ne peut pas être vide. Saisissez une valeur dans chaque champ ou supprimez ceux pour lesquels vous n'avez pas de valeurs."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Points",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Linéaire",
|
||||
"squares": "Carrés",
|
||||
"custom": "Personnalisé"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Catégories",
|
||||
"t_shirt_sizes": "Tailles de T-Shirt",
|
||||
"easy_to_hard": "Facile à difficile",
|
||||
"custom": "Personnalisé"
|
||||
},
|
||||
"time": {
|
||||
"label": "Temps",
|
||||
"hours": "Heures"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -869,7 +869,8 @@
|
||||
"deleting": "Menghapus",
|
||||
"pending": "Tertunda",
|
||||
"invite": "Undang",
|
||||
"view": "Lihat"
|
||||
"view": "Lihat",
|
||||
"deactivated_user": "Pengguna dinonaktifkan"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1740,12 +1741,15 @@
|
||||
"title": "Aktifkan perkiraan untuk proyek saya",
|
||||
"description": "Ini membantu Anda dalam mengkomunikasikan kompleksitas dan beban kerja tim.",
|
||||
"no_estimate": "Tidak ada perkiraan",
|
||||
"new": "Sistem perkiraan baru",
|
||||
"create": {
|
||||
"custom": "Kustom",
|
||||
"start_from_scratch": "Mulai dari awal",
|
||||
"choose_template": "Pilih template",
|
||||
"choose_estimate_system": "Pilih sistem perkiraan",
|
||||
"enter_estimate_point": "Masukkan perkiraan"
|
||||
"enter_estimate_point": "Masukkan perkiraan",
|
||||
"step": "Langkah {step} dari {total}",
|
||||
"label": "Buat perkiraan"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1794,6 +1798,25 @@
|
||||
"already_exists": "Nilai perkiraan sudah ada.",
|
||||
"unsaved_changes": "Anda memiliki beberapa perubahan yang belum disimpan, Harap simpan sebelum mengklik selesai",
|
||||
"remove_empty": "Perkiraan tidak boleh kosong. Masukkan nilai di setiap bidang atau hapus yang tidak memiliki nilai."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Poin",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Linear",
|
||||
"squares": "Kuadrat",
|
||||
"custom": "Kustom"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Kategori",
|
||||
"t_shirt_sizes": "Ukuran Baju",
|
||||
"easy_to_hard": "Mudah ke sulit",
|
||||
"custom": "Kustom"
|
||||
},
|
||||
"time": {
|
||||
"label": "Waktu",
|
||||
"hours": "Jam"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -868,7 +868,8 @@
|
||||
"deleting": "Eliminazione in corso",
|
||||
"pending": "In sospeso",
|
||||
"invite": "Invita",
|
||||
"view": "Visualizza"
|
||||
"view": "Visualizza",
|
||||
"deactivated_user": "Utente disattivato"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1739,12 +1740,15 @@
|
||||
"title": "Abilita le stime per il mio progetto",
|
||||
"description": "Ti aiutano a comunicare la complessità e il carico di lavoro del team.",
|
||||
"no_estimate": "Nessuna stima",
|
||||
"new": "Nuovo sistema di stima",
|
||||
"create": {
|
||||
"custom": "Personalizzato",
|
||||
"start_from_scratch": "Inizia da zero",
|
||||
"choose_template": "Scegli un modello",
|
||||
"choose_estimate_system": "Scegli un sistema di stima",
|
||||
"enter_estimate_point": "Inserisci stima"
|
||||
"enter_estimate_point": "Inserisci stima",
|
||||
"step": "Passo {step} di {total}",
|
||||
"label": "Crea stima"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1793,6 +1797,25 @@
|
||||
"already_exists": "Il valore della stima esiste già.",
|
||||
"unsaved_changes": "Hai delle modifiche non salvate. Salva prima di cliccare su Fatto",
|
||||
"remove_empty": "La stima non può essere vuota. Inserisci un valore in ogni campo o rimuovi quelli per cui non hai valori."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Punti",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Lineare",
|
||||
"squares": "Quadrati",
|
||||
"custom": "Personalizzato"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Categorie",
|
||||
"t_shirt_sizes": "Taglie T-Shirt",
|
||||
"easy_to_hard": "Da facile a difficile",
|
||||
"custom": "Personalizzato"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tempo",
|
||||
"hours": "Ore"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -870,7 +870,8 @@
|
||||
"deleting": "デリーティング",
|
||||
"pending": "保留中",
|
||||
"invite": "招待",
|
||||
"view": "ビュー"
|
||||
"view": "ビュー",
|
||||
"deactivated_user": "無効化されたユーザー"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1740,12 +1741,15 @@
|
||||
"title": "プロジェクトの見積もりを有効にする",
|
||||
"description": "チームの複雑さと作業負荷を伝えるのに役立ちます。",
|
||||
"no_estimate": "見積もりなし",
|
||||
"new": "新しい見積もりシステム",
|
||||
"create": {
|
||||
"custom": "カスタム",
|
||||
"start_from_scratch": "最初から開始",
|
||||
"choose_template": "テンプレートを選択",
|
||||
"choose_estimate_system": "見積もりシステムを選択",
|
||||
"enter_estimate_point": "見積もりを入力"
|
||||
"enter_estimate_point": "見積もりを入力",
|
||||
"step": "ステップ {step} の {total}",
|
||||
"label": "見積もりを作成"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1794,6 +1798,25 @@
|
||||
"already_exists": "見積もり値は既に存在します。",
|
||||
"unsaved_changes": "未保存の変更があります。完了をクリックする前に保存してください",
|
||||
"remove_empty": "見積もりは空にできません。各フィールドに値を入力するか、値がないフィールドを削除してください。"
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "ポイント",
|
||||
"fibonacci": "フィボナッチ",
|
||||
"linear": "リニア",
|
||||
"squares": "二乗",
|
||||
"custom": "カスタム"
|
||||
},
|
||||
"categories": {
|
||||
"label": "カテゴリー",
|
||||
"t_shirt_sizes": "Tシャツサイズ",
|
||||
"easy_to_hard": "簡単から難しい",
|
||||
"custom": "カスタム"
|
||||
},
|
||||
"time": {
|
||||
"label": "時間",
|
||||
"hours": "時間"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -871,7 +871,8 @@
|
||||
"deleting": "삭제 중",
|
||||
"pending": "보류 중",
|
||||
"invite": "초대",
|
||||
"view": "보기"
|
||||
"view": "보기",
|
||||
"deactivated_user": "비활성화된 사용자"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1742,12 +1743,15 @@
|
||||
"title": "프로젝트 추정 활성화",
|
||||
"description": "팀의 복잡성과 작업량을 전달하는 데 도움이 됩니다.",
|
||||
"no_estimate": "추정 없음",
|
||||
"new": "새 추정 시스템",
|
||||
"create": {
|
||||
"custom": "사용자 지정",
|
||||
"start_from_scratch": "처음부터 시작",
|
||||
"choose_template": "템플릿 선택",
|
||||
"choose_estimate_system": "추정 시스템 선택",
|
||||
"enter_estimate_point": "추정 입력"
|
||||
"enter_estimate_point": "추정 입력",
|
||||
"step": "단계 {step}/{total}",
|
||||
"label": "추정 생성"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1796,6 +1800,25 @@
|
||||
"already_exists": "추정 값이 이미 존재합니다.",
|
||||
"unsaved_changes": "저장되지 않은 변경 사항이 있습니다. 완료를 클릭하기 전에 저장하세요",
|
||||
"remove_empty": "추정은 비어있을 수 없습니다. 각 필드에 값을 입력하거나 값이 없는 필드를 제거하세요."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "포인트",
|
||||
"fibonacci": "피보나치",
|
||||
"linear": "선형",
|
||||
"squares": "제곱",
|
||||
"custom": "사용자 정의"
|
||||
},
|
||||
"categories": {
|
||||
"label": "카테고리",
|
||||
"t_shirt_sizes": "티셔츠 사이즈",
|
||||
"easy_to_hard": "쉬움에서 어려움",
|
||||
"custom": "사용자 정의"
|
||||
},
|
||||
"time": {
|
||||
"label": "시간",
|
||||
"hours": "시간"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -865,7 +865,8 @@
|
||||
"deleting": "Usuwanie",
|
||||
"pending": "Oczekujące",
|
||||
"invite": "Zaproś",
|
||||
"view": "Widok"
|
||||
"view": "Widok",
|
||||
"deactivated_user": "Dezaktywowany użytkownik"
|
||||
},
|
||||
"chart": {
|
||||
"x_axis": "Oś X",
|
||||
@@ -1717,12 +1718,15 @@
|
||||
"title": "Włącz szacunki dla mojego projektu",
|
||||
"description": "Pomagają w komunikacji o złożoności i obciążeniu zespołu.",
|
||||
"no_estimate": "Bez szacunku",
|
||||
"new": "Nowy system szacowania",
|
||||
"create": {
|
||||
"custom": "Niestandardowy",
|
||||
"start_from_scratch": "Zacznij od zera",
|
||||
"choose_template": "Wybierz szablon",
|
||||
"choose_estimate_system": "Wybierz system szacowania",
|
||||
"enter_estimate_point": "Wprowadź punkt szacunkowy"
|
||||
"enter_estimate_point": "Wprowadź punkt szacunkowy",
|
||||
"step": "Krok {step} z {total}",
|
||||
"label": "Utwórz szacunek"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1771,6 +1775,25 @@
|
||||
"already_exists": "Wartość szacunku już istnieje.",
|
||||
"unsaved_changes": "Masz niezapisane zmiany. Zapisz je przed kliknięciem 'gotowe'",
|
||||
"remove_empty": "Szacunek nie może być pusty. Wprowadź wartość w każde pole lub usuń te, dla których nie masz wartości."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Punkty",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Liniowy",
|
||||
"squares": "Kwadraty",
|
||||
"custom": "Własny"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Kategorie",
|
||||
"t_shirt_sizes": "Rozmiary koszulek",
|
||||
"easy_to_hard": "Od łatwego do trudnego",
|
||||
"custom": "Własne"
|
||||
},
|
||||
"time": {
|
||||
"label": "Czas",
|
||||
"hours": "Godziny"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -871,7 +871,8 @@
|
||||
"deleting": "Excluindo",
|
||||
"pending": "Pendente",
|
||||
"invite": "Convidar",
|
||||
"view": "Visualizar"
|
||||
"view": "Visualizar",
|
||||
"deactivated_user": "Usuário desativado"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1742,12 +1743,15 @@
|
||||
"title": "Habilitar estimativas para meu projeto",
|
||||
"description": "Elas ajudam você a comunicar a complexidade e a carga de trabalho da equipe.",
|
||||
"no_estimate": "Sem estimativa",
|
||||
"new": "Novo sistema de estimativa",
|
||||
"create": {
|
||||
"custom": "Personalizado",
|
||||
"start_from_scratch": "Começar do zero",
|
||||
"choose_template": "Escolher um modelo",
|
||||
"choose_estimate_system": "Escolher um sistema de estimativa",
|
||||
"enter_estimate_point": "Inserir estimativa"
|
||||
"enter_estimate_point": "Inserir estimativa",
|
||||
"step": "Passo {step} de {total}",
|
||||
"label": "Criar estimativa"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1796,6 +1800,25 @@
|
||||
"already_exists": "O valor da estimativa já existe.",
|
||||
"unsaved_changes": "Você tem algumas alterações não salvas. Por favor, salve-as antes de clicar em concluir",
|
||||
"remove_empty": "A estimativa não pode estar vazia. Insira um valor em cada campo ou remova aqueles para os quais você não tem valores."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Pontos",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Linear",
|
||||
"squares": "Quadrados",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Categorias",
|
||||
"t_shirt_sizes": "Tamanhos de Camiseta",
|
||||
"easy_to_hard": "Fácil a difícil",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tempo",
|
||||
"hours": "Horas"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -869,7 +869,8 @@
|
||||
"deleting": "Se șterge",
|
||||
"pending": "În așteptare",
|
||||
"invite": "Invită",
|
||||
"view": "Vizualizează"
|
||||
"view": "Vizualizează",
|
||||
"deactivated_user": "Utilizator dezactivat"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1740,12 +1741,15 @@
|
||||
"title": "Activează estimările pentru proiectul meu",
|
||||
"description": "Te ajută să comunici complexitatea și volumul de muncă al echipei.",
|
||||
"no_estimate": "Fără estimare",
|
||||
"new": "Noul sistem de estimare",
|
||||
"create": {
|
||||
"custom": "Personalizat",
|
||||
"start_from_scratch": "Începe de la zero",
|
||||
"choose_template": "Alege un șablon",
|
||||
"choose_estimate_system": "Alege un sistem de estimare",
|
||||
"enter_estimate_point": "Introdu estimarea"
|
||||
"enter_estimate_point": "Introdu estimarea",
|
||||
"step": "Pasul {step} de {total}",
|
||||
"label": "Creează estimare"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1794,6 +1798,25 @@
|
||||
"already_exists": "Valoarea estimării există deja.",
|
||||
"unsaved_changes": "Ai modificări nesalvate, te rugăm să le salvezi înainte de a finaliza",
|
||||
"remove_empty": "Estimarea nu poate fi goală. Introdu o valoare în fiecare câmp sau elimină câmpurile pentru care nu ai valori."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Puncte",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Linear",
|
||||
"squares": "Pătrate",
|
||||
"custom": "Personalizat"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Categorii",
|
||||
"t_shirt_sizes": "Mărimi tricou",
|
||||
"easy_to_hard": "De la ușor la greu",
|
||||
"custom": "Personalizat"
|
||||
},
|
||||
"time": {
|
||||
"label": "Timp",
|
||||
"hours": "Ore"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -869,7 +869,8 @@
|
||||
"deleting": "Удаление",
|
||||
"pending": "Ожидание",
|
||||
"invite": "Пригласить",
|
||||
"view": "Просмотр"
|
||||
"view": "Просмотр",
|
||||
"deactivated_user": "Деактивированный пользователь"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1740,12 +1741,15 @@
|
||||
"title": "Включить оценки для моего проекта",
|
||||
"description": "Они помогают вам в общении о сложности и рабочей нагрузке команды.",
|
||||
"no_estimate": "Без оценки",
|
||||
"new": "Новая система оценок",
|
||||
"create": {
|
||||
"custom": "Пользовательская",
|
||||
"start_from_scratch": "Начать с нуля",
|
||||
"choose_template": "Выбрать шаблон",
|
||||
"choose_estimate_system": "Выбрать систему оценок",
|
||||
"enter_estimate_point": "Ввести оценку"
|
||||
"enter_estimate_point": "Ввести оценку",
|
||||
"step": "Шаг {step} из {total}",
|
||||
"label": "Создать оценку"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1794,6 +1798,25 @@
|
||||
"already_exists": "Значение оценки уже существует.",
|
||||
"unsaved_changes": "У вас есть несохраненные изменения. Пожалуйста, сохраните их перед нажатием на готово",
|
||||
"remove_empty": "Оценка не может быть пустой. Введите значение в каждое поле или удалите те, для которых у вас нет значений."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Баллы",
|
||||
"fibonacci": "Фибоначчи",
|
||||
"linear": "Линейная",
|
||||
"squares": "Квадраты",
|
||||
"custom": "Пользовательская"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Категории",
|
||||
"t_shirt_sizes": "Размеры футболок",
|
||||
"easy_to_hard": "От простого к сложному",
|
||||
"custom": "Пользовательская"
|
||||
},
|
||||
"time": {
|
||||
"label": "Время",
|
||||
"hours": "Часы"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -869,7 +869,8 @@
|
||||
"deleting": "Mazanie",
|
||||
"pending": "Čakajúce",
|
||||
"invite": "Pozvať",
|
||||
"view": "Zobraziť"
|
||||
"view": "Zobraziť",
|
||||
"deactivated_user": "Deaktivovaný používateľ"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1739,12 +1740,15 @@
|
||||
"title": "Povoliť odhady pre môj projekt",
|
||||
"description": "Pomáhajú vám komunikovať zložitosť a pracovné zaťaženie tímu.",
|
||||
"no_estimate": "Bez odhadu",
|
||||
"new": "Nový systém odhadov",
|
||||
"create": {
|
||||
"custom": "Vlastné",
|
||||
"start_from_scratch": "Začať od nuly",
|
||||
"choose_template": "Vybrať šablónu",
|
||||
"choose_estimate_system": "Vybrať systém odhadov",
|
||||
"enter_estimate_point": "Zadať bod odhadu"
|
||||
"enter_estimate_point": "Zadať bod odhadu",
|
||||
"step": "Krok {step} z {total}",
|
||||
"label": "Vytvoriť odhad"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1793,6 +1797,25 @@
|
||||
"already_exists": "Hodnota odhadu už existuje.",
|
||||
"unsaved_changes": "Máte neuložené zmeny. Prosím, uložte ich pred kliknutím na hotovo",
|
||||
"remove_empty": "Odhad nemôže byť prázdny. Zadajte hodnotu do každého poľa alebo odstráňte prázdne polia."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Body",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Lineárne",
|
||||
"squares": "Štvorce",
|
||||
"custom": "Vlastné"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Kategórie",
|
||||
"t_shirt_sizes": "Veľkosti tričiek",
|
||||
"easy_to_hard": "Od jednoduchého po náročné",
|
||||
"custom": "Vlastné"
|
||||
},
|
||||
"time": {
|
||||
"label": "Čas",
|
||||
"hours": "Hodiny"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -864,7 +864,8 @@
|
||||
"deleting": "Видалення",
|
||||
"pending": "Очікує",
|
||||
"invite": "Запросити",
|
||||
"view": "Подання"
|
||||
"view": "Подання",
|
||||
"deactivated_user": "Деактивований користувач"
|
||||
},
|
||||
"chart": {
|
||||
"x_axis": "Вісь X",
|
||||
@@ -1716,12 +1717,15 @@
|
||||
"title": "Увімкнути оцінки для мого проєкту",
|
||||
"description": "Вони допомагають вам повідомляти про складність та навантаження команди.",
|
||||
"no_estimate": "Без оцінки",
|
||||
"new": "Нова система оцінок",
|
||||
"create": {
|
||||
"custom": "Власний",
|
||||
"start_from_scratch": "Почати з нуля",
|
||||
"choose_template": "Вибрати шаблон",
|
||||
"choose_estimate_system": "Вибрати систему оцінок",
|
||||
"enter_estimate_point": "Введіть оцінку"
|
||||
"enter_estimate_point": "Введіть оцінку",
|
||||
"step": "Крок {step} з {total}",
|
||||
"label": "Створити оцінку"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1770,6 +1774,25 @@
|
||||
"already_exists": "Таке значення оцінки вже існує.",
|
||||
"unsaved_changes": "У вас є незбережені зміни. Збережіть їх перед тим, як натиснути 'готово'",
|
||||
"remove_empty": "Оцінка не може бути порожньою. Введіть значення в кожне поле або видаліть ті, для яких у вас немає значень."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Бали",
|
||||
"fibonacci": "Фібоначчі",
|
||||
"linear": "Лінійна",
|
||||
"squares": "Квадрати",
|
||||
"custom": "Власна"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Категорії",
|
||||
"t_shirt_sizes": "Розміри футболок",
|
||||
"easy_to_hard": "Від легкого до складного",
|
||||
"custom": "Власна"
|
||||
},
|
||||
"time": {
|
||||
"label": "Час",
|
||||
"hours": "Години"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -863,7 +863,8 @@
|
||||
"deleting": "Đang xóa",
|
||||
"pending": "Đang chờ xử lý",
|
||||
"invite": "Mời",
|
||||
"view": "Xem"
|
||||
"view": "Xem",
|
||||
"deactivated_user": "Người dùng bị vô hiệu hóa"
|
||||
},
|
||||
"chart": {
|
||||
"x_axis": "Trục X",
|
||||
@@ -1715,12 +1716,15 @@
|
||||
"title": "Bật ước tính cho dự án của tôi",
|
||||
"description": "Chúng giúp bạn truyền đạt độ phức tạp và khối lượng công việc của nhóm.",
|
||||
"no_estimate": "Không có ước tính",
|
||||
"new": "Hệ thống ước tính mới",
|
||||
"create": {
|
||||
"custom": "Tùy chỉnh",
|
||||
"start_from_scratch": "Bắt đầu từ đầu",
|
||||
"choose_template": "Chọn mẫu",
|
||||
"choose_estimate_system": "Chọn hệ thống ước tính",
|
||||
"enter_estimate_point": "Nhập điểm ước tính"
|
||||
"enter_estimate_point": "Nhập điểm ước tính",
|
||||
"step": "Bước {step} của {total}",
|
||||
"label": "Tạo ước tính"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1768,6 +1772,25 @@
|
||||
"empty": "Giá trị ước tính không được để trống",
|
||||
"already_exists": "Giá trị ước tính này đã tồn tại",
|
||||
"unsaved_changes": "Bạn có thay đổi chưa lưu. Vui lòng lưu trước khi nhấn 'xong'"
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Điểm",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Tuyến tính",
|
||||
"squares": "Bình phương",
|
||||
"custom": "Tùy chỉnh"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Danh mục",
|
||||
"t_shirt_sizes": "Kích cỡ áo",
|
||||
"easy_to_hard": "Dễ đến khó",
|
||||
"custom": "Tùy chỉnh"
|
||||
},
|
||||
"time": {
|
||||
"label": "Thời gian",
|
||||
"hours": "Giờ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -870,7 +870,8 @@
|
||||
"deleting": "删除中",
|
||||
"pending": "待处理",
|
||||
"invite": "邀请",
|
||||
"view": "查看"
|
||||
"view": "查看",
|
||||
"deactivated_user": "已停用用户"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1740,12 +1741,15 @@
|
||||
"title": "为我的项目启用估算",
|
||||
"description": "它们有助于您传达团队的复杂性和工作量。",
|
||||
"no_estimate": "无估算",
|
||||
"new": "新估算系统",
|
||||
"create": {
|
||||
"custom": "自定义",
|
||||
"start_from_scratch": "从头开始",
|
||||
"choose_template": "选择模板",
|
||||
"choose_estimate_system": "选择估算系统",
|
||||
"enter_estimate_point": "输入估算点数"
|
||||
"enter_estimate_point": "输入估算点数",
|
||||
"step": "步骤 {step} 共 {total}",
|
||||
"label": "创建估算"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1784,6 +1788,16 @@
|
||||
"message": "无法禁用估算。请重试"
|
||||
}
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"min_length": "估算需要大于0。",
|
||||
"unable_to_process": "我们无法处理您的请求,请重试。",
|
||||
"numeric": "估算需要是数值。",
|
||||
"character": "估算需要是字符值。",
|
||||
"empty": "估算值不能为空。",
|
||||
"already_exists": "估算值已存在。",
|
||||
"unsaved_changes": "您有未保存的更改,请在点击完成前保存。",
|
||||
"remove_empty": "估算不能为空。请在每个字段中输入值或删除没有值的字段。"
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -871,7 +871,8 @@
|
||||
"deleting": "刪除中",
|
||||
"pending": "待處理",
|
||||
"invite": "邀請",
|
||||
"view": "檢視"
|
||||
"view": "檢視",
|
||||
"deactivated_user": "已停用用戶"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1742,12 +1743,15 @@
|
||||
"title": "為我的專案啟用預估",
|
||||
"description": "幫助你傳達團隊的複雜性和工作負荷。",
|
||||
"no_estimate": "無預估",
|
||||
"new": "新估算系統",
|
||||
"create": {
|
||||
"custom": "自訂",
|
||||
"start_from_scratch": "從頭開始",
|
||||
"choose_template": "選擇範本",
|
||||
"choose_estimate_system": "選擇預估系統",
|
||||
"enter_estimate_point": "輸入預估"
|
||||
"enter_estimate_point": "輸入預估",
|
||||
"step": "步驟 {step} 共 {total}",
|
||||
"label": "建立預估"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1796,6 +1800,25 @@
|
||||
"already_exists": "預估值已存在。",
|
||||
"unsaved_changes": "你有未儲存的變更。請在點擊完成前儲存",
|
||||
"remove_empty": "預估不能為空。在每個欄位中輸入值或移除沒有值的欄位。"
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "點數",
|
||||
"fibonacci": "費波那契數列",
|
||||
"linear": "線性",
|
||||
"squares": "平方數",
|
||||
"custom": "自訂"
|
||||
},
|
||||
"categories": {
|
||||
"label": "類別",
|
||||
"t_shirt_sizes": "T恤尺寸",
|
||||
"easy_to_hard": "簡單到困難",
|
||||
"custom": "自訂"
|
||||
},
|
||||
"time": {
|
||||
"label": "時間",
|
||||
"hours": "小時"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -27,6 +27,7 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
boxShadow: {
|
||||
"custom-shadow": "var(--color-shadow-custom)",
|
||||
"custom-shadow-2xs": "var(--color-shadow-2xs)",
|
||||
"custom-shadow-xs": "var(--color-shadow-xs)",
|
||||
"custom-shadow-sm": "var(--color-shadow-sm)",
|
||||
@@ -208,6 +209,28 @@ module.exports = {
|
||||
hover: "rgba(96, 100, 108, 0.25)",
|
||||
active: "rgba(96, 100, 108, 0.7)",
|
||||
},
|
||||
subscription: {
|
||||
free: {
|
||||
200: convertToRGB("--color-subscription-free-200"),
|
||||
400: convertToRGB("--color-subscription-free-400"),
|
||||
},
|
||||
one: {
|
||||
200: convertToRGB("--color-subscription-one-200"),
|
||||
400: convertToRGB("--color-subscription-one-400"),
|
||||
},
|
||||
pro: {
|
||||
200: convertToRGB("--color-subscription-pro-200"),
|
||||
400: convertToRGB("--color-subscription-pro-400"),
|
||||
},
|
||||
business: {
|
||||
200: convertToRGB("--color-subscription-business-200"),
|
||||
400: convertToRGB("--color-subscription-business-400"),
|
||||
},
|
||||
enterprise: {
|
||||
200: convertToRGB("--color-subscription-enterprise-200"),
|
||||
400: convertToRGB("--color-subscription-enterprise-400"),
|
||||
},
|
||||
},
|
||||
},
|
||||
onboarding: {
|
||||
background: {
|
||||
|
||||
Vendored
+4
-7
@@ -14,10 +14,7 @@ export interface IEstimatePoint {
|
||||
updated_by: string | undefined;
|
||||
}
|
||||
|
||||
export type TEstimateSystemKeys =
|
||||
| EEstimateSystem.POINTS
|
||||
| EEstimateSystem.CATEGORIES
|
||||
| EEstimateSystem.TIME;
|
||||
export type TEstimateSystemKeys = EEstimateSystem.POINTS | EEstimateSystem.CATEGORIES | EEstimateSystem.TIME;
|
||||
|
||||
export interface IEstimate {
|
||||
id: string | undefined;
|
||||
@@ -55,12 +52,14 @@ export type TEstimatePointsObject = {
|
||||
|
||||
export type TTemplateValues = {
|
||||
title: string;
|
||||
i18n_title: string;
|
||||
values: TEstimatePointsObject[];
|
||||
hide?: boolean;
|
||||
};
|
||||
|
||||
export type TEstimateSystem = {
|
||||
name: string;
|
||||
i18n_name: string;
|
||||
templates: Record<string, TTemplateValues>;
|
||||
is_available: boolean;
|
||||
is_ee: boolean;
|
||||
@@ -82,6 +81,4 @@ export type TEstimateTypeErrorObject = {
|
||||
message: string | undefined;
|
||||
};
|
||||
|
||||
export type TEstimateTypeError =
|
||||
| Record<number, TEstimateTypeErrorObject>
|
||||
| undefined;
|
||||
export type TEstimateTypeError = Record<number, TEstimateTypeErrorObject> | undefined;
|
||||
|
||||
Vendored
+1
@@ -42,3 +42,4 @@ export * from "./charts";
|
||||
export * from "./home";
|
||||
export * from "./stickies";
|
||||
export * from "./utils";
|
||||
export * from "./payment";
|
||||
|
||||
+4
-1
@@ -1,3 +1,6 @@
|
||||
// plane imports
|
||||
import { EInboxIssueSource } from "@plane/constants";
|
||||
// local imports
|
||||
import {
|
||||
TIssueActivityWorkspaceDetail,
|
||||
TIssueActivityProjectDetail,
|
||||
@@ -31,7 +34,7 @@ export type TIssueActivity = {
|
||||
epoch: number;
|
||||
issue_comment: string | null;
|
||||
source_data: {
|
||||
source: "IN_APP" | "FORM" | "EMAIL";
|
||||
source: EInboxIssueSource;
|
||||
source_email?: string;
|
||||
extra: {
|
||||
username?: string;
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ export type TIssueComment = {
|
||||
};
|
||||
|
||||
export type TCommentsOperations = {
|
||||
createComment: (data: Partial<TIssueComment>) => Promise<void>;
|
||||
createComment: (data: Partial<TIssueComment>) => Promise<Partial<TIssueComment> | undefined>;
|
||||
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
|
||||
removeComment: (commentId: string) => Promise<void>;
|
||||
uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
|
||||
|
||||
+17
@@ -20,3 +20,20 @@ export type TIssueSubIssuesStateDistributionMap = {
|
||||
export type TIssueSubIssuesIdMap = {
|
||||
[issue_id: string]: string[];
|
||||
};
|
||||
|
||||
export type TSubIssueOperations = {
|
||||
copyLink: (path: string) => void;
|
||||
fetchSubIssues: (workspaceSlug: string, projectId: string, parentIssueId: string) => Promise<void>;
|
||||
addSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => Promise<void>;
|
||||
updateSubIssue: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
parentIssueId: string,
|
||||
issueId: string,
|
||||
issueData: Partial<TIssue>,
|
||||
oldIssue?: Partial<TIssue>,
|
||||
fromModal?: boolean
|
||||
) => Promise<void>;
|
||||
removeSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise<void>;
|
||||
deleteSubIssue: (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
import { EProductSubscriptionEnum } from "@plane/constants";
|
||||
|
||||
export type TBillingFrequency = "month" | "year";
|
||||
|
||||
export type IPaymentProductPrice = {
|
||||
currency: string;
|
||||
id: string;
|
||||
product: string;
|
||||
recurring: TBillingFrequency;
|
||||
unit_amount: number;
|
||||
workspace_amount: number;
|
||||
};
|
||||
|
||||
export type TProductSubscriptionType = "FREE" | "ONE" | "PRO" | "BUSINESS" | "ENTERPRISE";
|
||||
|
||||
export type IPaymentProduct = {
|
||||
description: string;
|
||||
id: string;
|
||||
name: string;
|
||||
type: Omit<TProductSubscriptionType, "FREE">;
|
||||
payment_quantity: number;
|
||||
prices: IPaymentProductPrice[];
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type TSubscriptionPrice = {
|
||||
key: string;
|
||||
id: string | undefined;
|
||||
currency: string;
|
||||
price: number;
|
||||
recurring: TBillingFrequency;
|
||||
};
|
||||
|
||||
export type TProductBillingFrequency = {
|
||||
[key in EProductSubscriptionEnum]: TBillingFrequency | undefined;
|
||||
};
|
||||
@@ -42,7 +42,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||
<div className={cn("max-h-48 space-y-1 overflow-y-scroll", !disableSearch && "mt-2")}>
|
||||
<>
|
||||
{options ? (
|
||||
options.length > 0 ? (
|
||||
|
||||
@@ -12,3 +12,4 @@ export * from "./string";
|
||||
export * from "./theme";
|
||||
export * from "./workspace";
|
||||
export * from "./work-item";
|
||||
export * from "./subscription";
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import orderBy from "lodash/orderBy";
|
||||
// plane imports
|
||||
import { EProductSubscriptionEnum } from "@plane/constants";
|
||||
import { IPaymentProduct, TProductSubscriptionType, TSubscriptionPrice } from "@plane/types";
|
||||
|
||||
/**
|
||||
* Calculates the yearly discount percentage when switching from monthly to yearly billing
|
||||
* @param monthlyPrice - The monthly subscription price
|
||||
* @param yearlyPricePerMonth - The monthly equivalent price when billed yearly
|
||||
* @returns The discount percentage as a whole number (floored)
|
||||
*/
|
||||
export const calculateYearlyDiscount = (monthlyPrice: number, yearlyPricePerMonth: number): number => {
|
||||
const monthlyCost = monthlyPrice * 12;
|
||||
const yearlyCost = yearlyPricePerMonth * 12;
|
||||
const amountSaved = monthlyCost - yearlyCost;
|
||||
const discountPercentage = (amountSaved / monthlyCost) * 100;
|
||||
return Math.floor(discountPercentage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the display name for a subscription plan variant
|
||||
* @param planVariant - The subscription plan variant enum
|
||||
* @returns The human-readable name of the plan
|
||||
*/
|
||||
export const getSubscriptionName = (planVariant: EProductSubscriptionEnum): string => {
|
||||
switch (planVariant) {
|
||||
case EProductSubscriptionEnum.FREE:
|
||||
return "Free";
|
||||
case EProductSubscriptionEnum.ONE:
|
||||
return "One";
|
||||
case EProductSubscriptionEnum.PRO:
|
||||
return "Pro";
|
||||
case EProductSubscriptionEnum.BUSINESS:
|
||||
return "Business";
|
||||
case EProductSubscriptionEnum.ENTERPRISE:
|
||||
return "Enterprise";
|
||||
default:
|
||||
return "--";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the base subscription name for upgrade/downgrade paths
|
||||
* @param planVariant - The current subscription plan variant
|
||||
* @param isSelfHosted - Whether the instance is self-hosted / community
|
||||
* @returns The name of the base subscription plan
|
||||
*
|
||||
* @remarks
|
||||
* - For self-hosted / community instances, the upgrade path differs from cloud instances
|
||||
* - Returns the immediate lower tier subscription name
|
||||
*/
|
||||
export const getBaseSubscriptionName = (planVariant: TProductSubscriptionType, isSelfHosted: boolean): string => {
|
||||
switch (planVariant) {
|
||||
case EProductSubscriptionEnum.ONE:
|
||||
return getSubscriptionName(EProductSubscriptionEnum.FREE);
|
||||
case EProductSubscriptionEnum.PRO:
|
||||
return isSelfHosted
|
||||
? getSubscriptionName(EProductSubscriptionEnum.ONE)
|
||||
: getSubscriptionName(EProductSubscriptionEnum.FREE);
|
||||
case EProductSubscriptionEnum.BUSINESS:
|
||||
return getSubscriptionName(EProductSubscriptionEnum.PRO);
|
||||
case EProductSubscriptionEnum.ENTERPRISE:
|
||||
return getSubscriptionName(EProductSubscriptionEnum.BUSINESS);
|
||||
default:
|
||||
return "--";
|
||||
}
|
||||
};
|
||||
|
||||
export type TSubscriptionPriceDetail = {
|
||||
monthlyPriceDetails: TSubscriptionPrice;
|
||||
yearlyPriceDetails: TSubscriptionPrice;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the price details for a subscription product
|
||||
* @param product - The payment product to get price details for
|
||||
* @returns Array of price details for monthly and yearly plans
|
||||
*/
|
||||
export const getSubscriptionPriceDetails = (product: IPaymentProduct | undefined): TSubscriptionPriceDetail => {
|
||||
const productPrices = product?.prices || [];
|
||||
const monthlyPriceDetails = orderBy(productPrices, ["recurring"], ["desc"])?.find(
|
||||
(price) => price.recurring === "month"
|
||||
);
|
||||
const monthlyPriceAmount = Number(((monthlyPriceDetails?.unit_amount || 0) / 100).toFixed(2));
|
||||
const yearlyPriceDetails = orderBy(productPrices, ["recurring"], ["desc"])?.find(
|
||||
(price) => price.recurring === "year"
|
||||
);
|
||||
const yearlyPriceAmount = Number(((yearlyPriceDetails?.unit_amount || 0) / 1200).toFixed(2));
|
||||
|
||||
return {
|
||||
monthlyPriceDetails: {
|
||||
key: "monthly",
|
||||
id: monthlyPriceDetails?.id,
|
||||
currency: "$",
|
||||
price: monthlyPriceAmount,
|
||||
recurring: "month",
|
||||
},
|
||||
yearlyPriceDetails: {
|
||||
key: "yearly",
|
||||
id: yearlyPriceDetails?.id,
|
||||
currency: "$",
|
||||
price: yearlyPriceAmount,
|
||||
recurring: "year",
|
||||
},
|
||||
};
|
||||
};
|
||||
+15
-32
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { ArrowRight, PanelRight } from "lucide-react";
|
||||
import { PanelRight } from "lucide-react";
|
||||
// plane constants
|
||||
import {
|
||||
EIssueLayoutTypes,
|
||||
@@ -23,15 +23,15 @@ import {
|
||||
IIssueFilterOptions,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
|
||||
import { Breadcrumbs, Button, DiceIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// helpers
|
||||
import { ModuleQuickActions } from "@/components/modules";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { isIssueFilterActive } from "@/helpers/filter.helper";
|
||||
import { truncateText } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import {
|
||||
useEventTracker,
|
||||
@@ -51,30 +51,9 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
|
||||
const ModuleDropdownOption: React.FC<{ moduleId: string }> = ({ moduleId }) => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { getModuleById } = useModule();
|
||||
// derived values
|
||||
const moduleDetail = getModuleById(moduleId);
|
||||
|
||||
if (!moduleDetail) return null;
|
||||
|
||||
return (
|
||||
<CustomMenu.MenuItem key={moduleDetail.id}>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/modules/${moduleDetail.id}`}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<DiceIcon className="h-3 w-3" />
|
||||
{truncateText(moduleDetail.name, 40)}
|
||||
</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
// refs
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// states
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
// router
|
||||
@@ -320,14 +299,18 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80"
|
||||
className="p-1 rounded outline-none hover:bg-custom-sidebar-background-80 bg-custom-background-80/70"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<ArrowRight className={`hidden h-4 w-4 duration-300 md:block ${isSidebarCollapsed ? "-rotate-180" : ""}`} />
|
||||
<PanelRight
|
||||
className={cn("block h-4 w-4 md:hidden", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")}
|
||||
/>
|
||||
<PanelRight className={cn("h-4 w-4", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
|
||||
</button>
|
||||
<ModuleQuickActions
|
||||
parentRef={parentRef}
|
||||
moduleId={moduleId?.toString()}
|
||||
projectId={projectId.toString()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
customClassName="flex-shrink-0 flex items-center justify-center size-6 bg-custom-background-80/70 rounded"
|
||||
/>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
</>
|
||||
|
||||
+25
-6
@@ -2,9 +2,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { FileText, Layers } from "lucide-react";
|
||||
import { ArchiveIcon, Earth, FileText, Lock } from "lucide-react";
|
||||
// types
|
||||
import { ICustomSearchSelectOption } from "@plane/types";
|
||||
import { EPageAccess } from "@plane/constants";
|
||||
import { ICustomSearchSelectOption, TPage } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Header, CustomSearchSelect } from "@plane/ui";
|
||||
// components
|
||||
@@ -12,6 +13,7 @@ import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
|
||||
import { PageEditInformationPopover } from "@/components/pages";
|
||||
// helpers
|
||||
// hooks
|
||||
import { getPageName } from "@/helpers/page.helper";
|
||||
import { useProject } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
@@ -19,6 +21,18 @@ import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
|
||||
|
||||
const PageAccessIcon = (page: TPage) => (
|
||||
<div>
|
||||
{page.archived_at ? (
|
||||
<ArchiveIcon className="h-2.5 w-2.5 text-custom-text-300" />
|
||||
) : page.access === EPageAccess.PUBLIC ? (
|
||||
<Earth className="h-2.5 w-2.5 text-custom-text-300" />
|
||||
) : (
|
||||
<Lock className="h-2.5 w-2.5 text-custom-text-300" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export interface IPagesHeaderProps {
|
||||
showButton?: boolean;
|
||||
}
|
||||
@@ -46,9 +60,12 @@ export const PageDetailsHeader = observer(() => {
|
||||
value: _page.id,
|
||||
query: _page.name,
|
||||
content: (
|
||||
<Link href={pageLink} className="flex gap-2 items-center justify-between">
|
||||
<SwitcherLabel logo_props={_page.logo_props} name={_page.name} LabelIcon={Layers} />
|
||||
</Link>
|
||||
<div className="flex gap-2 items-center justify-between">
|
||||
<Link href={pageLink} className="flex gap-2 items-center justify-between w-full">
|
||||
<SwitcherLabel logo_props={_page.logo_props} name={getPageName(_page.name)} LabelIcon={FileText} />
|
||||
</Link>
|
||||
<PageAccessIcon {..._page} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})
|
||||
@@ -94,7 +111,9 @@ export const PageDetailsHeader = observer(() => {
|
||||
<CustomSearchSelect
|
||||
value={pageId}
|
||||
options={switcherOptions}
|
||||
label={<SwitcherLabel logo_props={page.logo_props} name={page.name} LabelIcon={Layers} />}
|
||||
label={
|
||||
<SwitcherLabel logo_props={page.logo_props} name={getPageName(page.name)} LabelIcon={FileText} />
|
||||
}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export const MobileWorkspaceSettingsTabs = observer(() => {
|
||||
<div className="flex-shrink-0 md:hidden sticky inset-0 flex overflow-x-auto bg-custom-background-100 z-10">
|
||||
{WORKSPACE_SETTINGS_LINKS.map(
|
||||
(item, index) =>
|
||||
shouldRenderSettingLink(item.key) &&
|
||||
shouldRenderSettingLink(workspaceSlug.toString(), item.key) &&
|
||||
allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && (
|
||||
<div
|
||||
className={`${
|
||||
|
||||
@@ -28,7 +28,7 @@ export const WorkspaceSettingsSidebar = observer(() => {
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{WORKSPACE_SETTINGS_LINKS.map(
|
||||
(link) =>
|
||||
shouldRenderSettingLink(link.key) &&
|
||||
shouldRenderSettingLink(workspaceSlug.toString(), link.key) &&
|
||||
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && (
|
||||
<Link key={link.key} href={`/${workspaceSlug}${link.href}`}>
|
||||
<SidebarNavItem
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// plane imports
|
||||
import { EIssueServiceType, EIssuesStoreType } from "@plane/constants";
|
||||
import { TIssue } from "@plane/types";
|
||||
// components
|
||||
import { BulkDeleteIssuesModal } from "@/components/core";
|
||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||
// constants
|
||||
// hooks
|
||||
import { useCommandPalette, useIssueDetail, useUser } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
|
||||
export type TIssueLevelModalsProps = {
|
||||
projectId: string | undefined;
|
||||
@@ -26,9 +28,10 @@ export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) =>
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const {
|
||||
issues: { removeIssue },
|
||||
} = useIssuesStore();
|
||||
|
||||
const { removeIssue: removeEpic } = useIssuesActions(EIssuesStoreType.EPIC);
|
||||
const { removeIssue: removeWorkItem } = useIssuesActions(EIssuesStoreType.PROJECT);
|
||||
|
||||
const {
|
||||
isCreateIssueModalOpen,
|
||||
toggleCreateIssueModal,
|
||||
@@ -40,24 +43,51 @@ export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) =>
|
||||
// derived values
|
||||
const issueDetails = issueId ? getIssueById(issueId) : undefined;
|
||||
const isDraftIssue = pathname?.includes("draft-issues") || false;
|
||||
const { fetchSubIssues: fetchSubWorkItems } = useIssueDetail();
|
||||
const { fetchSubIssues: fetchEpicSubWorkItems } = useIssueDetail(EIssueServiceType.EPICS);
|
||||
|
||||
const handleDeleteIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
const isEpic = issueDetails?.is_epic;
|
||||
const deleteAction = isEpic ? removeEpic : removeWorkItem;
|
||||
const redirectPath = `/${workspaceSlug}/projects/${projectId}/${isEpic ? "epics" : "issues"}`;
|
||||
|
||||
await deleteAction(projectId, issueId);
|
||||
router.push(redirectPath);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete issue:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateIssueSubmit = async (newIssue: TIssue) => {
|
||||
if (!workspaceSlug || !newIssue.project_id || !newIssue.id || newIssue.parent_id !== issueDetails?.id) return;
|
||||
|
||||
const fetchAction = issueDetails?.is_epic ? fetchEpicSubWorkItems : fetchSubWorkItems;
|
||||
await fetchAction(workspaceSlug?.toString(), newIssue.project_id, issueDetails.id);
|
||||
};
|
||||
|
||||
const getCreateIssueModalData = () => {
|
||||
if (cycleId) return { cycle_id: cycleId.toString() };
|
||||
if (moduleId) return { module_ids: [moduleId.toString()] };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={isCreateIssueModalOpen}
|
||||
onClose={() => toggleCreateIssueModal(false)}
|
||||
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined}
|
||||
data={getCreateIssueModalData()}
|
||||
isDraft={isDraftIssue}
|
||||
onSubmit={handleCreateIssueSubmit}
|
||||
/>
|
||||
{workspaceSlug && projectId && issueId && issueDetails && (
|
||||
<DeleteIssueModal
|
||||
handleClose={() => toggleDeleteIssueModal(false)}
|
||||
isOpen={isDeleteIssueModalOpen}
|
||||
data={issueDetails}
|
||||
onSubmit={async () => {
|
||||
await removeIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString());
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
|
||||
}}
|
||||
onSubmit={() => handleDeleteIssue(workspaceSlug.toString(), projectId?.toString(), issueId?.toString())}
|
||||
isEpic={issueDetails?.is_epic}
|
||||
/>
|
||||
)}
|
||||
<BulkDeleteIssuesModal
|
||||
|
||||
@@ -59,7 +59,7 @@ export const SidebarChart: FC<ProgressChartProps> = observer((props) => {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="relative flex items-center justify-between gap-2 pt-4">
|
||||
<EstimateTypeDropdown value={estimateType} onChange={onChange} cycleId={cycleId} projectId={projectId} />
|
||||
</div>
|
||||
@@ -92,6 +92,6 @@ export const SidebarChart: FC<ProgressChartProps> = observer((props) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { EInboxIssueSource } from "@plane/constants";
|
||||
|
||||
export type TInboxSourcePill = {
|
||||
source: EInboxIssueSource;
|
||||
};
|
||||
|
||||
export const InboxSourcePill = (props: TInboxSourcePill) => <></>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { TIssueActivity } from "@plane/types";
|
||||
|
||||
export const renderEstimate = (activity: TIssueActivity, value: string) => value;
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./root";
|
||||
export * from "./helper";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./modal";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./upgrade-modal";
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import {
|
||||
BUSINESS_PLAN_FEATURES,
|
||||
ENTERPRISE_PLAN_FEATURES,
|
||||
EProductSubscriptionEnum,
|
||||
PLANE_COMMUNITY_PRODUCTS,
|
||||
PRO_PLAN_FEATURES,
|
||||
SUBSCRIPTION_REDIRECTION_URLS,
|
||||
SUBSCRIPTION_WEBPAGE_URLS,
|
||||
TALK_TO_SALES_URL,
|
||||
} from "@plane/constants";
|
||||
import { EModalWidth, ModalCore } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { FreePlanCard, PlanUpgradeCard } from "@/components/license";
|
||||
import { TCheckoutParams } from "@/components/license/modal/card/checkout-button";
|
||||
|
||||
// Constants
|
||||
const COMMON_CARD_CLASSNAME = "flex flex-col w-full h-full justify-end col-span-12 sm:col-span-6 xl:col-span-3";
|
||||
const COMMON_EXTRA_FEATURES_CLASSNAME = "pt-2 text-center text-xs text-custom-primary-200 font-medium hover:underline";
|
||||
|
||||
export type PaidPlanUpgradeModalProps = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const PaidPlanUpgradeModal: FC<PaidPlanUpgradeModalProps> = observer((props) => {
|
||||
const { isOpen, handleClose } = props;
|
||||
// derived values
|
||||
const isSelfHosted = true;
|
||||
const isTrialAllowed = false;
|
||||
|
||||
const handleRedirection = ({ planVariant, priceId }: TCheckoutParams) => {
|
||||
// Get the product and price using plane community constants
|
||||
const product = PLANE_COMMUNITY_PRODUCTS[planVariant];
|
||||
const price = product.prices.find((price) => price.id === priceId);
|
||||
const frequency = price?.recurring ?? "year";
|
||||
// Redirect to the appropriate URL
|
||||
const redirectUrl = SUBSCRIPTION_REDIRECTION_URLS[planVariant][frequency] ?? TALK_TO_SALES_URL;
|
||||
window.open(redirectUrl, "_blank");
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.VIIXL} className="rounded-2xl">
|
||||
<div className="p-10 max-h-[90vh] overflow-auto">
|
||||
<div className="grid grid-cols-12 gap-6 h-full">
|
||||
{/* Free Plan Section */}
|
||||
<div className={cn(COMMON_CARD_CLASSNAME)}>
|
||||
<div className="text-3xl font-bold leading-8 flex">Upgrade to a paid plan and unlock missing features.</div>
|
||||
<div className="mt-4 mb-2">
|
||||
<p className="text-sm mb-4 pr-8 text-custom-text-100">
|
||||
Dashboards, Workflows, Approvals, Time Management, and other superpowers are just a click away. Upgrade
|
||||
today to unlock features your teams need yesterday.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Free plan details */}
|
||||
<FreePlanCard isOnFreePlan />
|
||||
</div>
|
||||
|
||||
{/* Pro plan */}
|
||||
<div className={cn(COMMON_CARD_CLASSNAME)}>
|
||||
<PlanUpgradeCard
|
||||
planVariant={EProductSubscriptionEnum.PRO}
|
||||
product={PLANE_COMMUNITY_PRODUCTS[EProductSubscriptionEnum.PRO]}
|
||||
features={PRO_PLAN_FEATURES}
|
||||
verticalFeatureList
|
||||
extraFeatures={
|
||||
<p className={COMMON_EXTRA_FEATURES_CLASSNAME}>
|
||||
<a href={SUBSCRIPTION_WEBPAGE_URLS[EProductSubscriptionEnum.PRO]} target="_blank">
|
||||
See full features list
|
||||
</a>
|
||||
</p>
|
||||
}
|
||||
handleCheckout={handleRedirection}
|
||||
isSelfHosted={!!isSelfHosted}
|
||||
isTrialAllowed={!!isTrialAllowed}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(COMMON_CARD_CLASSNAME)}>
|
||||
<PlanUpgradeCard
|
||||
planVariant={EProductSubscriptionEnum.BUSINESS}
|
||||
product={PLANE_COMMUNITY_PRODUCTS[EProductSubscriptionEnum.BUSINESS]}
|
||||
features={BUSINESS_PLAN_FEATURES}
|
||||
verticalFeatureList
|
||||
extraFeatures={
|
||||
<p className={COMMON_EXTRA_FEATURES_CLASSNAME}>
|
||||
<a href={SUBSCRIPTION_WEBPAGE_URLS[EProductSubscriptionEnum.BUSINESS]} target="_blank">
|
||||
See full features list
|
||||
</a>
|
||||
</p>
|
||||
}
|
||||
handleCheckout={handleRedirection}
|
||||
isSelfHosted={!!isSelfHosted}
|
||||
isTrialAllowed={!!isTrialAllowed}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(COMMON_CARD_CLASSNAME)}>
|
||||
<PlanUpgradeCard
|
||||
planVariant={EProductSubscriptionEnum.ENTERPRISE}
|
||||
product={PLANE_COMMUNITY_PRODUCTS[EProductSubscriptionEnum.ENTERPRISE]}
|
||||
features={ENTERPRISE_PLAN_FEATURES}
|
||||
verticalFeatureList
|
||||
extraFeatures={
|
||||
<p className={COMMON_EXTRA_FEATURES_CLASSNAME}>
|
||||
<a href={SUBSCRIPTION_WEBPAGE_URLS[EProductSubscriptionEnum.ENTERPRISE]} target="_blank">
|
||||
See full features list
|
||||
</a>
|
||||
</p>
|
||||
}
|
||||
handleCheckout={handleRedirection}
|
||||
isSelfHosted={!!isSelfHosted}
|
||||
isTrialAllowed={!!isTrialAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { FC } from "react";
|
||||
// plane imports
|
||||
import { observer } from "mobx-react";
|
||||
import { EProductSubscriptionEnum } from "@plane/constants";
|
||||
import { TBillingFrequency } from "@plane/types";
|
||||
import { calculateYearlyDiscount, cn } from "@plane/utils";
|
||||
// plane web imports
|
||||
import { getDiscountPillStyle, getSubscriptionBackgroundColor } from "@/components/workspace/billing/subscription";
|
||||
|
||||
type TPlanFrequencyToggleProps = {
|
||||
subscriptionType: EProductSubscriptionEnum;
|
||||
monthlyPrice: number;
|
||||
yearlyPrice: number;
|
||||
selectedFrequency: TBillingFrequency;
|
||||
setSelectedFrequency: (frequency: TBillingFrequency) => void;
|
||||
};
|
||||
|
||||
export const PlanFrequencyToggle: FC<TPlanFrequencyToggleProps> = observer((props) => {
|
||||
const { subscriptionType, monthlyPrice, yearlyPrice, selectedFrequency, setSelectedFrequency } = props;
|
||||
// derived values
|
||||
const yearlyDiscount = calculateYearlyDiscount(monthlyPrice, yearlyPrice);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center cursor-pointer py-1 animate-slide-up">
|
||||
<div
|
||||
className={cn(
|
||||
"flex space-x-1 rounded-md bg-custom-primary-200/10 p-0.5 w-full",
|
||||
getSubscriptionBackgroundColor(subscriptionType, "50")
|
||||
)}
|
||||
>
|
||||
<div
|
||||
key="month"
|
||||
onClick={() => setSelectedFrequency("month")}
|
||||
className={cn(
|
||||
"w-full rounded px-1 py-0.5 text-xs font-medium leading-5 text-center",
|
||||
selectedFrequency === "month"
|
||||
? "bg-custom-background-100 text-custom-text-100 shadow"
|
||||
: "text-custom-text-300 hover:text-custom-text-200"
|
||||
)}
|
||||
>
|
||||
Monthly
|
||||
</div>
|
||||
<div
|
||||
key="year"
|
||||
onClick={() => setSelectedFrequency("year")}
|
||||
className={cn(
|
||||
"w-full rounded px-1 py-0.5 text-xs font-medium leading-5 text-center",
|
||||
selectedFrequency === "year"
|
||||
? "bg-custom-background-100 text-custom-text-100 shadow"
|
||||
: "text-custom-text-300 hover:text-custom-text-200"
|
||||
)}
|
||||
>
|
||||
Yearly
|
||||
{yearlyDiscount > 0 && (
|
||||
<span className={cn(getDiscountPillStyle(subscriptionType), "rounded-full px-1 py-0.5 ml-1 text-[9px]")}>
|
||||
-{yearlyDiscount}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import {
|
||||
EProductSubscriptionEnum,
|
||||
SUBSCRIPTION_REDIRECTION_URLS,
|
||||
SUBSCRIPTION_WITH_BILLING_FREQUENCY,
|
||||
TALK_TO_SALES_URL,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TBillingFrequency } from "@plane/types";
|
||||
import { getButtonStyling } from "@plane/ui";
|
||||
import { cn, getSubscriptionName } from "@plane/utils";
|
||||
// constants
|
||||
import { getUpgradeButtonStyle } from "@/components/workspace/billing/subscription";
|
||||
import { TPlanDetail } from "@/constants/plans";
|
||||
// components
|
||||
import { PlanFrequencyToggle } from "./frequency-toggle";
|
||||
|
||||
type TPlanDetailProps = {
|
||||
subscriptionType: EProductSubscriptionEnum;
|
||||
planDetail: TPlanDetail;
|
||||
billingFrequency: TBillingFrequency | undefined;
|
||||
setBillingFrequency: (frequency: TBillingFrequency) => void;
|
||||
};
|
||||
|
||||
const COMMON_BUTTON_STYLE =
|
||||
"relative inline-flex items-center justify-center w-full px-4 py-1.5 text-xs font-medium rounded-lg focus:outline-none transition-all duration-300 animate-slide-up";
|
||||
|
||||
export const PlanDetail: FC<TPlanDetailProps> = observer((props) => {
|
||||
const { subscriptionType, planDetail, billingFrequency, setBillingFrequency } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// subscription details
|
||||
const subscriptionName = getSubscriptionName(subscriptionType);
|
||||
const isSubscriptionActive = planDetail.isActive;
|
||||
// pricing details
|
||||
const displayPrice = billingFrequency === "month" ? planDetail.monthlyPrice : planDetail.yearlyPrice;
|
||||
const pricingDescription = isSubscriptionActive ? "a user per month" : "Quote on request";
|
||||
const pricingSecondaryDescription =
|
||||
billingFrequency === "month"
|
||||
? planDetail.monthlyPriceSecondaryDescription
|
||||
: planDetail.yearlyPriceSecondaryDescription;
|
||||
// helper styles
|
||||
const upgradeButtonStyle = getUpgradeButtonStyle(subscriptionType, false) ?? getButtonStyling("primary", "lg");
|
||||
|
||||
const handleRedirection = () => {
|
||||
const frequency = billingFrequency ?? "year";
|
||||
// Get the redirection URL based on the subscription type and billing frequency
|
||||
const redirectUrl = SUBSCRIPTION_REDIRECTION_URLS[subscriptionType][frequency] ?? TALK_TO_SALES_URL;
|
||||
// Open the URL in a new tab
|
||||
window.open(redirectUrl, "_blank");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between col-span-1 p-3 space-y-0.5">
|
||||
{/* Plan name and pricing section */}
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="flex w-full gap-2 items-center text-xl font-medium">
|
||||
<span className="transition-all duration-300">{subscriptionName}</span>
|
||||
{subscriptionType === EProductSubscriptionEnum.PRO && (
|
||||
<span className="px-2 rounded text-custom-primary-200 bg-custom-primary-100/20 text-xs">Popular</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-x-2 items-start text-custom-text-300 pb-1 transition-all duration-300 animate-slide-up">
|
||||
{isSubscriptionActive && displayPrice !== undefined && (
|
||||
<span className="text-custom-text-100 text-2xl font-semibold transition-all duration-300">
|
||||
{"$" + displayPrice}
|
||||
</span>
|
||||
)}
|
||||
<div className="pt-2">
|
||||
{pricingDescription && <div className="transition-all duration-300">{pricingDescription}</div>}
|
||||
{pricingSecondaryDescription && (
|
||||
<div className="text-xs text-custom-text-400 transition-all duration-300">
|
||||
{pricingSecondaryDescription}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing frequency toggle */}
|
||||
{SUBSCRIPTION_WITH_BILLING_FREQUENCY.includes(subscriptionType) && billingFrequency && (
|
||||
<div className="h-8 py-0.5">
|
||||
<PlanFrequencyToggle
|
||||
subscriptionType={subscriptionType}
|
||||
monthlyPrice={planDetail.monthlyPrice || 0}
|
||||
yearlyPrice={planDetail.yearlyPrice || 0}
|
||||
selectedFrequency={billingFrequency}
|
||||
setSelectedFrequency={setBillingFrequency}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscription button */}
|
||||
<div className={cn("flex flex-col gap-1 py-3 items-start transition-all duration-300")}>
|
||||
<button onClick={handleRedirection} className={cn(upgradeButtonStyle, COMMON_BUTTON_STYLE)}>
|
||||
{isSubscriptionActive ? `Upgrade to ${subscriptionName}` : t("common.upgrade_cta.talk_to_sales")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { forwardRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EProductSubscriptionEnum } from "@plane/constants";
|
||||
import { TBillingFrequency } from "@plane/types";
|
||||
// components
|
||||
import { PlansComparisonBase, shouldRenderPlanDetail } from "@/components/workspace/billing/comparison/base";
|
||||
import { PLANE_PLANS, TPlanePlans } from "@/constants/plans";
|
||||
// plane web imports
|
||||
import { PlanDetail } from "./plan-detail";
|
||||
|
||||
type TPlansComparisonProps = {
|
||||
isScrolled: boolean;
|
||||
isCompareAllFeaturesSectionOpen: boolean;
|
||||
getBillingFrequency: (subscriptionType: EProductSubscriptionEnum) => TBillingFrequency | undefined;
|
||||
setBillingFrequency: (subscriptionType: EProductSubscriptionEnum, frequency: TBillingFrequency) => void;
|
||||
setIsCompareAllFeaturesSectionOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsScrolled: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const PlansComparison = observer(
|
||||
forwardRef<HTMLDivElement, TPlansComparisonProps>(function PlansComparison(
|
||||
props: TPlansComparisonProps,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
const {
|
||||
isScrolled,
|
||||
isCompareAllFeaturesSectionOpen,
|
||||
getBillingFrequency,
|
||||
setBillingFrequency,
|
||||
setIsCompareAllFeaturesSectionOpen,
|
||||
setIsScrolled,
|
||||
} = props;
|
||||
// plan details
|
||||
const { planDetails } = PLANE_PLANS;
|
||||
|
||||
return (
|
||||
<PlansComparisonBase
|
||||
ref={ref}
|
||||
planeDetails={Object.entries(planDetails).map(([planKey, plan]) => {
|
||||
const currentPlanKey = planKey as TPlanePlans;
|
||||
if (!shouldRenderPlanDetail(currentPlanKey)) return null;
|
||||
return (
|
||||
<PlanDetail
|
||||
key={planKey}
|
||||
subscriptionType={plan.id}
|
||||
planDetail={plan}
|
||||
billingFrequency={getBillingFrequency(plan.id)}
|
||||
setBillingFrequency={(frequency) => setBillingFrequency(plan.id, frequency)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
isSelfManaged
|
||||
isScrolled={isScrolled}
|
||||
isCompareAllFeaturesSectionOpen={isCompareAllFeaturesSectionOpen}
|
||||
setIsCompareAllFeaturesSectionOpen={setIsCompareAllFeaturesSectionOpen}
|
||||
setIsScrolled={setIsScrolled}
|
||||
/>
|
||||
);
|
||||
})
|
||||
);
|
||||
@@ -1,27 +1,100 @@
|
||||
import { MARKETING_PRICING_PAGE_LINK } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/ui";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import {
|
||||
DEFAULT_PRODUCT_BILLING_FREQUENCY,
|
||||
EProductSubscriptionEnum,
|
||||
SUBSCRIPTION_WITH_BILLING_FREQUENCY,
|
||||
} from "@plane/constants";
|
||||
import { TBillingFrequency, TProductBillingFrequency } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { getSubscriptionTextColor } from "@/components/workspace/billing/subscription";
|
||||
// local imports
|
||||
import { PlansComparison } from "./comparison/root";
|
||||
|
||||
export const BillingRoot = observer(() => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isCompareAllFeaturesSectionOpen, setIsCompareAllFeaturesSectionOpen] = useState(false);
|
||||
const [productBillingFrequency, setProductBillingFrequency] = useState<TProductBillingFrequency>(
|
||||
DEFAULT_PRODUCT_BILLING_FREQUENCY
|
||||
);
|
||||
|
||||
/**
|
||||
* Retrieves the billing frequency for a given subscription type
|
||||
* @param {EProductSubscriptionEnum} subscriptionType - Type of subscription to get frequency for
|
||||
* @returns {TBillingFrequency | undefined} - Billing frequency if subscription supports it, undefined otherwise
|
||||
*/
|
||||
const getBillingFrequency = (subscriptionType: EProductSubscriptionEnum): TBillingFrequency | undefined =>
|
||||
SUBSCRIPTION_WITH_BILLING_FREQUENCY.includes(subscriptionType)
|
||||
? productBillingFrequency[subscriptionType]
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Updates the billing frequency for a specific subscription type
|
||||
* @param {EProductSubscriptionEnum} subscriptionType - Type of subscription to update
|
||||
* @param {TBillingFrequency} frequency - New billing frequency to set
|
||||
* @returns {void}
|
||||
*/
|
||||
const setBillingFrequency = (subscriptionType: EProductSubscriptionEnum, frequency: TBillingFrequency): void =>
|
||||
setProductBillingFrequency({ ...productBillingFrequency, [subscriptionType]: frequency });
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const scrollTop = container.scrollTop;
|
||||
const isScrolled = isCompareAllFeaturesSectionOpen ? scrollTop > 0 : false;
|
||||
setIsScrolled(isScrolled);
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll);
|
||||
return () => container.removeEventListener("scroll", handleScroll);
|
||||
}, [isCompareAllFeaturesSectionOpen]);
|
||||
|
||||
export const BillingRoot = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="w-full overflow-y-auto">
|
||||
<section className="relative size-full flex flex-col overflow-y-auto scrollbar-hide">
|
||||
<div>
|
||||
<div className="flex items-center border-b border-custom-border-100 pb-3.5">
|
||||
<h3 className="text-xl font-medium">{t("workspace_settings.settings.billing_and_plans.title")}</h3>
|
||||
<div className="flex items-center">
|
||||
<h3 className="text-xl font-medium flex gap-4">Billing and plans</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-6">
|
||||
<div>
|
||||
<h4 className="text-md mb-1 leading-6">{t("workspace_settings.settings.billing_and_plans.current_plan")}</h4>
|
||||
<p className="mb-3 text-sm text-custom-text-200">
|
||||
{t("workspace_settings.settings.billing_and_plans.free_plan")}
|
||||
</p>
|
||||
<a href={MARKETING_PRICING_PAGE_LINK} target="_blank" rel="noreferrer">
|
||||
<Button variant="neutral-primary">{t("workspace_settings.settings.billing_and_plans.view_plans")}</Button>
|
||||
</a>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-500 ease-in-out will-change-[height,opacity]",
|
||||
isScrolled ? "h-0 opacity-0 pointer-events-none" : "h-[300px] opacity-100"
|
||||
)}
|
||||
>
|
||||
<div className="py-6">
|
||||
<div className={cn("px-6 py-4 border border-custom-border-200 rounded-lg")}>
|
||||
<div className="flex gap-2 font-medium items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4
|
||||
className={cn("text-xl leading-6 font-bold", getSubscriptionTextColor(EProductSubscriptionEnum.FREE))}
|
||||
>
|
||||
Community
|
||||
</h4>
|
||||
<div className="text-sm text-custom-text-200 font-medium">
|
||||
Unlimited projects, issues, cycles, modules, pages, and storage
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xl font-semibold mt-3">All plans</div>
|
||||
</div>
|
||||
<PlansComparison
|
||||
ref={containerRef}
|
||||
isScrolled={isScrolled}
|
||||
isCompareAllFeaturesSectionOpen={isCompareAllFeaturesSectionOpen}
|
||||
getBillingFrequency={getBillingFrequency}
|
||||
setBillingFrequency={setBillingFrequency}
|
||||
setIsCompareAllFeaturesSectionOpen={setIsCompareAllFeaturesSectionOpen}
|
||||
setIsScrolled={setIsScrolled}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import packageJson from "package.json";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Button, Tooltip } from "@plane/ui";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// assets
|
||||
// local components
|
||||
import { PaidPlanUpgradeModal } from "./upgrade";
|
||||
import { PaidPlanUpgradeModal } from "../license";
|
||||
|
||||
export const WorkspaceEditionBadge = observer(() => {
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false);
|
||||
|
||||
@@ -29,7 +26,7 @@ export const WorkspaceEditionBadge = observer(() => {
|
||||
className="w-fit min-w-24 cursor-pointer rounded-2xl px-2 py-1 text-center text-sm font-medium outline-none"
|
||||
onClick={() => setIsPaidPlanPurchaseModalOpen(true)}
|
||||
>
|
||||
{t("sidebar.upgrade")}
|
||||
Community
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./pro-plan-upgrade";
|
||||
export * from "./one-plan-upgrade";
|
||||
export * from "./paid-plans-upgrade-modal";
|
||||
@@ -1,55 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import { CheckCircle } from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
export type OnePlanUpgradeProps = {
|
||||
features: string[];
|
||||
verticalFeatureList?: boolean;
|
||||
extraFeatures?: string | React.ReactNode;
|
||||
};
|
||||
|
||||
export const OnePlanUpgrade: FC<OnePlanUpgradeProps> = (props) => {
|
||||
const { features, verticalFeatureList = false, extraFeatures } = props;
|
||||
// env
|
||||
const PLANE_ONE_PAYMENT_URL = "https://prime.plane.so/";
|
||||
|
||||
return (
|
||||
<div className="py-4 px-2 border border-custom-border-90 rounded-xl bg-custom-background-90">
|
||||
<div className="flex w-full justify-center h-10" />
|
||||
<div className="pt-6 pb-4 text-center font-semibold">
|
||||
<div className="text-2xl">Plane One</div>
|
||||
<div className="text-3xl">$799</div>
|
||||
<div className="text-sm text-custom-text-300">for two years’ support and updates</div>
|
||||
</div>
|
||||
<div className="flex justify-center w-full">
|
||||
<a
|
||||
href={PLANE_ONE_PAYMENT_URL}
|
||||
target="_blank"
|
||||
className="relative inline-flex items-center justify-center w-56 px-4 py-2.5 text-white text-sm font-medium border border-[#525252] bg-gradient-to-r from-[#353535] via-[#1111118C] to-[#21212153] rounded-lg focus:outline-none"
|
||||
>
|
||||
Upgrade to One
|
||||
</a>
|
||||
</div>
|
||||
<div className="px-2 pt-6 pb-2">
|
||||
<div className="p-2 text-sm font-semibold">Everything in Free +</div>
|
||||
<ul className="w-full grid grid-cols-12 gap-x-4">
|
||||
{features.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className={cn("col-span-12 relative rounded-md p-2 flex", {
|
||||
"sm:col-span-6": !verticalFeatureList,
|
||||
})}
|
||||
>
|
||||
<p className="w-full text-sm font-medium leading-5 flex items-center">
|
||||
<CheckCircle className="h-4 w-4 mr-4 text-custom-text-300 flex-shrink-0" />
|
||||
<span className="text-custom-text-200 truncate">{feature}</span>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{extraFeatures && <div>{extraFeatures}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,113 +0,0 @@
|
||||
import { FC } from "react";
|
||||
// types
|
||||
import { CircleX } from "lucide-react";
|
||||
// services
|
||||
import { EModalWidth, ModalCore } from "@plane/ui";
|
||||
// plane web components
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// local components
|
||||
import { OnePlanUpgrade } from "./one-plan-upgrade";
|
||||
import { ProPlanUpgrade } from "./pro-plan-upgrade";
|
||||
|
||||
const PRO_PLAN_FEATURES = [
|
||||
"More Cycles features",
|
||||
"Full Time Tracking + Bulk Ops",
|
||||
"Workflow manager",
|
||||
"Automations",
|
||||
"Popular integrations",
|
||||
"Plane AI",
|
||||
];
|
||||
|
||||
const ONE_PLAN_FEATURES = [
|
||||
"OIDC + SAML for SSO",
|
||||
"Active Cycles",
|
||||
"Real-time collab + public views and page",
|
||||
"Link pages in work items and vice-versa",
|
||||
"Time-tracking + limited bulk ops",
|
||||
"Docker, Kubernetes and more",
|
||||
];
|
||||
|
||||
const FREE_PLAN_UPGRADE_FEATURES = [
|
||||
"OIDC + SAML for SSO",
|
||||
"Time tracking and bulk ops",
|
||||
"Integrations",
|
||||
"Public views and pages",
|
||||
];
|
||||
|
||||
export type PaidPlanUpgradeModalProps = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const PaidPlanUpgradeModal: FC<PaidPlanUpgradeModalProps> = (props) => {
|
||||
const { isOpen, handleClose } = props;
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.VIXL} className="rounded-2xl">
|
||||
<div className="p-10 max-h-[90vh] overflow-auto">
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<div className="col-span-12 md:col-span-4">
|
||||
<div className="text-3xl font-bold leading-8 flex">Upgrade to a paid plan and unlock missing features.</div>
|
||||
<div className="mt-4 mb-12">
|
||||
<p className="text-sm mb-4 pr-8 text-custom-text-100">
|
||||
Active Cycles, time tracking, bulk ops, and other features are waiting for you on one of our paid plans.
|
||||
Upgrade today to unlock features your teams need yesterday.
|
||||
</p>
|
||||
</div>
|
||||
{/* Free plan details */}
|
||||
<div className="py-4 px-2 border border-custom-border-90 rounded-xl">
|
||||
<div className="py-2 px-3">
|
||||
<span className="px-2 py-1 bg-custom-background-90 text-sm text-custom-text-300 font-medium rounded">
|
||||
Your plan
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 font-semibold">
|
||||
<div className="text-3xl">Free</div>
|
||||
<div className="text-sm text-custom-text-300">$0 a user per month</div>
|
||||
</div>
|
||||
<div className="px-2 pt-2 pb-3">
|
||||
<ul className="w-full grid grid-cols-12 gap-x-4">
|
||||
{FREE_PLAN_UPGRADE_FEATURES.map((feature) => (
|
||||
<li key={feature} className={cn("col-span-12 relative rounded-md p-2 flex")}>
|
||||
<p className="w-full text-sm font-medium leading-5 flex items-center">
|
||||
<CircleX className="h-4 w-4 mr-4 text-red-500 flex-shrink-0" />
|
||||
<span className="text-custom-text-200 truncate">{feature}</span>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-12 md:col-span-4">
|
||||
<ProPlanUpgrade
|
||||
basePlan="One"
|
||||
features={PRO_PLAN_FEATURES}
|
||||
verticalFeatureList
|
||||
extraFeatures={
|
||||
<p className="pt-1.5 text-center text-xs text-custom-primary-200 font-semibold underline">
|
||||
<a href="https://plane.so/pro" target="_blank">
|
||||
See full features list
|
||||
</a>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-12 md:col-span-4">
|
||||
<OnePlanUpgrade
|
||||
features={ONE_PLAN_FEATURES}
|
||||
verticalFeatureList
|
||||
extraFeatures={
|
||||
<p className="pt-1.5 text-center text-xs text-custom-primary-200 font-semibold underline">
|
||||
<a href="https://plane.so/one" target="_blank">
|
||||
See full features list
|
||||
</a>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useState } from "react";
|
||||
import { CheckCircle } from "lucide-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
export type ProPlanUpgradeProps = {
|
||||
basePlan: "Free" | "One";
|
||||
features: string[];
|
||||
verticalFeatureList?: boolean;
|
||||
extraFeatures?: string | React.ReactNode;
|
||||
};
|
||||
|
||||
type TProPiceFrequency = "month" | "year";
|
||||
|
||||
type TProPlanPrice = {
|
||||
key: string;
|
||||
currency: string;
|
||||
price: number;
|
||||
recurring: TProPiceFrequency;
|
||||
};
|
||||
|
||||
// constants
|
||||
export const calculateYearlyDiscount = (monthlyPrice: number, yearlyPricePerMonth: number): number => {
|
||||
const monthlyCost = monthlyPrice * 12;
|
||||
const yearlyCost = yearlyPricePerMonth * 12;
|
||||
const amountSaved = monthlyCost - yearlyCost;
|
||||
const discountPercentage = (amountSaved / monthlyCost) * 100;
|
||||
return Math.floor(discountPercentage);
|
||||
};
|
||||
|
||||
const PRO_PLAN_PRICES: TProPlanPrice[] = [
|
||||
{ key: "monthly", currency: "$", price: 8, recurring: "month" },
|
||||
{ key: "yearly", currency: "$", price: 6, recurring: "year" },
|
||||
];
|
||||
|
||||
export const ProPlanUpgrade: FC<ProPlanUpgradeProps> = (props) => {
|
||||
const { basePlan, features, verticalFeatureList = false, extraFeatures } = props;
|
||||
// states
|
||||
const [selectedPlan, setSelectedPlan] = useState<TProPiceFrequency>("month");
|
||||
// derived
|
||||
const monthlyPrice = PRO_PLAN_PRICES.find((price) => price.recurring === "month")?.price ?? 0;
|
||||
const yearlyPrice = PRO_PLAN_PRICES.find((price) => price.recurring === "year")?.price ?? 0;
|
||||
const yearlyDiscount = calculateYearlyDiscount(monthlyPrice, yearlyPrice);
|
||||
// env
|
||||
const PRO_PLAN_MONTHLY_PAYMENT_URL = "https://app.plane.so/upgrade/pro/self-hosted?plan=month";
|
||||
const PRO_PLAN_YEARLY_PAYMENT_URL = "https://app.plane.so/upgrade/pro/self-hosted?plan=year";
|
||||
|
||||
return (
|
||||
<div className="py-4 px-2 border border-custom-primary-200/30 rounded-xl bg-custom-primary-200/5">
|
||||
<Tab.Group>
|
||||
<div className="flex w-full justify-center h-10">
|
||||
<Tab.List className="flex space-x-1 rounded-lg bg-custom-primary-200/10 p-1 w-60">
|
||||
{PRO_PLAN_PRICES.map((price: TProPlanPrice) => (
|
||||
<Tab
|
||||
key={price.key}
|
||||
className={({ selected }) =>
|
||||
cn(
|
||||
"w-full rounded-lg py-1.5 text-sm font-medium leading-5",
|
||||
selected
|
||||
? "bg-custom-background-100 text-custom-primary-300 shadow"
|
||||
: "hover:bg-custom-primary-100/5 text-custom-text-300 hover:text-custom-text-200"
|
||||
)
|
||||
}
|
||||
onClick={() => setSelectedPlan(price.recurring)}
|
||||
>
|
||||
<>
|
||||
{price.recurring === "month" && ("Monthly" as string)}
|
||||
{price.recurring === "year" && ("Yearly" as string)}
|
||||
{price.recurring === "year" && (
|
||||
<span className="bg-gradient-to-r from-[#C78401] to-[#896828] text-white rounded-full px-2 py-1 ml-1 text-xs">
|
||||
-{yearlyDiscount}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
</div>
|
||||
<Tab.Panels>
|
||||
{PRO_PLAN_PRICES.map((price: TProPlanPrice) => (
|
||||
<Tab.Panel key={price.key}>
|
||||
<div className="pt-6 pb-4 text-center font-semibold">
|
||||
<div className="text-2xl">Plane Pro</div>
|
||||
<div className="text-3xl">
|
||||
{price.currency}
|
||||
{price.price}
|
||||
</div>
|
||||
<div className="text-sm text-custom-text-300">a user per month</div>
|
||||
</div>
|
||||
<div className="flex justify-center w-full">
|
||||
<a
|
||||
href={selectedPlan === "month" ? PRO_PLAN_MONTHLY_PAYMENT_URL : PRO_PLAN_YEARLY_PAYMENT_URL}
|
||||
target="_blank"
|
||||
className="relative inline-flex items-center justify-center w-56 px-4 py-2.5 text-white text-sm font-medium border border-[#E9DBBF99]/60 bg-gradient-to-r from-[#C78401] to-[#896828] rounded-lg focus:outline-none"
|
||||
>
|
||||
Upgrade to Pro
|
||||
</a>
|
||||
</div>
|
||||
<div className="px-2 pt-6 pb-2">
|
||||
<div className="p-2 text-sm font-semibold">{`Everything in ${basePlan} +`}</div>
|
||||
<ul className="grid grid-cols-12 gap-x-4">
|
||||
{features.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className={cn("col-span-12 relative rounded-md p-2 flex", {
|
||||
"sm:col-span-6": !verticalFeatureList,
|
||||
})}
|
||||
>
|
||||
<p className="w-full text-sm font-medium leading-5 flex items-center line-clamp-1">
|
||||
<CheckCircle className="h-4 w-4 mr-4 text-custom-text-300 flex-shrink-0" />
|
||||
<span className="text-custom-text-200 truncate">{feature}</span>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{extraFeatures && <div>{extraFeatures}</div>}
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -23,9 +23,11 @@ export const estimateCount = {
|
||||
export const ESTIMATE_SYSTEMS: TEstimateSystems = {
|
||||
points: {
|
||||
name: "Points",
|
||||
i18n_name: "project_settings.estimates.systems.points.label",
|
||||
templates: {
|
||||
fibonacci: {
|
||||
title: "Fibonacci",
|
||||
i18n_title: "project_settings.estimates.systems.points.fibonacci",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "1" },
|
||||
{ id: undefined, key: 2, value: "2" },
|
||||
@@ -37,6 +39,7 @@ export const ESTIMATE_SYSTEMS: TEstimateSystems = {
|
||||
},
|
||||
linear: {
|
||||
title: "Linear",
|
||||
i18n_title: "project_settings.estimates.systems.points.linear",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "1" },
|
||||
{ id: undefined, key: 2, value: "2" },
|
||||
@@ -48,6 +51,7 @@ export const ESTIMATE_SYSTEMS: TEstimateSystems = {
|
||||
},
|
||||
squares: {
|
||||
title: "Squares",
|
||||
i18n_title: "project_settings.estimates.systems.points.squares",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "1" },
|
||||
{ id: undefined, key: 2, value: "4" },
|
||||
@@ -59,6 +63,7 @@ export const ESTIMATE_SYSTEMS: TEstimateSystems = {
|
||||
},
|
||||
custom: {
|
||||
title: "Custom",
|
||||
i18n_title: "project_settings.estimates.systems.points.custom",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "1" },
|
||||
{ id: undefined, key: 2, value: "2" },
|
||||
@@ -71,9 +76,11 @@ export const ESTIMATE_SYSTEMS: TEstimateSystems = {
|
||||
},
|
||||
categories: {
|
||||
name: "Categories",
|
||||
i18n_name: "project_settings.estimates.systems.categories.label",
|
||||
templates: {
|
||||
t_shirt_sizes: {
|
||||
title: "T-Shirt Sizes",
|
||||
i18n_title: "project_settings.estimates.systems.categories.t_shirt_sizes",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "XS" },
|
||||
{ id: undefined, key: 2, value: "S" },
|
||||
@@ -85,6 +92,7 @@ export const ESTIMATE_SYSTEMS: TEstimateSystems = {
|
||||
},
|
||||
easy_to_hard: {
|
||||
title: "Easy to hard",
|
||||
i18n_title: "project_settings.estimates.systems.categories.easy_to_hard",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "Easy" },
|
||||
{ id: undefined, key: 2, value: "Medium" },
|
||||
@@ -94,6 +102,7 @@ export const ESTIMATE_SYSTEMS: TEstimateSystems = {
|
||||
},
|
||||
custom: {
|
||||
title: "Custom",
|
||||
i18n_title: "project_settings.estimates.systems.categories.custom",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "Easy" },
|
||||
{ id: undefined, key: 2, value: "Hard" },
|
||||
@@ -106,9 +115,11 @@ export const ESTIMATE_SYSTEMS: TEstimateSystems = {
|
||||
},
|
||||
time: {
|
||||
name: "Time",
|
||||
i18n_name: "project_settings.estimates.systems.time.label",
|
||||
templates: {
|
||||
hours: {
|
||||
title: "Hours",
|
||||
i18n_title: "project_settings.estimates.systems.time.hours",
|
||||
values: [
|
||||
{ id: undefined, key: 1, value: "1" },
|
||||
{ id: undefined, key: 2, value: "2" },
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const shouldRenderSettingLink = (settingKey: string) => true;
|
||||
export type TRenderSettingsLink = (workspaceSlug: string, settingKey: string) => boolean;
|
||||
export const shouldRenderSettingLink: TRenderSettingsLink = (workspaceSlug, settingKey) => true;
|
||||
@@ -40,7 +40,7 @@ export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) =
|
||||
{WORKSPACE_SETTINGS_LINKS.map(
|
||||
(setting) =>
|
||||
allowPermissions(setting.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) &&
|
||||
shouldRenderSettingLink(setting.key) && (
|
||||
shouldRenderSettingLink(workspaceSlug.toString(), setting.key) && (
|
||||
<Command.Item
|
||||
key={setting.key}
|
||||
onSelect={() => redirect(`/${workspaceSlug}${setting.href}`)}
|
||||
|
||||
@@ -161,7 +161,9 @@ export const CommentCard: FC<TCommentCard> = observer((props) => {
|
||||
return asset_id;
|
||||
}}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
/>
|
||||
editorClassName="[&>*]:!py-0 [&>*]:!text-sm"
|
||||
parentClassName="p-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 self-end">
|
||||
{!isEmpty && (
|
||||
@@ -181,7 +183,10 @@ export const CommentCard: FC<TCommentCard> = observer((props) => {
|
||||
<button
|
||||
type="button"
|
||||
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
|
||||
onClick={() => setIsEditing(false)}
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
editorRef.current?.setEditorValue(comment.comment_html ?? "<p></p>");
|
||||
}}
|
||||
>
|
||||
<X className="size-3 text-red-500 duration-300 group-hover:text-white" />
|
||||
</button>
|
||||
|
||||
@@ -5,15 +5,17 @@ import { useForm, Controller } from "react-hook-form";
|
||||
import { EIssueCommentAccessSpecifier } from "@plane/constants";
|
||||
// plane editor
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
// components
|
||||
// plane types
|
||||
import { TIssueComment, TCommentsOperations } from "@plane/types";
|
||||
// components
|
||||
import { LiteTextEditor } from "@/components/editor";
|
||||
// constants
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// helpers
|
||||
import { isCommentEmpty } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
|
||||
type TCommentCreate = {
|
||||
@@ -22,12 +24,21 @@ type TCommentCreate = {
|
||||
activityOperations: TCommentsOperations;
|
||||
showToolbarInitially?: boolean;
|
||||
projectId?: string;
|
||||
onSubmitCallback?: (elementId: string) => void;
|
||||
};
|
||||
|
||||
// services
|
||||
const fileService = new FileService();
|
||||
|
||||
export const CommentCreate: FC<TCommentCreate> = observer((props) => {
|
||||
const { workspaceSlug, entityId, activityOperations, showToolbarInitially = false, projectId } = props;
|
||||
const {
|
||||
workspaceSlug,
|
||||
entityId,
|
||||
activityOperations,
|
||||
showToolbarInitially = false,
|
||||
projectId,
|
||||
onSubmitCallback,
|
||||
} = props;
|
||||
// states
|
||||
const [uploadedAssetIds, setUploadedAssetIds] = useState<string[]>([]);
|
||||
// refs
|
||||
@@ -51,7 +62,8 @@ export const CommentCreate: FC<TCommentCreate> = observer((props) => {
|
||||
|
||||
const onSubmit = async (formData: Partial<TIssueComment>) => {
|
||||
try {
|
||||
await activityOperations.createComment(formData);
|
||||
const comment = await activityOperations.createComment(formData);
|
||||
if (comment?.id) onSubmitCallback?.(comment.id);
|
||||
if (uploadedAssetIds.length > 0) {
|
||||
if (projectId) {
|
||||
await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId.toString(), entityId, {
|
||||
@@ -81,7 +93,15 @@ export const CommentCreate: FC<TCommentCreate> = observer((props) => {
|
||||
<div
|
||||
className={cn("sticky bottom-0 z-[4] bg-custom-background-100 sm:static")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty && !isSubmitting)
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
!isEmpty &&
|
||||
!isSubmitting &&
|
||||
editorRef.current?.isEditorReadyToDiscard()
|
||||
)
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React, { FC } from "react";
|
||||
import React, { FC, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import smoothScrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import { E_SORT_ORDER } from "@plane/constants";
|
||||
import { TCommentsOperations, TIssueComment } from "@plane/types";
|
||||
// local components
|
||||
import { CommentCard } from "./comment-card";
|
||||
@@ -15,6 +17,7 @@ type TCommentsWrapper = {
|
||||
isEditingAllowed?: boolean;
|
||||
activityOperations: TCommentsOperations;
|
||||
comments: TIssueComment[] | string[];
|
||||
sortOrder?: E_SORT_ORDER;
|
||||
getCommentById?: (activityId: string) => TIssueComment | undefined;
|
||||
};
|
||||
|
||||
@@ -23,18 +26,28 @@ export const CommentsWrapper: FC<TCommentsWrapper> = observer((props) => {
|
||||
// router
|
||||
const { workspaceSlug: routerWorkspaceSlug } = useParams();
|
||||
const workspaceSlug = routerWorkspaceSlug?.toString();
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-y-2 h-full overflow-hidden">
|
||||
{isEditingAllowed && (
|
||||
const renderCommentCreate = useMemo(
|
||||
() =>
|
||||
isEditingAllowed && (
|
||||
<CommentCreate
|
||||
workspaceSlug={workspaceSlug}
|
||||
entityId={entityId}
|
||||
activityOperations={activityOperations}
|
||||
projectId={projectId}
|
||||
onSubmitCallback={async (elementId: string) => {
|
||||
const sourceElementId = elementId ?? "";
|
||||
const sourceElement = document.getElementById(sourceElementId);
|
||||
if (sourceElement)
|
||||
await smoothScrollIntoView(sourceElement, { behavior: "smooth", block: "center", duration: 1500 });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
),
|
||||
[isEditingAllowed, workspaceSlug, entityId, activityOperations, projectId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-y-2 h-full overflow-hidden">
|
||||
{renderCommentCreate}
|
||||
<div className="flex-grow py-4 overflow-y-auto">
|
||||
{comments?.map((data, index) => {
|
||||
let comment;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TDescriptionVersion } from "@plane/types";
|
||||
import { Avatar, CustomMenu } from "@plane/ui";
|
||||
import { calculateTimeAgo, getFileURL } from "@plane/utils";
|
||||
@@ -17,11 +18,17 @@ export const DescriptionVersionsDropdownItem: React.FC<Props> = observer((props)
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const versionCreator = version.owned_by ? getUserDetails(version.owned_by) : null;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<CustomMenu.MenuItem key={version.id} className="flex items-center gap-1" onClick={() => onClick(version.id)}>
|
||||
<span className="flex-shrink-0">
|
||||
<Avatar name={versionCreator?.display_name} size="sm" src={getFileURL(versionCreator?.avatar_url ?? "")} />
|
||||
<Avatar
|
||||
name={versionCreator?.display_name ?? t("common.deactivated_user")}
|
||||
size="sm"
|
||||
src={getFileURL(versionCreator?.avatar_url ?? "")}
|
||||
/>
|
||||
</span>
|
||||
<p className="text-xs text-custom-text-200 flex items-center gap-1.5">
|
||||
<span className="font-medium">{versionCreator?.display_name}</span>
|
||||
|
||||
@@ -25,7 +25,9 @@ export const DescriptionVersionsDropdown: React.FC<Props> = observer((props) =>
|
||||
// derived values
|
||||
const latestVersion = versions?.[0];
|
||||
const lastUpdatedAt = latestVersion?.created_at ?? entityInformation.createdAt;
|
||||
const lastUpdatedByUserDetails = getUserDetails(latestVersion?.owned_by ?? entityInformation.createdBy);
|
||||
const lastUpdatedByUserDisplayName = latestVersion?.owned_by
|
||||
? getUserDetails(latestVersion?.owned_by)?.display_name
|
||||
: entityInformation.createdByDisplayName;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -38,8 +40,7 @@ export const DescriptionVersionsDropdown: React.FC<Props> = observer((props) =>
|
||||
</span>
|
||||
<p className="text-xs">
|
||||
{t("description_versions.last_edited_by")}{" "}
|
||||
<span className="font-medium">{lastUpdatedByUserDetails?.display_name}</span>{" "}
|
||||
{calculateTimeAgo(lastUpdatedAt)}
|
||||
<span className="font-medium">{lastUpdatedByUserDisplayName}</span> {calculateTimeAgo(lastUpdatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ export const DescriptionVersionsModal: React.FC<Props> = observer((props) => {
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={1}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{!isRestoreDisabled && (
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DescriptionVersionsModal } from "./modal";
|
||||
|
||||
export type TDescriptionVersionEntityInformation = {
|
||||
createdAt: Date;
|
||||
createdBy: string;
|
||||
createdByDisplayName: string;
|
||||
id: string;
|
||||
isRestoreDisabled: boolean;
|
||||
};
|
||||
|
||||
@@ -30,13 +30,13 @@ const useCyclesDetails = (props: IActiveCycleDetails) => {
|
||||
|
||||
// fetch cycle details
|
||||
useSWR(
|
||||
workspaceSlug && projectId && cycle?.id ? `PROJECT_ACTIVE_CYCLE_${projectId}_PROGRESS` : null,
|
||||
workspaceSlug && projectId && cycle?.id ? `PROJECT_ACTIVE_CYCLE_${projectId}_PROGRESS_${cycle.id}` : null,
|
||||
workspaceSlug && projectId && cycle?.id ? () => fetchActiveCycleProgress(workspaceSlug, projectId, cycle.id) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
useSWR(
|
||||
workspaceSlug && projectId && cycle?.id && !cycle?.distribution
|
||||
? `PROJECT_ACTIVE_CYCLE_${projectId}_DURATION`
|
||||
? `PROJECT_ACTIVE_CYCLE_${projectId}_DURATION_${cycle.id}`
|
||||
: null,
|
||||
workspaceSlug && projectId && cycle?.id && !cycle?.distribution
|
||||
? () => fetchActiveCycleAnalytics(workspaceSlug, projectId, cycle.id, "issues")
|
||||
@@ -44,7 +44,7 @@ const useCyclesDetails = (props: IActiveCycleDetails) => {
|
||||
);
|
||||
useSWR(
|
||||
workspaceSlug && projectId && cycle?.id && !cycle?.estimate_distribution
|
||||
? `PROJECT_ACTIVE_CYCLE_${projectId}_ESTIMATE_DURATION`
|
||||
? `PROJECT_ACTIVE_CYCLE_${projectId}_ESTIMATE_DURATION_${cycle.id}`
|
||||
: null,
|
||||
workspaceSlug && projectId && cycle?.id && !cycle?.estimate_distribution
|
||||
? () => fetchActiveCycleAnalytics(workspaceSlug, projectId, cycle.id, "points")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FC, Fragment, useCallback, useMemo } from "react";
|
||||
import { FC, useCallback, useMemo } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observer } from "mobx-react";
|
||||
@@ -133,7 +133,7 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
|
||||
if (!cycleDetails) return <></>;
|
||||
return (
|
||||
<div className="border-t border-custom-border-200 space-y-4 py-5">
|
||||
<Disclosure defaultOpen={isCycleDateValid ? true : false}>
|
||||
<Disclosure defaultOpen>
|
||||
{({ open }) => (
|
||||
<div className="flex flex-col">
|
||||
{/* progress bar header */}
|
||||
@@ -161,24 +161,34 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
|
||||
)}
|
||||
|
||||
<Transition show={open}>
|
||||
<Disclosure.Panel className="flex flex-col">
|
||||
<SidebarChartRoot workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
|
||||
{/* progress detailed view */}
|
||||
{chartDistributionData && (
|
||||
<div className="w-full border-t border-custom-border-200 py-4">
|
||||
<CycleProgressStats
|
||||
cycleId={cycleId}
|
||||
plotType={plotType}
|
||||
distribution={chartDistributionData}
|
||||
groupedIssues={groupedIssues}
|
||||
totalIssuesCount={estimateType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
|
||||
isEditable={Boolean(!peekCycle)}
|
||||
size="xs"
|
||||
roundedTab={false}
|
||||
noBackground={false}
|
||||
filters={issueFilters}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
/>
|
||||
<Disclosure.Panel className="flex flex-col divide-y divide-custom-border-200">
|
||||
{cycleStartDate && cycleEndDate ? (
|
||||
<>
|
||||
{isCycleDateValid && (
|
||||
<SidebarChartRoot workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
|
||||
)}
|
||||
{/* progress detailed view */}
|
||||
{chartDistributionData && (
|
||||
<div className="w-full py-4">
|
||||
<CycleProgressStats
|
||||
cycleId={cycleId}
|
||||
plotType={plotType}
|
||||
distribution={chartDistributionData}
|
||||
groupedIssues={groupedIssues}
|
||||
totalIssuesCount={estimateType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
|
||||
isEditable={Boolean(!peekCycle)}
|
||||
size="xs"
|
||||
roundedTab={false}
|
||||
noBackground={false}
|
||||
filters={issueFilters}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="my-2 py-2 text-sm text-custom-text-350 bg-custom-background-90 rounded-md px-2 w-full">
|
||||
{t("no_data_yet")}
|
||||
</div>
|
||||
)}
|
||||
</Disclosure.Panel>
|
||||
|
||||
@@ -125,7 +125,7 @@ export const CycleSidebarDetails: FC<Props> = observer((props) => {
|
||||
{/**
|
||||
* NOTE: Render this section when estimate points of he projects is enabled and the estimate system is points
|
||||
*/}
|
||||
{isEstimatePointValid && (
|
||||
{isEstimatePointValid && !isCompleted && (
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
|
||||
<LayersIcon className="h-4 w-4" />
|
||||
|
||||
@@ -90,13 +90,15 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { currentActiveEstimateIdByProjectId, getProjectEstimates, currentActiveEstimate } = useProjectEstimates();
|
||||
const { currentActiveEstimateIdByProjectId, getProjectEstimates, getEstimateById } = useProjectEstimates();
|
||||
const { estimatePointIds, estimatePointById } = useEstimate(
|
||||
projectId ? currentActiveEstimateIdByProjectId(projectId) : undefined
|
||||
);
|
||||
|
||||
const currentActiveEstimateId = projectId ? currentActiveEstimateIdByProjectId(projectId) : undefined;
|
||||
|
||||
const currentActiveEstimate = currentActiveEstimateId ? getEstimateById(currentActiveEstimateId) : undefined;
|
||||
|
||||
const options: DropdownOptions = (estimatePointIds ?? [])
|
||||
?.map((estimatePoint) => {
|
||||
const currentEstimatePoint = estimatePointById(estimatePoint);
|
||||
|
||||
@@ -153,9 +153,14 @@ export const CreateEstimateModal: FC<TCreateEstimateModal> = observer((props) =>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xl font-medium text-custom-text-100">New estimate system</div>
|
||||
<div className="text-xl font-medium text-custom-text-100">{t("project_settings.estimates.new")}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{t("project_settings.estimates.create.step", {
|
||||
step: renderEstimateStepsCount,
|
||||
total: 2,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">Step {renderEstimateStepsCount} of 2</div>
|
||||
</div>
|
||||
|
||||
{/* estimate steps */}
|
||||
@@ -191,11 +196,11 @@ export const CreateEstimateModal: FC<TCreateEstimateModal> = observer((props) =>
|
||||
|
||||
<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
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{estimatePoints && (
|
||||
<Button variant="primary" size="sm" onClick={handleCreateEstimate} disabled={buttonLoader}>
|
||||
{buttonLoader ? `Creating` : `Create estimate`}
|
||||
{buttonLoader ? t("common.creating") : t("project_settings.estimates.create.label")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -38,18 +38,18 @@ export const EstimateCreateStageOne: FC<TEstimateCreateStageOne> = (props) => {
|
||||
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}
|
||||
{t(ESTIMATE_SYSTEMS[currentSystem]?.i18n_name)}
|
||||
<Tooltip tooltipContent={t("common.coming_soon")}>
|
||||
<Info size={12} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : !isEnabled ? (
|
||||
<div className="relative flex items-center gap-2 cursor-no-drop text-custom-text-300">
|
||||
{ESTIMATE_SYSTEMS[currentSystem]?.name}
|
||||
{t(ESTIMATE_SYSTEMS[currentSystem]?.i18n_name)}
|
||||
<UpgradeBadge />
|
||||
</div>
|
||||
) : (
|
||||
<div>{ESTIMATE_SYSTEMS[currentSystem]?.name}</div>
|
||||
<div>{t(ESTIMATE_SYSTEMS[currentSystem]?.i18n_name)}</div>
|
||||
),
|
||||
value: system,
|
||||
disabled: !isEnabled,
|
||||
@@ -78,7 +78,7 @@ export const EstimateCreateStageOne: FC<TEstimateCreateStageOne> = (props) => {
|
||||
<p className="text-base font-medium">{t("project_settings.estimates.create.custom")}</p>
|
||||
<p className="text-xs text-custom-text-300">
|
||||
{/* TODO: Translate here */}
|
||||
Add your own <span className="lowercase">{currentEstimateSystem.name}</span> from scratch
|
||||
Add your own <span className="lowercase">{currentEstimateSystem.name}</span> from scratch.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
@@ -100,7 +100,7 @@ export const EstimateCreateStageOne: FC<TEstimateCreateStageOne> = (props) => {
|
||||
{currentEstimateSystem.templates[name]?.values
|
||||
?.map((template) =>
|
||||
estimateSystem === EEstimateSystem.TIME
|
||||
? convertMinutesToHoursMinutesString(Number(template.value))
|
||||
? convertMinutesToHoursMinutesString(Number(template.value)).trim()
|
||||
: template.value
|
||||
)
|
||||
?.join(", ")}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const EstimateTextInput: FC<TEstimateTextInputProps> = (props) => {
|
||||
value={value}
|
||||
onChange={(e) => handleEstimateInputValue(e.target.value)}
|
||||
className="border-none focus:ring-0 focus:border-0 focus:outline-none px-3 py-2 w-full bg-transparent text-sm"
|
||||
placeholder={t("project_settings.estimates.create.enter_estimate_input")}
|
||||
placeholder={t("project_settings.estimates.create.enter_estimate_point")}
|
||||
autoFocus
|
||||
type="text"
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Dispatch, SetStateAction, useEffect, useMemo, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
// plane imports
|
||||
import { ISSUE_ARCHIVED, ISSUE_DELETED } from "@plane/constants";
|
||||
import { EInboxIssueSource, ISSUE_ARCHIVED, ISSUE_DELETED } from "@plane/constants";
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
import { TIssue, TNameDescriptionLoader } from "@plane/types";
|
||||
import { Loader, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
// helpers
|
||||
import { getTextContent } from "@/helpers/editor.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useIssueDetail, useProject, useProjectInbox, useUser } from "@/hooks/store";
|
||||
import { useEventTracker, useIssueDetail, useMember, useProject, useProjectInbox, useUser } from "@/hooks/store";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
// store types
|
||||
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe";
|
||||
@@ -51,6 +51,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { getUserDetails } = useMember();
|
||||
const { loader } = useProjectInbox();
|
||||
const { getProjectById } = useProject();
|
||||
const { removeIssue, archiveIssue } = useIssueDetail();
|
||||
@@ -231,8 +232,11 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||
<DescriptionVersionsRoot
|
||||
className="flex-shrink-0"
|
||||
entityInformation={{
|
||||
createdAt: new Date(issue.created_at ?? ""),
|
||||
createdBy: issue.created_by ?? "",
|
||||
createdAt: issue.created_at ? new Date(issue.created_at) : new Date(),
|
||||
createdByDisplayName:
|
||||
inboxIssue.source === EInboxIssueSource.FORMS
|
||||
? "Intake Form user"
|
||||
: (getUserDetails(issue.created_by ?? "")?.display_name ?? ""),
|
||||
id: issue.id,
|
||||
isRestoreDisabled: !isEditable,
|
||||
}}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
import { useLabel, useMember, useProjectInbox } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { InboxSourcePill } from "@/plane-web/components/inbox/source-pill";
|
||||
|
||||
type InboxIssueListItemProps = {
|
||||
workspaceSlug: string;
|
||||
@@ -65,7 +66,10 @@ export const InboxIssueListItem: FC<InboxIssueListItemProps> = observer((props)
|
||||
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
|
||||
{projectIdentifier}-{issue.sequence_id}
|
||||
</div>
|
||||
{inboxIssue.status !== -2 && <InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />}
|
||||
<div className="flex items-center gap-2">
|
||||
{inboxIssue.source && <InboxSourcePill source={inboxIssue.source} />}
|
||||
{inboxIssue.status !== -2 && <InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="truncate w-full text-sm">{issue.name}</h3>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { FC } from "react";
|
||||
import { Layers, Link, Paperclip, Waypoints } from "lucide-react";
|
||||
//i18n
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TIssueServiceType } from "@plane/types";
|
||||
// components
|
||||
import {
|
||||
IssueAttachmentActionButton,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
RelationActionButton,
|
||||
SubIssuesActionButton,
|
||||
IssueDetailWidgetButton,
|
||||
TWorkItemWidgets,
|
||||
} from "@/components/issues/issue-detail-widgets";
|
||||
|
||||
type Props = {
|
||||
@@ -17,58 +20,74 @@ type Props = {
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
hideWidgets?: TWorkItemWidgets[];
|
||||
};
|
||||
|
||||
export const IssueDetailWidgetActionButtons: FC<Props> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled } = props;
|
||||
const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
<SubIssuesActionButton
|
||||
issueId={issueId}
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title={t("issue.add.sub_issue")}
|
||||
icon={<Layers className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<RelationActionButton
|
||||
issueId={issueId}
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title={t("issue.add.relation")}
|
||||
icon={<Waypoints className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<IssueLinksActionButton
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title={t("issue.add.link")}
|
||||
icon={<Link className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<IssueAttachmentActionButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title={t("common.attach")}
|
||||
icon={<Paperclip className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{!hideWidgets?.includes("sub-work-items") && (
|
||||
<SubIssuesActionButton
|
||||
issueId={issueId}
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title={t("issue.add.sub_issue")}
|
||||
icon={<Layers className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
{!hideWidgets?.includes("relations") && (
|
||||
<RelationActionButton
|
||||
issueId={issueId}
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title={t("issue.add.relation")}
|
||||
icon={<Waypoints className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
{!hideWidgets?.includes("links") && (
|
||||
<IssueLinksActionButton
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title={t("issue.add.link")}
|
||||
icon={<Link className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
{!hideWidgets?.includes("attachments") && (
|
||||
<IssueAttachmentActionButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
customButton={
|
||||
<IssueDetailWidgetButton
|
||||
title={t("common.attach")}
|
||||
icon={<Paperclip className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-12
@@ -4,15 +4,14 @@ import React, { FC, useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { FileRejection, useDropzone } from "react-dropzone";
|
||||
import { Plus } from "lucide-react";
|
||||
import { EIssueServiceType } from "@plane/constants";
|
||||
// plane imports
|
||||
import { TIssueServiceType } from "@plane/types";
|
||||
// plane ui
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// plane web hooks
|
||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||
|
||||
// local imports
|
||||
import { useAttachmentOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
@@ -21,18 +20,11 @@ type Props = {
|
||||
issueId: string;
|
||||
customButton?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueAttachmentActionButton: FC<Props> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
customButton,
|
||||
disabled = false,
|
||||
issueServiceType = EIssueServiceType.ISSUES,
|
||||
} = props;
|
||||
const { workspaceSlug, projectId, issueId, customButton, disabled = false, issueServiceType } = props;
|
||||
// state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// store hooks
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { EIssueServiceType } from "@plane/constants";
|
||||
// plane imports
|
||||
import { TIssueServiceType } from "@plane/types";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// components
|
||||
@@ -17,11 +17,11 @@ type Props = {
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const AttachmentsCollapsible: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType } = props;
|
||||
// store hooks
|
||||
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
|
||||
|
||||
|
||||
+28
-11
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { TIssueServiceType } from "@plane/types";
|
||||
// components
|
||||
import {
|
||||
AttachmentsCollapsible,
|
||||
LinksCollapsible,
|
||||
RelationsCollapsible,
|
||||
SubIssuesCollapsible,
|
||||
TWorkItemWidgets,
|
||||
} from "@/components/issues/issue-detail-widgets";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
@@ -19,31 +22,33 @@ type Props = {
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType: TIssueServiceType;
|
||||
hideWidgets?: TWorkItemWidgets[];
|
||||
};
|
||||
|
||||
export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled } = props;
|
||||
const { workspaceSlug, projectId, issueId, disabled, issueServiceType, hideWidgets } = props;
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
subIssues: { subIssuesByIssueId },
|
||||
attachment: { getAttachmentsCountByIssueId, getAttachmentsUploadStatusByIssueId },
|
||||
relation: { getRelationCountByIssueId },
|
||||
} = useIssueDetail();
|
||||
|
||||
} = useIssueDetail(issueServiceType);
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
const subIssues = subIssuesByIssueId(issueId);
|
||||
const ISSUE_RELATION_OPTIONS = useTimeLineRelationOptions();
|
||||
const issueRelationsCount = getRelationCountByIssueId(issueId, ISSUE_RELATION_OPTIONS);
|
||||
|
||||
// render conditions
|
||||
const shouldRenderSubIssues = !!subIssues && subIssues.length > 0;
|
||||
const shouldRenderRelations = issueRelationsCount > 0;
|
||||
const shouldRenderLinks = !!issue?.link_count && issue?.link_count > 0;
|
||||
const shouldRenderSubIssues = !!subIssues && subIssues.length > 0 && !hideWidgets?.includes("sub-work-items");
|
||||
const shouldRenderRelations = issueRelationsCount > 0 && !hideWidgets?.includes("relations");
|
||||
const shouldRenderLinks = !!issue?.link_count && issue?.link_count > 0 && !hideWidgets?.includes("links");
|
||||
const attachmentUploads = getAttachmentsUploadStatusByIssueId(issueId);
|
||||
const attachmentsCount = getAttachmentsCountByIssueId(issueId);
|
||||
const shouldRenderAttachments = attachmentsCount > 0 || (!!attachmentUploads && attachmentUploads.length > 0);
|
||||
const shouldRenderAttachments =
|
||||
attachmentsCount > 0 ||
|
||||
(!!attachmentUploads && attachmentUploads.length > 0 && !hideWidgets?.includes("attachments"));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
@@ -53,13 +58,25 @@ export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
{shouldRenderRelations && (
|
||||
<RelationsCollapsible workspaceSlug={workspaceSlug} issueId={issueId} disabled={disabled} />
|
||||
<RelationsCollapsible
|
||||
workspaceSlug={workspaceSlug}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
{shouldRenderLinks && (
|
||||
<LinksCollapsible workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={disabled} />
|
||||
<LinksCollapsible
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
{shouldRenderAttachments && (
|
||||
<AttachmentsCollapsible
|
||||
@@ -67,9 +84,9 @@ export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
|
||||
<WorkItemAdditionalWidgets
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ISearchIssueResponse, TIssue } from "@plane/types";
|
||||
import { ISearchIssueResponse, TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "@/components/core";
|
||||
@@ -17,10 +17,11 @@ type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueDetailWidgetModals: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId } = props;
|
||||
const { workspaceSlug, projectId, issueId, issueServiceType } = props;
|
||||
// store hooks
|
||||
const {
|
||||
isIssueLinkModalOpen,
|
||||
@@ -41,7 +42,7 @@ export const IssueDetailWidgetModals: FC<Props> = observer((props) => {
|
||||
} = useIssueDetail();
|
||||
|
||||
// helper hooks
|
||||
const subIssueOperations = useSubIssueOperations();
|
||||
const subIssueOperations = useSubIssueOperations(issueServiceType);
|
||||
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId);
|
||||
|
||||
// handlers
|
||||
@@ -146,6 +147,7 @@ export const IssueDetailWidgetModals: FC<Props> = observer((props) => {
|
||||
isModalOpen={isIssueLinkModalOpen}
|
||||
handleOnClose={handleIssueLinkModalOnClose}
|
||||
linkOperations={handleLinkOperations}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
|
||||
{shouldRenderCreateUpdateModal && (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
import React, { FC } from "react";
|
||||
import { EIssueServiceType } from "@plane/constants";
|
||||
import { TIssueServiceType } from "@plane/types";
|
||||
// components
|
||||
import { LinkList } from "../../issue-detail/links";
|
||||
@@ -12,11 +11,11 @@ type Props = {
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
issueServiceType: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const IssueLinksCollapsibleContent: FC<Props> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
const { workspaceSlug, projectId, issueId, disabled, issueServiceType } = props;
|
||||
|
||||
// helper
|
||||
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId, issueServiceType);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user