Compare commits

...

46 Commits

Author SHA1 Message Date
Aaryan Khandelwal 1c376aaf0a refactor: sub-work items components, hooks and types 2025-04-15 12:24:21 +05:30
Saurabh Kumar 24899887b2 chore: Add workspace slug to should render setting link method (#6886)
* add workspace slug to setting link function

* add params in the function
2025-04-14 17:41:47 +05:30
sriram veeraghanta c6953ff878 fix: db modeling changes in pages 2025-04-12 16:22:13 +05:30
Prateek Shourya 06be9ab81b [WEB-3854] feat: billing and plans new design (#6920)
* [WEB-3854] feat: billing and plans new design

* chore: add missing styles
2025-04-11 20:37:25 +05:30
Akshita Goyal ed8d00acb1 [WEB-3849] chore: added intake source in the list (#6919)
* chore: added intake source in the list

* fix: refactor
2025-04-11 19:49:35 +05:30
Aaryan Khandelwal 915e374485 [WIKI-307]chore: update page icon placement #6916 2025-04-11 18:07:03 +05:30
Vamsi Krishna 1d5b93cebd [WEB-3853] fix: untitled page name issue #6918 2025-04-11 18:06:26 +05:30
sriram veeraghanta df65b8c34a fix: adding request logger middleware 2025-04-11 17:59:19 +05:30
Akshita Goyal 4c688b1d25 [WEB-3529] fix: fixed the comment create box position in common comments component (#6915) 2025-04-11 14:00:54 +05:30
Nikhil bfc6ed839f fix: uuid validation, status and webhook errors (#6896)
* fix: uuid validation and function parameter handling for external apis

* chore: update status 410 Gone to 409 conflicts

* chore: add webhook trigger for issue created through apis

* chore: remove pks from post

* chore: remove issue id from module post
2025-04-11 01:47:00 +05:30
Surya Prashanth b68396a4b2 [WEB-3831] chore: add validation for project_id in cycle serializer #6908 2025-04-11 01:42:53 +05:30
Vamsi Krishna b4fc715aba [WEB-3826] fix: estimate dropdown formatting (#6906)
* * fix: time conversion for estimate dropdown in browse
* chore: updated puncutations for estimates.

* chore: estimate activiy formatting

* chore: estimate activity refactor
2025-04-11 01:41:43 +05:30
Anmol Singh Bhatia 33a1b916cb [WEB-3837] fix: mutation of child work item added via Cmd+K with parent context #6910 2025-04-11 01:40:29 +05:30
Akshita Goyal 2818310619 [WEB-3529] fix: comment reset + edit comment font size + comment box position (#6909)
* fix: comment reset + edit comment font size

* fix: dynamically setting the position of the comment box

* fix: refactor

* fix: nomenclature
2025-04-11 01:40:05 +05:30
Anmol Singh Bhatia 882520b3c7 [WEB-3841] fix: create issue modal now correctly uses current project context #6911 2025-04-11 01:35:27 +05:30
Aaryan Khandelwal 20132e7544 [WEB-3839] fix: peek overview description version history (#6912)
* fix: handle undefined created_at

* chore: add created_by, updated_by updated_at and created_at fields in
relation apis

* chore: handle undefined date

* fix: project typo

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2025-04-10 16:22:26 +05:30
Aaryan Khandelwal 0ae57b49d2 [WEB-3829]fix: update workspace store action (#6905) 2025-04-09 20:31:52 +05:30
M. Palanikannan d347269afb [WEB-3819] fix: images now restore in read only mode as well (#6904) 2025-04-09 20:06:15 +05:30
Aaryan Khandelwal a3fd616ec4 [WEB-3827] refactor: work item widget components (#6902) 2025-04-09 19:58:16 +05:30
Akshita Goyal 9eeff158d5 [WEB-3811] fix: cycle charts issues (#6901) 2025-04-09 19:57:47 +05:30
Aaryan Khandelwal ef20b5814e [WEB-3792, 3823] fix: intake form version history (#6898)
* chore: intake form version history

* fix: remove autofocus from the copy markdown button

* chore: add logic to display deactivated user
2025-04-09 19:56:59 +05:30
Vamsi Krishna 14914e8716 [WEB-3759]chore: updated module and pages detail header (#6903)
* chore: added panel collapse and quick action menu for module detail header

* fix: updated pages header swithcer
2025-04-09 19:36:15 +05:30
Vamsi Krishna b738e39a4a [WEB-3798]chore: updated language support to estimates (#6900) 2025-04-09 19:34:01 +05:30
Vamsi Krishna 993c7899b6 [WEB-3759] chore: header revamp for cycles, modules, pages and views (#6875)
* chore: header revamp for cycles, modules, pages and views

* chore: moved list fetch to layout level
2025-04-09 14:56:57 +05:30
Vipin Chaudhary 2b411de1e3 [WIKI-306] fix: handle editor click behavior on the last node #6879 2025-04-09 14:51:58 +05:30
Prateek Shourya 1f9222065e [WEB-3788] improvement: enhance project properties related components modularity (#6882)
* improvement: work item modal data preload and parent work item details

* improvement: collapsible button title

* improvement: project creation form and modal

* improvement: emoji helper

* improvement: enhance labels component modularity

* improvement: enable state group and state list components modularity

* improvement: project settings feature list

* improvement: common utils
2025-04-09 14:50:43 +05:30
Akshita Goyal 670134562f [WEB-3808] fix: replaced the profile charts with propel components #6892 2025-04-09 14:50:23 +05:30
Akshita Goyal 144c793e9e [WEB-3803] fix: duplicate comments issue (#6893)
* fix: duplicate comments issue

* fix: refactor
2025-04-09 14:49:54 +05:30
Anmol Singh Bhatia 0a924e4824 [WEB-3693] chore: cmd-k work item actions improvements (#6891) 2025-04-09 09:25:57 +05:30
Aaryan Khandelwal 08702a5381 [WEB-3766] fix: user avatar in description version history dropdown item (#6888)
* fix: avatar url

* chore: update version modal width
2025-04-08 18:05:14 +05:30
sriram veeraghanta 270f282c3c fix: copy url util build error 2025-04-08 15:44:07 +05:30
Aaryan Khandelwal 37699362ad [WEB-3797] fix: remove leading slash from URL to copy (#6890)
* fix: remove prefix slash if present

* chore: make use of URL class to generate a valid URL
2025-04-08 15:22:23 +05:30
Vamsi Krishna 27cec64c56 [WEB-3794]chore: set project states to expand by default #6885 2025-04-08 14:38:08 +05:30
Akshita Goyal 782b09eeaf [WEB-3711] fix: relations delete issue (#6887)
* fix: relations delete issue

* fix: removed unnecessary type casting
2025-04-08 14:37:00 +05:30
Akshita Goyal 5ac5892fe5 [WEB-3586] fix: recents dropdown in home #6889 2025-04-08 14:32:08 +05:30
Bavisetti Narayan 96c403ff0b chore: changed inbox to intake (#6884) 2025-04-08 12:46:20 +05:30
Nikhil 543552f492 [WEB-3786] fix: issue date update when converting when dates are passed as string for comparison #6880
for comparison
2025-04-07 19:08:19 +05:30
Akshita Goyal c3cfcc1b92 [WEB-3753] fix: font size for comment box changed #6881 2025-04-07 19:06:04 +05:30
Anmol Singh Bhatia ac84d6ecf0 [WEB-3540] chore: icon color picker enhancements #6878 2025-04-07 15:53:02 +05:30
Vamsi Krishna 475b7a8396 [WEB-3737]chore: updated translations for estimates #6871 2025-04-07 15:50:15 +05:30
Nikhil 00f78bd6a1 [WEB-3728] fix: duplicate sequence ids being generated due to race condition (#6877)
* fix: race condition which is creating duplicate sequence ids

* chore: add management command to fix duplicate sequences

* chore: update command to take a lock and optimize the script to use dict
instead of loops

* chore: update the script to use transaction
2025-04-07 15:48:43 +05:30
Aaryan Khandelwal 34337f90c1 [WEB-3748, 3749] feat: work item description version history (#6863)
* chore: work item description versions

* chore: intake issue description

* chore: intake work item description versions

* chore: add missing translations

* chore: endpoint for intake description version

* chore: renamed key to work item

* chore: changed the paginator class

* chore: authorization added

* chore: added the enum validation

* chore: removed extra validations

* chore: added extra validations

* chore: modal position

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
2025-04-04 20:09:02 +05:30
Prateek Shourya 4f68aaafa6 fix: web build (#6870) 2025-04-04 20:07:12 +05:30
Vamsi Krishna 9c10235fca [WEB-3737]chore: estimates code refactor and translations (#6857)
* * chore: refactored estimates components.
* chore: added translations for estimates components.

* fix: translation key update
2025-04-04 16:59:12 +05:30
Lorenzo Palaia 9c1b158291 feat: hide create account button on ENABLE_SIGNUP=0 (#6841) 2025-04-04 16:52:59 +05:30
Prateek Shourya 2d0a15efd6 [WEB-3762] improvement: redirect logged in user to the workspace after accepting the invitation (#6869) 2025-04-04 16:52:09 +05:30
297 changed files with 8569 additions and 3179 deletions
+9 -1
View File
@@ -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"))
@@ -16,7 +16,6 @@ class ProjectSerializer(BaseSerializer):
member_role = serializers.IntegerField(read_only=True)
is_deployed = serializers.BooleanField(read_only=True)
cover_image_url = serializers.CharField(read_only=True)
inbox_view = serializers.BooleanField(read_only=True, source="intake_view")
class Meta:
model = Project
-10
View File
@@ -4,16 +4,6 @@ from plane.api.views import IntakeIssueAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/",
IntakeIssueAPIEndpoint.as_view(),
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:issue_id>/",
IntakeIssueAPIEndpoint.as_view(),
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-issues/",
IntakeIssueAPIEndpoint.as_view(),
+34 -14
View File
@@ -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,
)
+12
View File
@@ -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)
+6 -7
View File
@@ -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):
@@ -258,9 +259,7 @@ class ProjectAPIEndpoint(BaseAPIView):
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
intake_view = request.data.get(
"inbox_view", request.data.get("intake_view", project.intake_view)
)
intake_view = request.data.get("intake_view", project.intake_view)
if project.archived_at:
return Response(
@@ -307,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(
@@ -316,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):
+24 -2
View File
@@ -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):
+15 -1
View File
@@ -1,7 +1,11 @@
from django.urls import path
from plane.app.views import IntakeViewSet, IntakeIssueViewSet
from plane.app.views import (
IntakeViewSet,
IntakeIssueViewSet,
IntakeWorkItemDescriptionVersionEndpoint,
)
urlpatterns = [
@@ -53,4 +57,14 @@ urlpatterns = [
),
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-work-items/<uuid:work_item_id>/description-versions/",
IntakeWorkItemDescriptionVersionEndpoint.as_view(),
name="intake-work-item-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-work-items/<uuid:work_item_id>/description-versions/<uuid:pk>/",
IntakeWorkItemDescriptionVersionEndpoint.as_view(),
name="intake-work-item-versions",
),
]
+9 -9
View File
@@ -25,7 +25,7 @@ from plane.app.views import (
IssueAttachmentV2Endpoint,
IssueBulkUpdateDateEndpoint,
IssueVersionEndpoint,
IssueDescriptionVersionEndpoint,
WorkItemDescriptionVersionEndpoint,
IssueMetaEndpoint,
IssueDetailIdentifierEndpoint,
)
@@ -263,22 +263,22 @@ urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/",
IssueVersionEndpoint.as_view(),
name="page-versions",
name="issue-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/<uuid:pk>/",
IssueVersionEndpoint.as_view(),
name="page-versions",
name="issue-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/",
IssueDescriptionVersionEndpoint.as_view(),
name="page-versions",
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:work_item_id>/description-versions/",
WorkItemDescriptionVersionEndpoint.as_view(),
name="work-item-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/<uuid:pk>/",
IssueDescriptionVersionEndpoint.as_view(),
name="page-versions",
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:work_item_id>/description-versions/<uuid:pk>/",
WorkItemDescriptionVersionEndpoint.as_view(),
name="work-item-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/meta/",
+6 -2
View File
@@ -144,7 +144,7 @@ from .issue.sub_issue import SubIssuesEndpoint
from .issue.subscriber import IssueSubscriberViewSet
from .issue.version import IssueVersionEndpoint, IssueDescriptionVersionEndpoint
from .issue.version import IssueVersionEndpoint, WorkItemDescriptionVersionEndpoint
from .module.base import (
ModuleViewSet,
@@ -184,7 +184,11 @@ from .estimate.base import (
EstimatePointEndpoint,
)
from .intake.base import IntakeViewSet, IntakeIssueViewSet
from .intake.base import (
IntakeViewSet,
IntakeIssueViewSet,
IntakeWorkItemDescriptionVersionEndpoint,
)
from .analytic.base import (
AnalyticsEndpoint,
+107 -10
View File
@@ -27,16 +27,22 @@ from plane.db.models import (
Project,
ProjectMember,
CycleIssue,
IssueDescriptionVersion,
)
from plane.app.serializers import (
IssueCreateSerializer,
IssueSerializer,
IssueDetailSerializer,
IntakeSerializer,
IntakeIssueSerializer,
IntakeIssueDetailSerializer,
IssueDescriptionVersionDetailSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_description_version_task import issue_description_version_task
from plane.app.views.base import BaseAPIView
from plane.utils.timezone_converter import user_timezone_converter
from plane.utils.global_paginator import paginate
from plane.utils.host import base_host
@@ -88,7 +94,7 @@ class IntakeIssueViewSet(BaseViewSet):
serializer_class = IntakeIssueSerializer
model = IntakeIssue
filterset_fields = ["statulls"]
filterset_fields = ["status"]
def get_queryset(self):
return (
@@ -219,7 +225,7 @@ class IntakeIssueViewSet(BaseViewSet):
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
role=ROLE.GUEST.value,
is_active=True,
).exists()
and not project.guest_view_all_features
@@ -287,6 +293,13 @@ class IntakeIssueViewSet(BaseViewSet):
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=json.dumps(request.data, cls=DjangoJSONEncoder),
issue_id=str(serializer.data["id"]),
user_id=request.user.id,
is_creating=True,
)
intake_issue = (
IntakeIssue.objects.select_related("issue")
.prefetch_related("issue__labels", "issue__assignees")
@@ -386,13 +399,16 @@ class IntakeIssueViewSet(BaseViewSet):
),
"description": issue_data.get("description", issue.description),
}
current_instance = json.dumps(
IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder
)
issue_serializer = IssueCreateSerializer(
issue, data=issue_data, partial=True, context={"project_id": project_id}
)
if issue_serializer.is_valid():
current_instance = issue
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
@@ -402,15 +418,18 @@ class IntakeIssueViewSet(BaseViewSet):
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=current_instance,
issue_id=str(pk),
user_id=request.user.id,
)
issue_serializer.save()
else:
return Response(
@@ -550,7 +569,7 @@ class IntakeIssueViewSet(BaseViewSet):
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
role=ROLE.GUEST.value,
is_active=True,
).exists()
and not project.guest_view_all_features
@@ -558,7 +577,7 @@ class IntakeIssueViewSet(BaseViewSet):
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
status=status.HTTP_403_FORBIDDEN,
)
issue = IntakeIssueDetailSerializer(intake_issue).data
return Response(issue, status=status.HTTP_200_OK)
@@ -585,3 +604,81 @@ class IntakeIssueViewSet(BaseViewSet):
intake_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class IntakeWorkItemDescriptionVersionEndpoint(BaseAPIView):
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
datetime_fields = ["created_at", "updated_at"]
paginated_data = user_timezone_converter(
paginated_data, datetime_fields, timezone
)
return paginated_data
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, work_item_id, pk=None):
project = Project.objects.get(pk=project_id)
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=work_item_id
)
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=ROLE.GUEST.value,
is_active=True,
).exists()
and not project.guest_view_all_features
and not issue.created_by == request.user
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_403_FORBIDDEN,
)
if pk:
issue_description_version = IssueDescriptionVersion.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=work_item_id,
pk=pk,
)
serializer = IssueDescriptionVersionDetailSerializer(
issue_description_version
)
return Response(serializer.data, status=status.HTTP_200_OK)
cursor = request.GET.get("cursor", None)
required_fields = [
"id",
"workspace",
"project",
"issue",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
issue_description_versions_queryset = IssueDescriptionVersion.objects.filter(
workspace__slug=slug, project_id=project_id, issue_id=work_item_id
)
paginated_data = paginate(
base_queryset=issue_description_versions_queryset,
queryset=issue_description_versions_queryset,
cursor=cursor,
on_result=lambda results: self.process_paginated_result(
required_fields, results, request.user.user_timezone
),
)
return Response(paginated_data, status=status.HTTP_200_OK)
+12 -3
View File
@@ -63,6 +63,7 @@ from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.issue_description_version_task import issue_description_version_task
from plane.utils.host import base_host
class IssueListEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
@@ -565,7 +566,7 @@ class IssueViewSet(BaseViewSet):
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
status=status.HTTP_403_FORBIDDEN,
)
recent_visited_task.delay(
@@ -632,7 +633,7 @@ class IssueViewSet(BaseViewSet):
)
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder
)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
@@ -1034,9 +1035,17 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView):
"""
Validate that start date is before target date.
"""
from datetime import datetime
start = new_start or current_start
target = new_target or current_target
# Convert string dates to datetime objects if they're strings
if isinstance(start, str):
start = datetime.strptime(start, "%Y-%m-%d").date()
if isinstance(target, str):
target = datetime.strptime(target, "%Y-%m-%d").date()
if start and target and start > target:
return False
return True
@@ -1278,7 +1287,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
status=status.HTTP_403_FORBIDDEN,
)
recent_visited_task.delay(
+36 -6
View File
@@ -3,7 +3,13 @@ from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.db.models import IssueVersion, IssueDescriptionVersion
from plane.db.models import (
IssueVersion,
IssueDescriptionVersion,
Project,
ProjectMember,
Issue,
)
from ..base import BaseAPIView
from plane.app.serializers import (
IssueVersionDetailSerializer,
@@ -66,7 +72,7 @@ class IssueVersionEndpoint(BaseAPIView):
return Response(paginated_data, status=status.HTTP_200_OK)
class IssueDescriptionVersionEndpoint(BaseAPIView):
class WorkItemDescriptionVersionEndpoint(BaseAPIView):
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
@@ -78,10 +84,34 @@ class IssueDescriptionVersionEndpoint(BaseAPIView):
return paginated_data
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, issue_id, pk=None):
def get(self, request, slug, project_id, work_item_id, pk=None):
project = Project.objects.get(pk=project_id)
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=work_item_id
)
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=ROLE.GUEST.value,
is_active=True,
).exists()
and not project.guest_view_all_features
and not issue.created_by == request.user
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_403_FORBIDDEN,
)
if pk:
issue_description_version = IssueDescriptionVersion.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
workspace__slug=slug,
project_id=project_id,
issue_id=work_item_id,
pk=pk,
)
serializer = IssueDescriptionVersionDetailSerializer(
@@ -105,8 +135,8 @@ class IssueDescriptionVersionEndpoint(BaseAPIView):
]
issue_description_versions_queryset = IssueDescriptionVersion.objects.filter(
workspace__slug=slug, project_id=project_id, issue_id=issue_id
)
workspace__slug=slug, project_id=project_id, issue_id=work_item_id
).order_by("-created_at")
paginated_data = paginate(
base_queryset=issue_description_versions_queryset,
queryset=issue_description_versions_queryset,
+5 -4
View File
@@ -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):
+1 -1
View File
@@ -432,7 +432,7 @@ class IssueViewViewSet(BaseViewSet):
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
status=status.HTTP_403_FORBIDDEN,
)
serializer = IssueViewSerializer(issue_view)
+1 -1
View File
@@ -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
+7 -6
View File
@@ -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(
@@ -32,7 +32,7 @@ from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
from plane.bgtasks.webhook_task import webhook_activity
from plane.utils.issue_relation_mapper import get_inverse_relation
from plane.utils.valid_uuid import is_valid_uuid
from plane.utils.uuid import is_valid_uuid
# Track Changes in name
@@ -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(
@@ -0,0 +1,102 @@
# Django imports
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Max
from django.db import connection, transaction
# Module imports
from plane.db.models import Project, Issue, IssueSequence
from plane.utils.uuid import convert_uuid_to_integer
class Command(BaseCommand):
help = "Fix duplicate sequences"
def add_arguments(self, parser):
# Positional argument
parser.add_argument("issue_identifier", type=str, help="Issue Identifier")
def strict_str_to_int(self, s):
if not s.isdigit() and not (s.startswith("-") and s[1:].isdigit()):
raise ValueError("Invalid integer string")
return int(s)
def handle(self, *args, **options):
workspace_slug = input("Workspace slug: ")
if not workspace_slug:
raise CommandError("Workspace slug is required")
issue_identifier = options.get("issue_identifier", False)
# Validate issue_identifier
if not issue_identifier:
raise CommandError("Issue identifier is required")
# Validate issue identifier
try:
identifier = issue_identifier.split("-")
if len(identifier) != 2:
raise ValueError("Invalid issue identifier format")
project_identifier = identifier[0]
issue_sequence = self.strict_str_to_int(identifier[1])
# Fetch the project
project = Project.objects.get(
identifier__iexact=project_identifier, workspace__slug=workspace_slug
)
# Get the issues
issues = Issue.objects.filter(project=project, sequence_id=issue_sequence)
# Check if there are duplicate issues
if not issues.count() > 1:
raise CommandError(
"No duplicate issues found with the given identifier"
)
self.stdout.write(
self.style.SUCCESS(
f"{issues.count()} issues found with identifier {issue_identifier}"
)
)
with transaction.atomic():
# This ensures only one transaction per project can execute this code at a time
lock_key = convert_uuid_to_integer(project.id)
# Acquire an exclusive lock using the project ID as the lock key
with connection.cursor() as cursor:
# Get an exclusive lock using the project ID as the lock key
cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key])
# Get the maximum sequence ID for the project
last_sequence = IssueSequence.objects.filter(project=project).aggregate(
largest=Max("sequence")
)["largest"]
bulk_issues = []
bulk_issue_sequences = []
issue_sequence_map = {
isq.issue_id: isq
for isq in IssueSequence.objects.filter(project=project)
}
# change the ids of duplicate issues
for index, issue in enumerate(issues[1:]):
updated_sequence_id = last_sequence + index + 1
issue.sequence_id = updated_sequence_id
bulk_issues.append(issue)
# Find the same issue sequence instance from the above queryset
sequence_identifier = issue_sequence_map.get(issue.id)
if sequence_identifier:
sequence_identifier.sequence = updated_sequence_id
bulk_issue_sequences.append(sequence_identifier)
Issue.objects.bulk_update(bulk_issues, ["sequence_id"])
IssueSequence.objects.bulk_update(bulk_issue_sequences, ["sequence"])
self.stdout.write(self.style.SUCCESS("Sequence IDs updated successfully"))
except Exception as e:
raise CommandError(str(e))
@@ -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),
),
]
+1 -1
View File
@@ -82,4 +82,4 @@ from .label import Label
from .device import Device, DeviceSession
from .sticky import Sticky
from .sticky import Sticky
+14 -7
View File
@@ -6,7 +6,7 @@ from django.conf import settings
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction
from django.db import models, transaction, connection
from django.utils import timezone
from django.db.models import Q
from django import apps
@@ -15,8 +15,8 @@ from django import apps
from plane.utils.html_processor import strip_tags
from plane.db.mixins import SoftDeletionManager
from plane.utils.exception_logger import log_exception
from .base import BaseModel
from .project import ProjectBaseModel
from plane.utils.uuid import convert_uuid_to_integer
def get_default_properties():
@@ -209,11 +209,18 @@ class Issue(ProjectBaseModel):
if self._state.adding:
with transaction.atomic():
last_sequence = (
IssueSequence.objects.filter(project=self.project)
.select_for_update()
.aggregate(largest=models.Max("sequence"))["largest"]
)
# Create a lock for this specific project using an advisory lock
# This ensures only one transaction per project can execute this code at a time
lock_key = convert_uuid_to_integer(self.project.id)
with connection.cursor() as cursor:
# Get an exclusive lock using the project ID as the lock key
cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key])
# Get the last sequence for the project
last_sequence = IssueSequence.objects.filter(
project=self.project
).aggregate(largest=models.Max("sequence"))["largest"]
self.sequence_id = last_sequence + 1 if last_sequence else 1
# Strip the html tags using html parser
self.description_stripped = (
+3
View File
@@ -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,
)
+71
View File
@@ -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
+1
View File
@@ -59,6 +59,7 @@ MIDDLEWARE = [
"crum.CurrentRequestUserMiddleware",
"django.middleware.gzip.GZipMiddleware",
"plane.middleware.api_log_middleware.APITokenLogMiddleware",
"plane.middleware.logger.RequestLoggerMiddleware",
]
# Rest Framework settings
+11 -6
View File
@@ -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},
},
}
+13 -10
View File
@@ -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,
},
},
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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
+22
View File
@@ -0,0 +1,22 @@
# Python imports
import uuid
import hashlib
def is_valid_uuid(uuid_str):
"""Check if a string is a valid UUID version 4"""
try:
uuid_obj = uuid.UUID(uuid_str)
return uuid_obj.version == 4
except ValueError:
return False
def convert_uuid_to_integer(uuid_val: uuid.UUID) -> int:
"""Convert a UUID to a 64-bit signed integer"""
# Ensure UUID is a string
uuid_value: str = str(uuid_val)
# Hash to 64-bit signed int
h: bytes = hashlib.sha256(uuid_value.encode()).digest()
bigint: int = int.from_bytes(h[:8], byteorder="big", signed=True)
return bigint
-8
View File
@@ -1,8 +0,0 @@
import uuid
def is_valid_uuid(uuid_str):
try:
uuid.UUID(uuid_str, version=4)
return True
except ValueError:
return False
+1 -1
View File
@@ -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
+15
View File
@@ -8,3 +8,18 @@ export const ISSUE_REACTION_EMOJI_CODES = [
"9992",
"128064",
];
export const RANDOM_EMOJI_CODES = [
"8986",
"9200",
"128204",
"127773",
"127891",
"128076",
"128077",
"128187",
"128188",
"128512",
"128522",
"128578",
];
+75 -69
View File
@@ -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",
},
];
+3
View File
@@ -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";
@@ -29,3 +30,5 @@ export * from "./event-tracker";
export * from "./spreadsheet";
export * from "./dashboard";
export * from "./page";
export * from "./emoji";
export * from "./subscription";
+1
View File
@@ -41,6 +41,7 @@ export enum EIssueGroupBYServerToProperty {
export enum EIssueServiceType {
ISSUES = "issues",
EPICS = "epics",
WORK_ITEMS = "work-items",
}
export enum EIssuesStoreType {
+163
View File
@@ -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",
};
+19 -2
View File
@@ -1,5 +1,7 @@
// icons
import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
// plane imports
import { IProject, TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
// local imports
import { RANDOM_EMOJI_CODES } from "./emoji";
export type TNetworkChoiceIconKey = "Lock" | "Globe2";
@@ -132,3 +134,18 @@ export const PROJECT_ERROR_MESSAGES = {
i18n_message: "workspace_projects.error.issue_delete",
},
};
export const DEFAULT_PROJECT_FORM_VALUES: Partial<IProject> = {
cover_image_url: PROJECT_UNSPLASH_COVERS[Math.floor(Math.random() * PROJECT_UNSPLASH_COVERS.length)],
description: "",
logo_props: {
in_use: "emoji",
emoji: {
value: RANDOM_EMOJI_CODES[Math.floor(Math.random() * RANDOM_EMOJI_CODES.length)],
},
},
identifier: "",
name: "",
network: 2,
project_lead: null,
};
+8 -10
View File
@@ -1,9 +1,4 @@
export type TStateGroups =
| "backlog"
| "unstarted"
| "started"
| "completed"
| "cancelled";
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
export type TDraggableData = {
groupKey: TStateGroups;
@@ -14,40 +9,43 @@ export const STATE_GROUPS: {
[key in TStateGroups]: {
key: TStateGroups;
label: string;
defaultStateName: string;
color: string;
};
} = {
backlog: {
key: "backlog",
label: "Backlog",
defaultStateName: "Backlog",
color: "#d9d9d9",
},
unstarted: {
key: "unstarted",
label: "Unstarted",
defaultStateName: "Todo",
color: "#3f76ff",
},
started: {
key: "started",
label: "Started",
defaultStateName: "In Progress",
color: "#f59e0b",
},
completed: {
key: "completed",
label: "Completed",
defaultStateName: "Done",
color: "#16a34a",
},
cancelled: {
key: "cancelled",
label: "Canceled",
defaultStateName: "Cancelled",
color: "#dc2626",
},
};
export const ARCHIVABLE_STATE_GROUPS = [
STATE_GROUPS.completed.key,
STATE_GROUPS.cancelled.key,
];
export const ARCHIVABLE_STATE_GROUPS = [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key];
export const COMPLETED_STATE_GROUPS = [STATE_GROUPS.completed.key];
export const PENDING_STATE_GROUPS = [
STATE_GROUPS.backlog.key,
+42
View File
@@ -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",
];
@@ -47,15 +47,23 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
return;
}
// Insert a new paragraph at the end of the document
const endPosition = editor?.state.doc.content.size;
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run();
// Get the last node in the document
const docSize = editor.state.doc.content.size;
const lastNodePos = editor.state.doc.resolve(Math.max(0, docSize - 2));
const lastNode = lastNodePos.node();
// Focus the newly added paragraph for immediate editing
editor
.chain()
.setTextSelection(endPosition + 1)
.run();
// Check if the last node is a not paragraph
if (lastNode && lastNode.type.name !== "paragraph") {
// If last node is not a paragraph, insert a new paragraph at the end
const endPosition = editor?.state.doc.content.size;
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run();
// Focus the newly added paragraph for immediate editing
editor
.chain()
.setTextSelection(endPosition + 1)
.run();
}
} catch (error) {
console.error("An error occurred while handling container click to insert new empty node at bottom:", error);
}
@@ -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);
},
};
},
+2 -2
View File
@@ -145,8 +145,8 @@ export const useEditor = (props: CustomEditorProps) => {
clearEditor: (emitUpdate = false) => {
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string) => {
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
setEditorValue: (content: string, emitUpdate = false) => {
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" });
},
setEditorValueAtCursorPosition: (content: string) => {
if (editor?.state.selection) {
@@ -77,8 +77,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
clearEditor: (emitUpdate = false) => {
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string) => {
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
setEditorValue: (content: string, emitUpdate = false) => {
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" });
},
getMarkDown: (): string => {
const markdownOutput = editor?.storage.markdown.getMarkdown();
+1 -1
View File
@@ -84,7 +84,7 @@ export type EditorReadOnlyRefApi = {
json: JSONContent | null;
};
clearEditor: (emitUpdate?: boolean) => void;
setEditorValue: (content: string) => void;
setEditorValue: (content: string, emitUpdate?: boolean) => void;
scrollSummary: (marking: IMarking) => void;
getDocumentInfo: () => {
characters: number;
+12 -12
View File
@@ -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);
}
+102 -16
View File
@@ -590,10 +590,10 @@
"default": "Zatím nemáte žádné nedávné položky."
},
"filters": {
"all": "Všechny položky",
"all": "Vše",
"projects": "Projekty",
"pages": "Stránky",
"issues": "Pracovní položky"
"issues": "Úkoly"
}
},
"new_at_plane": {
@@ -867,7 +867,8 @@
"deleting": "Mazání",
"pending": "Čekající",
"invite": "Pozvat",
"view": "Pohled"
"view": "Pohled",
"deactivated_user": "Deaktivovaný uživatel"
},
"chart": {
@@ -1734,32 +1735,111 @@
}
},
"estimates": {
"title": "Povolit odhady v projektu",
"description": "Pomáhají komunikovat složitost a vytížení týmu."
"label": "Odhady",
"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",
"step": "Krok {step} z {total}",
"label": "Vytvořit odhad"
},
"toasts": {
"created": {
"success": {
"title": "Odhad vytvořen",
"message": "Odhad byl úspěšně vytvořen"
},
"error": {
"title": "Vytvoření odhadu selhalo",
"message": "Nepodařilo se vytvořit nový odhad, zkuste to prosím znovu."
}
},
"updated": {
"success": {
"title": "Odhad upraven",
"message": "Odhad byl aktualizován ve vašem projektu."
},
"error": {
"title": "Úprava odhadu selhala",
"message": "Nepodařilo se upravit odhad, zkuste to prosím znovu"
}
},
"enabled": {
"success": {
"title": "Úspěch!",
"message": "Odhady byly povoleny."
}
},
"disabled": {
"success": {
"title": "Úspěch!",
"message": "Odhady byly zakázány."
},
"error": {
"title": "Chyba!",
"message": "Odhad nemohl být zakázán. Zkuste to prosím znovu"
}
}
},
"validation": {
"min_length": "Odhad musí být větší než 0.",
"unable_to_process": "Nemůžeme zpracovat váš požadavek, zkuste to prosím znovu.",
"numeric": "Odhad musí být číselná hodnota.",
"character": "Odhad musí být znakový.",
"empty": "Hodnota odhadu nemůže být prázdná.",
"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": {
"label": "Automatizace",
"auto-archive": {
"title": "Automaticky archivovat uzavřené položky",
"description": "Plane bude archivovat dokončené nebo zrušené položky.",
"duration": "Archivovat položky uzavřené déle než"
"title": "Automaticky archivovat uzavřené pracovní položky",
"description": "Plane bude automaticky archivovat pracovní položky, které byly dokončeny nebo zrušeny.",
"duration": "Automaticky archivovat pracovní položky, které jsou uzavřené po dobu"
},
"auto-close": {
"title": "Automaticky uzavírat položky",
"description": "Plane uzavře neaktivní položky.",
"duration": "Uzavřít položky neaktivní déle než",
"auto_close_status": "Stav pro automatické uzavření"
"title": "Automaticky uzavírat pracovní položky",
"description": "Plane automaticky uzavře pracovní položky, které nebyly dokončeny nebo zrušeny.",
"duration": "Automaticky uzavřít pracovní položky, které jsou neaktivní po dobu",
"auto_close_status": "Stav automatického uzavření"
}
},
"empty_state": {
"labels": {
"title": "Žádné štítky",
"description": "Vytvářejte štítky pro organizaci položek."
"title": "Zatím žádné štítky",
"description": "Vytvte štítky pro organizaci a filtrování pracovních položek ve vašem projektu."
},
"estimates": {
"title": "Žádné systémy odhadů",
"description": "Vytvořte systém odhadů pro komunikaci vytížení.",
"title": "Zatím žádné systémy odhadů",
"description": "Vytvořte sadu odhadů pro komunikaci množství práce na pracovní položku.",
"primary_button": "Přidat systém odhadů"
}
}
@@ -2372,5 +2452,11 @@
"module": {
"label": "{count, plural, one {Modul} few {Moduly} other {Modulů}}",
"no_module": "Žádný modul"
},
"description_versions": {
"last_edited_by": "Naposledy upraveno uživatelem",
"previously_edited_by": "Dříve upraveno uživatelem",
"edited_by": "Upraveno uživatelem"
}
}
+101 -13
View File
@@ -500,7 +500,7 @@
"export": "Exportieren",
"member": "{count, plural, one{# Mitglied} few{# Mitglieder} other{# Mitglieder}}",
"new_password_must_be_different_from_old_password": "Das neue Passwort muss von dem alten Passwort abweichen",
"project_view": {
"sort_by": {
"created_at": "Erstellt am",
@@ -587,7 +587,7 @@
"default": "Sie haben noch keine kürzlichen Elemente."
},
"filters": {
"all": "Alle Elemente",
"all": "Alle",
"projects": "Projekte",
"pages": "Seiten",
"issues": "Arbeitselemente"
@@ -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",
@@ -1710,21 +1711,100 @@
}
},
"estimates": {
"title": "Schätzungen im Projekt aktivieren",
"description": "Hilft, die Komplexität und Auslastung für das Team zu kommunizieren."
"label": "Schätzungen",
"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",
"step": "Schritt {step} von {total}",
"label": "Schätzung erstellen"
},
"toasts": {
"created": {
"success": {
"title": "Schätzung erstellt",
"message": "Die Schätzung wurde erfolgreich erstellt"
},
"error": {
"title": "Schätzungserstellung fehlgeschlagen",
"message": "Wir konnten die neue Schätzung nicht erstellen, bitte versuche es erneut."
}
},
"updated": {
"success": {
"title": "Schätzung geändert",
"message": "Die Schätzung wurde in deinem Projekt aktualisiert."
},
"error": {
"title": "Schätzungsänderung fehlgeschlagen",
"message": "Wir konnten die Schätzung nicht ändern, bitte versuche es erneut"
}
},
"enabled": {
"success": {
"title": "Erfolg!",
"message": "Schätzungen wurden aktiviert."
}
},
"disabled": {
"success": {
"title": "Erfolg!",
"message": "Schätzungen wurden deaktiviert."
},
"error": {
"title": "Fehler!",
"message": "Schätzung konnte nicht deaktiviert werden. Bitte versuche es erneut"
}
}
},
"validation": {
"min_length": "Die Schätzung muss größer als 0 sein.",
"unable_to_process": "Wir können deine Anfrage nicht verarbeiten, bitte versuche es erneut.",
"numeric": "Die Schätzung muss ein numerischer Wert sein.",
"character": "Die Schätzung muss ein Zeichenwert sein.",
"empty": "Der Schätzungswert darf nicht leer sein.",
"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": {
"label": "Automatisierung",
"label": "Automatisierungen",
"auto-archive": {
"title": "Abgeschlossene Elemente automatisch archivieren",
"description": "Plane archiviert abgeschlossene oder abgebrochene Elemente.",
"duration": "Archivieren von Elementen, die seit mehr als folgendem Zeitraum abgeschlossen sind"
"title": "Geschlossene Arbeitselemente automatisch archivieren",
"description": "Plane wird Arbeitselemente automatisch archivieren, die abgeschlossen oder abgebrochen wurden.",
"duration": "Arbeitselemente automatisch archivieren, die seit"
},
"auto-close": {
"title": "Elemente automatisch schließen",
"description": "Plane schließt inaktive Elemente.",
"duration": "Schließen von Elementen, die seit mehr als folgendem Zeitraum inaktiv sind",
"auto_close_status": "Status r automatisches Schließen"
"title": "Arbeitselemente automatisch schließen",
"description": "Plane wird Arbeitselemente automatisch schließen, die nicht abgeschlossen oder abgebrochen wurden.",
"duration": "Inaktive Arbeitselemente automatisch schließen seit",
"auto_close_status": "Status der automatischen Schließung"
}
},
"empty_state": {
@@ -2321,12 +2401,20 @@
"manual": "Manuell"
}
},
"cycle": {
"label": "{count, plural, one {Zyklus} few {Zyklen} other {Zyklen}}",
"no_cycle": "Kein Zyklus"
},
"module": {
"label": "{count, plural, one {Modul} few {Module} other {Module}}",
"no_module": "Kein Modul"
},
"description_versions": {
"last_edited_by": "Zuletzt bearbeitet von",
"previously_edited_by": "Zuvor bearbeitet von",
"edited_by": "Bearbeitet von"
}
}
+91 -4
View File
@@ -424,7 +424,7 @@
"default": "You don't have any recents yet."
},
"filters": {
"all": "All items",
"all": "All",
"projects": "Projects",
"pages": "Pages",
"issues": "Work items"
@@ -630,7 +630,8 @@
"clear_sorting": "Clear sorting",
"show_weekends": "Show weekends",
"enable": "Enable",
"disable": "Disable"
"disable": "Disable",
"copy_markdown": "Copy markdown"
},
"name": "Name",
"discard": "Discard",
@@ -701,7 +702,8 @@
"deleting": "Deleting",
"pending": "Pending",
"invite": "Invite",
"view": "View"
"view": "View",
"deactivated_user": "Deactivated user"
},
"chart": {
@@ -1568,8 +1570,87 @@
}
},
"estimates": {
"label": "Estimates",
"title": "Enable estimates for my project",
"description": "They help you in communicating complexity and workload of the team."
"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",
"step": "Step {step} of {total}",
"label": "Create estimate"
},
"toasts": {
"created": {
"success": {
"title": "Estimate created",
"message": "The estimate has been created successfully"
},
"error": {
"title": "Estimate creation failed",
"message": "We were unable to create the new estimate, please try again."
}
},
"updated": {
"success": {
"title": "Estimate modified",
"message": "The estimate has been updated in your project."
},
"error": {
"title": "Estimate modification failed",
"message": "We were unable to modify the estimate, please try again"
}
},
"enabled": {
"success": {
"title": "Success!",
"message": "Estimates have been enabled."
}
},
"disabled": {
"success": {
"title": "Success!",
"message": "Estimates have been disabled."
},
"error": {
"title": "Error!",
"message": "Estimate could not be disabled. Please try again"
}
}
},
"validation": {
"min_length": "Estimate needs to be greater than 0.",
"unable_to_process": "We are unable to process your request, please try again.",
"numeric": "Estimate needs to be a numeric value.",
"character": "Estimate needs to be a character value.",
"empty": "Estimate value cannot be empty.",
"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": {
"label": "Automations",
@@ -2206,5 +2287,11 @@
"module": {
"label": "{count, plural, one {Module} other {Modules}}",
"no_module": "No module"
},
"description_versions": {
"last_edited_by": "Last edited by",
"previously_edited_by": "Previously edited by",
"edited_by": "Edited by"
}
}
+99 -13
View File
@@ -596,7 +596,7 @@
"default": "Aún no tienes elementos recientes."
},
"filters": {
"all": "Todos los elementos",
"all": "Todos",
"projects": "Proyectos",
"pages": "Páginas",
"issues": "Elementos de trabajo"
@@ -872,7 +872,8 @@
"deleting": "Eliminando",
"pending": "Pendiente",
"invite": "Invitar",
"view": "Ver"
"view": "Ver",
"deactivated_user": "Usuario desactivado"
},
"chart": {
@@ -1738,32 +1739,111 @@
}
},
"estimates": {
"title": "Habilitar estimaciones para mi proyecto",
"description": "Ayudan a comunicar la complejidad y la carga de trabajo del equipo."
"label": "Estimaciones",
"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",
"step": "Paso {step} de {total}",
"label": "Crear estimación"
},
"toasts": {
"created": {
"success": {
"title": "Estimación creada",
"message": "La estimación se ha creado correctamente"
},
"error": {
"title": "Error al crear la estimación",
"message": "No pudimos crear la nueva estimación, por favor inténtalo de nuevo."
}
},
"updated": {
"success": {
"title": "Estimación modificada",
"message": "La estimación se ha actualizado en tu proyecto."
},
"error": {
"title": "Error al modificar la estimación",
"message": "No pudimos modificar la estimación, por favor inténtalo de nuevo"
}
},
"enabled": {
"success": {
"title": "¡Éxito!",
"message": "Las estimaciones han sido activadas."
}
},
"disabled": {
"success": {
"title": "¡Éxito!",
"message": "Las estimaciones han sido desactivadas."
},
"error": {
"title": "¡Error!",
"message": "No se pudo desactivar la estimación. Por favor inténtalo de nuevo"
}
}
},
"validation": {
"min_length": "La estimación debe ser mayor que 0.",
"unable_to_process": "No podemos procesar tu solicitud, por favor inténtalo de nuevo.",
"numeric": "La estimación debe ser un valor numérico.",
"character": "La estimación debe ser un valor de carácter.",
"empty": "El valor de la estimación no puede estar vacío.",
"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": {
"label": "Automatizaciones",
"auto-archive": {
"title": "Auto-archivar elementos de trabajo cerrados",
"description": "Plane archivará automáticamente los elementos de trabajo que se hayan completado o cancelado.",
"duration": "Auto-archivar elementos de trabajo que están cerrados por"
"title": "Archivar automáticamente elementos de trabajo cerrados",
"description": "Plane archivará automáticamente los elementos de trabajo que hayan sido completados o cancelados.",
"duration": "Archivar automáticamente elementos de trabajo cerrados durante"
},
"auto-close": {
"title": "Auto-cerrar elementos de trabajo",
"description": "Plane cerrará automáticamente los elementos de trabajo que no se hayan completado o cancelado.",
"duration": "Auto-cerrar elementos de trabajo que están inactivos por",
"auto_close_status": "Estado de auto-cierre"
"title": "Cerrar automáticamente elementos de trabajo",
"description": "Plane cerrará automáticamente los elementos de trabajo que no hayan sido completados o cancelados.",
"duration": "Cerrar automáticamente elementos de trabajo inactivos durante",
"auto_close_status": "Estado de cierre automático"
}
},
"empty_state": {
"labels": {
"title": "Aún no hay etiquetas",
"description": "Crea etiquetas para ayudar a organizar y filtrar elementos de trabajo en tu proyecto."
"description": "Crea etiquetas para organizar y filtrar elementos de trabajo en tu proyecto."
},
"estimates": {
"title": "Aún no hay sistemas de estimación",
"description": "Crea un conjunto de estimaciones para comunicar la cantidad de trabajo por elemento de trabajo.",
"description": "Crea un conjunto de estimaciones para comunicar el volumen de trabajo por elemento de trabajo.",
"primary_button": "Agregar sistema de estimación"
}
}
@@ -2376,5 +2456,11 @@
"module": {
"label": "{count, plural, one {Módulo} other {Módulos}}",
"no_module": "Sin módulo"
},
"description_versions": {
"last_edited_by": "Última edición por",
"previously_edited_by": "Editado anteriormente por",
"edited_by": "Editado por"
}
}
+96 -10
View File
@@ -594,7 +594,7 @@
"default": "Vous n'avez pas encore d'éléments récents."
},
"filters": {
"all": "Tous les éléments",
"all": "Tous",
"projects": "Projets",
"pages": "Pages",
"issues": "Éléments de travail"
@@ -870,7 +870,8 @@
"deleting": "Suppression",
"pending": "En attente",
"invite": "Inviter",
"view": "Afficher"
"view": "Afficher",
"deactivated_user": "Utilisateur désactivé"
},
"chart": {
@@ -1736,19 +1737,98 @@
}
},
"estimates": {
"label": "Estimations",
"title": "Activer les estimations pour mon projet",
"description": "Elles vous aident à communiquer la complexité et la charge de travail de l'équipe."
"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",
"step": "Étape {step} de {total}",
"label": "Créer une estimation"
},
"toasts": {
"created": {
"success": {
"title": "Estimation créée",
"message": "L'estimation a été créée avec succès"
},
"error": {
"title": "Échec de la création de l'estimation",
"message": "Nous n'avons pas pu créer la nouvelle estimation, veuillez réessayer."
}
},
"updated": {
"success": {
"title": "Estimation modifiée",
"message": "L'estimation a été mise à jour dans votre projet."
},
"error": {
"title": "Échec de la modification de l'estimation",
"message": "Nous n'avons pas pu modifier l'estimation, veuillez réessayer"
}
},
"enabled": {
"success": {
"title": "Succès !",
"message": "Les estimations ont été activées."
}
},
"disabled": {
"success": {
"title": "Succès !",
"message": "Les estimations ont été désactivées."
},
"error": {
"title": "Erreur !",
"message": "L'estimation n'a pas pu être désactivée. Veuillez réessayer"
}
}
},
"validation": {
"min_length": "L'estimation doit être supérieure à 0.",
"unable_to_process": "Nous ne pouvons pas traiter votre demande, veuillez réessayer.",
"numeric": "L'estimation doit être une valeur numérique.",
"character": "L'estimation doit être une valeur de caractère.",
"empty": "La valeur de l'estimation ne peut pas être vide.",
"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": {
"label": "Automatisations",
"auto-archive": {
"title": "Archiver automatiquement les éléments de travail fermés",
"description": "Plane archivera automatiquement les éléments de travail qui ont été terminés ou annulés.",
"duration": "Archiver automatiquement les éléments de travail qui sont fermés depuis"
"description": "Plane archivera automatiquement les éléments de travail qui ont été complétés ou annulés.",
"duration": "Archiver automatiquement les éléments de travail fermés depuis"
},
"auto-close": {
"title": "Fermer automatiquement les éléments de travail",
"description": "Plane fermera automatiquement les éléments de travail qui n'ont pas été terminés ou annulés.",
"description": "Plane fermera automatiquement les éléments de travail qui n'ont pas été complétés ou annulés.",
"duration": "Fermer automatiquement les éléments de travail inactifs depuis",
"auto_close_status": "Statut de fermeture automatique"
}
@@ -1756,12 +1836,12 @@
"empty_state": {
"labels": {
"title": "Aucune étiquette pour le moment",
"description": "Créez des étiquettes pour aider à organiser et filtrer les éléments de travail dans votre projet."
"title": "Pas encore d'étiquettes",
"description": "Créez des étiquettes pour organiser et filtrer les éléments de travail dans votre projet."
},
"estimates": {
"title": "Aucun système d'estimation pour le moment",
"description": "Créez un ensemble d'estimations pour communiquer la quantité de travail par élément de travail.",
"title": "Pas encore de systèmes d'estimation",
"description": "Créez un ensemble d'estimations pour communiquer le volume de travail par élément de travail.",
"primary_button": "Ajouter un système d'estimation"
}
}
@@ -2374,5 +2454,11 @@
"module": {
"label": "{count, plural, one {Module} other {Modules}}",
"no_module": "Pas de module"
},
"description_versions": {
"last_edited_by": "Dernière modification par",
"previously_edited_by": "Précédemment modifié par",
"edited_by": "Modifié par"
}
}
+101 -15
View File
@@ -592,7 +592,7 @@
"default": "Anda belum memiliki yang terbaru."
},
"filters": {
"all": "Semua item",
"all": "Semua",
"projects": "Proyek",
"pages": "Halaman",
"issues": "Item kerja"
@@ -869,7 +869,8 @@
"deleting": "Menghapus",
"pending": "Tertunda",
"invite": "Undang",
"view": "Lihat"
"view": "Lihat",
"deactivated_user": "Pengguna dinonaktifkan"
},
"chart": {
@@ -1736,33 +1737,112 @@
}
},
"estimates": {
"title": "Aktifkan estimasi untuk proyek saya",
"description": "Mereka membantu Anda dalam mengkomunikasikan kompleksitas dan beban kerja tim."
"label": "Perkiraan",
"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",
"step": "Langkah {step} dari {total}",
"label": "Buat perkiraan"
},
"toasts": {
"created": {
"success": {
"title": "Perkiraan dibuat",
"message": "Perkiraan telah berhasil dibuat"
},
"error": {
"title": "Pembuatan perkiraan gagal",
"message": "Kami tidak dapat membuat perkiraan baru, silakan coba lagi."
}
},
"updated": {
"success": {
"title": "Perkiraan dimodifikasi",
"message": "Perkiraan telah diperbarui dalam proyek Anda."
},
"error": {
"title": "Modifikasi perkiraan gagal",
"message": "Kami tidak dapat memodifikasi perkiraan, silakan coba lagi"
}
},
"enabled": {
"success": {
"title": "Berhasil!",
"message": "Perkiraan telah diaktifkan."
}
},
"disabled": {
"success": {
"title": "Berhasil!",
"message": "Perkiraan telah dinonaktifkan."
},
"error": {
"title": "Kesalahan!",
"message": "Perkiraan tidak dapat dinonaktifkan. Silakan coba lagi"
}
}
},
"validation": {
"min_length": "Perkiraan harus lebih besar dari 0.",
"unable_to_process": "Kami tidak dapat memproses permintaan Anda, silakan coba lagi.",
"numeric": "Perkiraan harus berupa nilai numerik.",
"character": "Perkiraan harus berupa nilai karakter.",
"empty": "Nilai perkiraan tidak boleh kosong.",
"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": {
"label": "Otomatisasi",
"auto-archive": {
"title": "Otomatis mengarsipkan item kerja yang ditutup",
"description": "Plane akan otomatis mengarsipkan item kerja yang telah diselesaikan atau dibatalkan.",
"duration": "Otomatis mengarsipkan item kerja yang ditutup untuk"
"title": "Arsip otomatis item kerja yang ditutup",
"description": "Plane akan mengarsipkan secara otomatis item kerja yang telah selesai atau dibatalkan.",
"duration": "Arsip otomatis item kerja yang ditutup selama"
},
"auto-close": {
"title": "Otomatis menutup item kerja",
"description": "Plane akan secara otomatis menutup item kerja yang belum diselesaikan atau dibatalkan.",
"duration": "Otomatis menutup item kerja yang tidak aktif selama",
"auto_close_status": "Status otomatis tutup"
"title": "Tutup otomatis item kerja",
"description": "Plane akan menutup secara otomatis item kerja yang belum selesai atau dibatalkan.",
"duration": "Tutup otomatis item kerja yang tidak aktif selama",
"auto_close_status": "Status penutupan otomatis"
}
},
"empty_state": {
"labels": {
"title": "Belum ada label",
"description": "Buat label untuk membantu mengorganisir dan memfilter item kerja dalam proyek Anda."
"description": "Buat label untuk membantu mengatur dan memfilter item kerja dalam proyek Anda."
},
"estimates": {
"title": "Belum ada sistem estimasi",
"description": "Buat seperangkat estimasi untuk mengkomunikasikan jumlah pekerjaan per item kerja.",
"primary_button": "Tambah sistem estimasi"
"title": "Belum ada sistem perkiraan",
"description": "Buat serangkaian perkiraan untuk mengkomunikasikan jumlah pekerjaan per item kerja.",
"primary_button": "Tambah sistem perkiraan"
}
}
},
@@ -2368,5 +2448,11 @@
"module": {
"label": "{count, plural, one {Modul} other {Modul}}",
"no_module": "Tidak ada modul"
},
"description_versions": {
"last_edited_by": "Terakhir disunting oleh",
"previously_edited_by": "Sebelumnya disunting oleh",
"edited_by": "Disunting oleh"
}
}
+94 -8
View File
@@ -501,7 +501,7 @@
"export": "Esporta",
"member": "{count, plural, one {# membro} other {# membri}}",
"new_password_must_be_different_from_old_password": "La nuova password deve essere diversa dalla password precedente",
"edited": "Modificato",
"bot": "Bot",
@@ -594,7 +594,7 @@
"default": "Non hai ancora elementi recenti."
},
"filters": {
"all": "Tutti gli elementi",
"all": "Tutti",
"projects": "Progetti",
"pages": "Pagine",
"issues": "Elementi di lavoro"
@@ -868,7 +868,8 @@
"deleting": "Eliminazione in corso",
"pending": "In sospeso",
"invite": "Invita",
"view": "Visualizza"
"view": "Visualizza",
"deactivated_user": "Utente disattivato"
},
"chart": {
@@ -1735,20 +1736,99 @@
}
},
"estimates": {
"label": "Stime",
"title": "Abilita le stime per il mio progetto",
"description": "Aiutano a comunicare la complessità e il carico di lavoro del team."
"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",
"step": "Passo {step} di {total}",
"label": "Crea stima"
},
"toasts": {
"created": {
"success": {
"title": "Stima creata",
"message": "La stima è stata creata con successo"
},
"error": {
"title": "Creazione stima fallita",
"message": "Non siamo riusciti a creare la nuova stima, riprova."
}
},
"updated": {
"success": {
"title": "Stima modificata",
"message": "La stima è stata aggiornata nel tuo progetto."
},
"error": {
"title": "Modifica stima fallita",
"message": "Non siamo riusciti a modificare la stima, riprova"
}
},
"enabled": {
"success": {
"title": "Successo!",
"message": "Le stime sono state abilitate."
}
},
"disabled": {
"success": {
"title": "Successo!",
"message": "Le stime sono state disabilitate."
},
"error": {
"title": "Errore!",
"message": "Impossibile disabilitare la stima. Riprova"
}
}
},
"validation": {
"min_length": "La stima deve essere maggiore di 0.",
"unable_to_process": "Non possiamo elaborare la tua richiesta, riprova.",
"numeric": "La stima deve essere un valore numerico.",
"character": "La stima deve essere un valore di carattere.",
"empty": "Il valore della stima non può essere vuoto.",
"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": {
"label": "Automazioni",
"label": "Automatizzazioni",
"auto-archive": {
"title": "Archivia automaticamente gli elementi di lavoro chiusi",
"description": "Plane archivierà automaticamente gli elementi di lavoro che sono stati completati o annullati.",
"duration": "Archivia automaticamente gli elementi di lavoro chiusi da"
"description": "Plane archiverà automaticamente gli elementi di lavoro che sono stati completati o annullati.",
"duration": "Archivia automaticamente gli elementi di lavoro chiusi per"
},
"auto-close": {
"title": "Chiudi automaticamente gli elementi di lavoro",
"description": "Plane chiuderà automaticamente gli elementi di lavoro che non sono stati completati o annullati.",
"duration": "Chiudi automaticamente gli elementi di lavoro inattivi da",
"duration": "Chiudi automaticamente gli elementi di lavoro inattivi per",
"auto_close_status": "Stato di chiusura automatica"
}
},
@@ -2373,5 +2453,11 @@
"module": {
"label": "{count, plural, one {Modulo} other {Moduli}}",
"no_module": "Nessun modulo"
},
"description_versions": {
"last_edited_by": "Ultima modifica di",
"previously_edited_by": "Precedentemente modificato da",
"edited_by": "Modificato da"
}
}
+94 -8
View File
@@ -594,7 +594,7 @@
"default": "まだ最近の項目がありません。"
},
"filters": {
"all": "すべての項目",
"all": "すべて",
"projects": "プロジェクト",
"pages": "ページ",
"issues": "作業項目"
@@ -870,7 +870,8 @@
"deleting": "デリーティング",
"pending": "保留中",
"invite": "招待",
"view": "ビュー"
"view": "ビュー",
"deactivated_user": "無効化されたユーザー"
},
"chart": {
@@ -1736,20 +1737,99 @@
}
},
"estimates": {
"label": "見積もり",
"title": "プロジェクトの見積もりを有効にする",
"description": "チームの複雑さと作業負荷を伝えるのに役立ちます。"
"description": "チームの複雑さと作業負荷を伝えるのに役立ちます。",
"no_estimate": "見積もりなし",
"new": "新しい見積もりシステム",
"create": {
"custom": "カスタム",
"start_from_scratch": "最初から開始",
"choose_template": "テンプレートを選択",
"choose_estimate_system": "見積もりシステムを選択",
"enter_estimate_point": "見積もりを入力",
"step": "ステップ {step} の {total}",
"label": "見積もりを作成"
},
"toasts": {
"created": {
"success": {
"title": "見積もりを作成",
"message": "見積もりが正常に作成されました"
},
"error": {
"title": "見積もり作成に失敗",
"message": "新しい見積もりを作成できませんでした。もう一度お試しください。"
}
},
"updated": {
"success": {
"title": "見積もりを更新",
"message": "プロジェクトの見積もりが更新されました。"
},
"error": {
"title": "見積もり更新に失敗",
"message": "見積もりを更新できませんでした。もう一度お試しください"
}
},
"enabled": {
"success": {
"title": "成功!",
"message": "見積もりが有効になりました。"
}
},
"disabled": {
"success": {
"title": "成功!",
"message": "見積もりが無効になりました。"
},
"error": {
"title": "エラー!",
"message": "見積もりを無効にできませんでした。もう一度お試しください"
}
}
},
"validation": {
"min_length": "見積もりは0より大きい必要があります。",
"unable_to_process": "リクエストを処理できません。もう一度お試しください。",
"numeric": "見積もりは数値である必要があります。",
"character": "見積もりは文字値である必要があります。",
"empty": "見積もり値は空にできません。",
"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": {
"label": "自動化",
"auto-archive": {
"title": "完了した作業項目を自動アーカイブ",
"title": "完了した作業項目を自動的にアーカイブ",
"description": "Planeは完了またはキャンセルされた作業項目を自動的にアーカイブします。",
"duration": "次の期間完了している作業項目を自動アーカイブ"
"duration": "閉じられた作業項目を自動的にアーカイブ"
},
"auto-close": {
"title": "作業項目を自動クローズ",
"description": "Planeは完了またはキャンセルされていない作業項目を自動的にクローズします。",
"duration": "次の期間非アクティブな作業項目を自動クローズ",
"title": "作業項目を自動的に閉じる",
"description": "Planeは完了またはキャンセルされていない作業項目を自動的に閉じます。",
"duration": "非アクティブな作業項目を自動的に閉じる",
"auto_close_status": "自動クローズステータス"
}
},
@@ -2374,5 +2454,11 @@
"module": {
"label": "{count, plural, one {モジュール} other {モジュール}}",
"no_module": "モジュールなし"
},
"description_versions": {
"last_edited_by": "最終編集者",
"previously_edited_by": "以前の編集者",
"edited_by": "編集者"
}
}
+94 -8
View File
@@ -594,7 +594,7 @@
"default": "아직 최근 항목이 없습니다."
},
"filters": {
"all": "모든 항목",
"all": "모든",
"projects": "프로젝트",
"pages": "페이지",
"issues": "작업 항목"
@@ -871,7 +871,8 @@
"deleting": "삭제 중",
"pending": "보류 중",
"invite": "초대",
"view": "보기"
"view": "보기",
"deactivated_user": "비활성화된 사용자"
},
"chart": {
@@ -1738,20 +1739,99 @@
}
},
"estimates": {
"title": "프로젝트에 대한 추정 활성화",
"description": "팀의 복잡성과 작업량을 전달하는 데 도움이 됩니다."
"label": "추정",
"title": "프로젝트 추정 활성화",
"description": "팀의 복잡성과 작업량을 전달하는 데 도움이 됩니다.",
"no_estimate": "추정 없음",
"new": "새 추정 시스템",
"create": {
"custom": "사용자 지정",
"start_from_scratch": "처음부터 시작",
"choose_template": "템플릿 선택",
"choose_estimate_system": "추정 시스템 선택",
"enter_estimate_point": "추정 입력",
"step": "단계 {step}/{total}",
"label": "추정 생성"
},
"toasts": {
"created": {
"success": {
"title": "추정 생성됨",
"message": "추정이 성공적으로 생성되었습니다"
},
"error": {
"title": "추정 생성 실패",
"message": "새 추정을 생성할 수 없습니다. 다시 시도해 주세요."
}
},
"updated": {
"success": {
"title": "추정 수정됨",
"message": "프로젝트의 추정이 업데이트되었습니다."
},
"error": {
"title": "추정 수정 실패",
"message": "추정을 수정할 수 없습니다. 다시 시도해 주세요"
}
},
"enabled": {
"success": {
"title": "성공!",
"message": "추정이 활성화되었습니다."
}
},
"disabled": {
"success": {
"title": "성공!",
"message": "추정이 비활성화되었습니다."
},
"error": {
"title": "오류!",
"message": "추정을 비활성화할 수 없습니다. 다시 시도해 주세요"
}
}
},
"validation": {
"min_length": "추정은 0보다 커야 합니다.",
"unable_to_process": "요청을 처리할 수 없습니다. 다시 시도해 주세요.",
"numeric": "추정은 숫자 값이어야 합니다.",
"character": "추정은 문자 값이어야 합니다.",
"empty": "추정 값은 비어있을 수 없습니다.",
"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": {
"label": "자동화",
"auto-archive": {
"title": "닫힌 작업 항목 자동 아카이브",
"description": "Plane은 완료되거나 취소된 작업 항목을 자동으로 아카이브합니다.",
"duration": "닫힌 작업 항목 자동 아카이브"
"title": "완료된 작업 항목 자동 보관",
"description": "Plane은 완료되거나 취소된 작업 항목을 자동으로 보관합니다.",
"duration": "다음 기간 동안 닫힌 작업 항목 자동 보관"
},
"auto-close": {
"title": "작업 항목 자동 닫기",
"description": "Plane은 완료되거나 취소되지 않은 작업 항목을 자동으로 닫습니다.",
"duration": "비활성 상태인 작업 항목 자동 닫기",
"duration": "다음 기간 동안 비활성 작업 항목 자동 닫기",
"auto_close_status": "자동 닫기 상태"
}
},
@@ -2376,5 +2456,11 @@
"module": {
"label": "{count, plural, one {모듈} other {모듈}}",
"no_module": "모듈 없음"
},
"description_versions": {
"last_edited_by": "마지막 편집자",
"previously_edited_by": "이전 편집자",
"edited_by": "편집자"
}
}
+99 -11
View File
@@ -500,7 +500,7 @@
"export": "Eksportuj",
"member": "{count, plural, one{# członek} few{# członkowie} other{# członków}}",
"new_password_must_be_different_from_old_password": "Nowe hasło musi być innym niż stare hasło",
"edited": "Edytowano",
"bot": "Bot",
@@ -590,7 +590,7 @@
"default": "Nie masz jeszcze żadnych ostatnich pozycji."
},
"filters": {
"all": "Wszystkie pozycje",
"all": "Wszystkie",
"projects": "Projekty",
"pages": "Strony",
"issues": "Elementy pracy"
@@ -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",
@@ -1713,21 +1714,100 @@
}
},
"estimates": {
"title": "Włącz szacowania w projekcie",
"description": "Pomaga komunikować złożoność i obciążenie zespołu."
"label": "Szacunki",
"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",
"step": "Krok {step} z {total}",
"label": "Utwórz szacunek"
},
"toasts": {
"created": {
"success": {
"title": "Utworzono szacunek",
"message": "Szacunek został utworzony pomyślnie"
},
"error": {
"title": "Błąd tworzenia szacunku",
"message": "Nie udało się utworzyć nowego szacunku, spróbuj ponownie."
}
},
"updated": {
"success": {
"title": "Zaktualizowano szacunek",
"message": "Szacunek został zaktualizowany w Twoim projekcie."
},
"error": {
"title": "Błąd aktualizacji szacunku",
"message": "Nie udało się zaktualizować szacunku, spróbuj ponownie"
}
},
"enabled": {
"success": {
"title": "Sukces!",
"message": "Szacunki zostały włączone."
}
},
"disabled": {
"success": {
"title": "Sukces!",
"message": "Szacunki zostały wyłączone."
},
"error": {
"title": "Błąd!",
"message": "Nie udało się wyłączyć szacunków. Spróbuj ponownie"
}
}
},
"validation": {
"min_length": "Punkt szacunkowy musi być większy niż 0.",
"unable_to_process": "Nie możemy przetworzyć Twojego żądania, spróbuj ponownie.",
"numeric": "Punkt szacunkowy musi być wartością liczbową.",
"character": "Punkt szacunkowy musi być znakiem.",
"empty": "Wartość szacunku nie może być pusta.",
"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": {
"label": "Automatyzacje",
"label": "Automatyzacja",
"auto-archive": {
"title": "Automatycznie archiwizuj zamknięte elementy",
"description": "Plane będzie archiwizował ukończone lub anulowane elementy.",
"title": "Automatyczna archiwizacja zamkniętych elementów",
"description": "Plane będzie automatycznie archiwizował elementy, które zostały ukończone lub anulowane.",
"duration": "Archiwizuj elementy zamknięte dłużej niż"
},
"auto-close": {
"title": "Automatycznie zamykaj elementy",
"description": "Plane będzie zamykać nieaktywne elementy.",
"title": "Automatyczne zamykanie elementów",
"description": "Plane będzie automatycznie zamykał elementy, które nie zostały ukończone lub anulowane.",
"duration": "Zamknij elementy nieaktywne dłużej niż",
"auto_close_status": "Stan do automatycznego zamknięcia"
"auto_close_status": "Status automatycznego zamknięcia"
}
},
"empty_state": {
@@ -2324,12 +2404,20 @@
"manual": "Ręcznie"
}
},
"cycle": {
"label": "{count, plural, one {Cykl} few {Cykle} other {Cyklów}}",
"no_cycle": "Brak cyklu"
},
"module": {
"label": "{count, plural, one {Moduł} few {Moduły} other {Modułów}}",
"no_module": "Brak modułu"
},
"description_versions": {
"last_edited_by": "Ostatnio edytowane przez",
"previously_edited_by": "Wcześniej edytowane przez",
"edited_by": "Edytowane przez"
}
}
@@ -594,7 +594,7 @@
"default": "Você não tem nenhum item recente ainda."
},
"filters": {
"all": "Todos os itens",
"all": "Todos",
"projects": "Projetos",
"pages": "Páginas",
"issues": "Itens de trabalho"
@@ -871,7 +871,8 @@
"deleting": "Excluindo",
"pending": "Pendente",
"invite": "Convidar",
"view": "Visualizar"
"view": "Visualizar",
"deactivated_user": "Usuário desativado"
},
"chart": {
@@ -1738,8 +1739,87 @@
}
},
"estimates": {
"label": "Estimativas",
"title": "Habilitar estimativas para meu projeto",
"description": "Elas ajudam você a comunicar a complexidade e a carga de trabalho da equipe."
"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",
"step": "Passo {step} de {total}",
"label": "Criar estimativa"
},
"toasts": {
"created": {
"success": {
"title": "Estimativa criada",
"message": "A estimativa foi criada com sucesso"
},
"error": {
"title": "Falha na criação da estimativa",
"message": "Não foi possível criar a nova estimativa, por favor tente novamente."
}
},
"updated": {
"success": {
"title": "Estimativa modificada",
"message": "A estimativa foi atualizada em seu projeto."
},
"error": {
"title": "Falha na modificação da estimativa",
"message": "Não foi possível modificar a estimativa, por favor tente novamente"
}
},
"enabled": {
"success": {
"title": "Sucesso!",
"message": "As estimativas foram habilitadas."
}
},
"disabled": {
"success": {
"title": "Sucesso!",
"message": "As estimativas foram desabilitadas."
},
"error": {
"title": "Erro!",
"message": "Não foi possível desabilitar a estimativa. Por favor, tente novamente"
}
}
},
"validation": {
"min_length": "A estimativa precisa ser maior que 0.",
"unable_to_process": "Não foi possível processar sua solicitação, por favor tente novamente.",
"numeric": "A estimativa precisa ser um valor numérico.",
"character": "A estimativa precisa ser um valor em caracteres.",
"empty": "O valor da estimativa não pode estar vazio.",
"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": {
"label": "Automações",
@@ -2369,5 +2449,11 @@
"module": {
"label": "{count, plural, one {Módulo} other {Módulos}}",
"no_module": "Nenhum módulo"
},
"description_versions": {
"last_edited_by": "Última edição por",
"previously_edited_by": "Anteriormente editado por",
"edited_by": "Editado por"
}
}
+89 -3
View File
@@ -592,7 +592,7 @@
"default": "Nu ai nimic recent încă."
},
"filters": {
"all": "Toate activitățile",
"all": "Toate",
"projects": "Proiecte",
"pages": "Documentație",
"issues": "Activități"
@@ -869,7 +869,8 @@
"deleting": "Se șterge",
"pending": "În așteptare",
"invite": "Invită",
"view": "Vizualizează"
"view": "Vizualizează",
"deactivated_user": "Utilizator dezactivat"
},
"chart": {
@@ -1736,8 +1737,87 @@
}
},
"estimates": {
"label": "Estimări",
"title": "Activează estimările pentru proiectul meu",
"description": "Acestea te ajută să comunici complexitatea și volumul de muncă al echipei."
"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",
"step": "Pasul {step} de {total}",
"label": "Creează estimare"
},
"toasts": {
"created": {
"success": {
"title": "Estimare creată",
"message": "Estimarea a fost creată cu succes"
},
"error": {
"title": "Crearea estimării a eșuat",
"message": "Nu am putut crea noua estimare, te rugăm să încerci din nou."
}
},
"updated": {
"success": {
"title": "Estimare modificată",
"message": "Estimarea a fost actualizată în proiectul tău."
},
"error": {
"title": "Modificarea estimării a eșuat",
"message": "Nu am putut modifica estimarea, te rugăm să încerci din nou"
}
},
"enabled": {
"success": {
"title": "Succes!",
"message": "Estimările au fost activate."
}
},
"disabled": {
"success": {
"title": "Succes!",
"message": "Estimările au fost dezactivate."
},
"error": {
"title": "Eroare!",
"message": "Estimarea nu a putut fi dezactivată. Te rugăm să încerci din nou"
}
}
},
"validation": {
"min_length": "Estimarea trebuie să fie mai mare decât 0.",
"unable_to_process": "Nu putem procesa cererea ta, te rugăm să încerci din nou.",
"numeric": "Estimarea trebuie să fie o valoare numerică.",
"character": "Estimarea trebuie să fie o valoare de tip caracter.",
"empty": "Valoarea estimării nu poate fi goală.",
"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": {
"label": "Automatizări",
@@ -2368,5 +2448,11 @@
"module": {
"label": "{count, plural, one {Modul} other {Module}}",
"no_module": "Niciun modul"
},
"description_versions": {
"last_edited_by": "Ultima editare de către",
"previously_edited_by": "Editat anterior de către",
"edited_by": "Editat de"
}
}
+100 -14
View File
@@ -592,7 +592,7 @@
"default": "Пока нет недавних элементов"
},
"filters": {
"all": "Все элементы",
"all": "Все",
"projects": "Проекты",
"pages": "Страницы",
"issues": "Рабочие элементы"
@@ -869,7 +869,8 @@
"deleting": "Удаление",
"pending": "Ожидание",
"invite": "Пригласить",
"view": "Просмотр"
"view": "Просмотр",
"deactivated_user": "Деактивированный пользователь"
},
"chart": {
@@ -1736,33 +1737,112 @@
}
},
"estimates": {
"title": "Включить оценку для проекта",
"description": "Помогают оценивать сложность и загрузку команды."
"label": "Оценки",
"title": "Включить оценки для моего проекта",
"description": "Они помогают вам в общении о сложности и рабочей нагрузке команды.",
"no_estimate": "Без оценки",
"new": "Новая система оценок",
"create": {
"custom": "Пользовательская",
"start_from_scratch": "Начать с нуля",
"choose_template": "Выбрать шаблон",
"choose_estimate_system": "Выбрать систему оценок",
"enter_estimate_point": "Ввести оценку",
"step": "Шаг {step} из {total}",
"label": "Создать оценку"
},
"toasts": {
"created": {
"success": {
"title": "Оценка создана",
"message": "Оценка успешно создана"
},
"error": {
"title": "Ошибка создания оценки",
"message": "Не удалось создать новую оценку, пожалуйста, попробуйте снова."
}
},
"updated": {
"success": {
"title": "Оценка изменена",
"message": "Оценка обновлена в вашем проекте."
},
"error": {
"title": "Ошибка изменения оценки",
"message": "Не удалось изменить оценку, пожалуйста, попробуйте снова"
}
},
"enabled": {
"success": {
"title": "Успех!",
"message": "Оценки включены."
}
},
"disabled": {
"success": {
"title": "Успех!",
"message": "Оценки отключены."
},
"error": {
"title": "Ошибка!",
"message": "Не удалось отключить оценки. Пожалуйста, попробуйте снова"
}
}
},
"validation": {
"min_length": "Оценка должна быть больше 0.",
"unable_to_process": "Не удалось обработать ваш запрос, пожалуйста, попробуйте снова.",
"numeric": "Оценка должна быть числовым значением.",
"character": "Оценка должна быть символьным значением.",
"empty": "Значение оценки не может быть пустым.",
"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": {
"label": "Автоматизация",
"auto-archive": {
"title": "Автоархивация закрытых рабочих элементов",
"description": "Plane будет автоматически архивировать завершённые или отменённые рабочие элементы.",
"duration": "Архивировать рабочие элементы закрытые более"
"description": "Plane будет автоматически архивировать рабочие элементы, которые были завершены или отменены.",
"duration": "Автоархивация рабочих элементов, которые закрыты в течение"
},
"auto-close": {
"title": "Автозакрытие рабочих элементов",
"description": "Plane будет автоматически закрывать неактивные рабочие элементы.",
"duration": "Закрывать рабочие элементы неактивные более",
"auto_close_status": "Статус автозакрытия"
"title": "Автоматическое закрытие рабочих элементов",
"description": "Plane будет автоматически закрывать рабочие элементы, которые не были завершены или отменены.",
"duration": "Автоматическое закрытие рабочих элементов, которые неактивны в течение",
"auto_close_status": "Статус автоматического закрытия"
}
},
"empty_state": {
"labels": {
"title": "Нет меток",
"description": "Создавайте метки для организации и фильтрации рабочих элементов."
"description": "Создайте метки для организации и фильтрации рабочих элементов в вашем проекте."
},
"estimates": {
"title": "Нет систем оценки",
"description": "Создайте систему оценок для планирования работ.",
"primary_button": "Добавить систему"
"title": "Нет систем оценок",
"description": "Создайте набор оценок для передачи объема работы на каждый рабочий элемент.",
"primary_button": "Добавить систему оценок"
}
}
},
@@ -2374,5 +2454,11 @@
"module": {
"label": "{count, plural, one {Модуль} other {Модули}}",
"no_module": "Нет модуля"
},
"description_versions": {
"last_edited_by": "Последнее редактирование",
"previously_edited_by": "Ранее отредактировано",
"edited_by": "Отредактировано"
}
}
+90 -4
View File
@@ -592,7 +592,7 @@
"default": "Zatiaľ nemáte žiadne nedávne položky."
},
"filters": {
"all": "Všetky položky",
"all": "Všetko",
"projects": "Projekty",
"pages": "Stránky",
"issues": "Pracovné položky"
@@ -869,7 +869,8 @@
"deleting": "Mazanie",
"pending": "Čakajúce",
"invite": "Pozvať",
"view": "Zobraziť"
"view": "Zobraziť",
"deactivated_user": "Deaktivovaný používateľ"
},
"chart": {
@@ -1735,8 +1736,87 @@
}
},
"estimates": {
"title": "Povoliť odhady v projekte",
"description": "Pomáhajú komunikovať zložitosť a vyťaženie tímu."
"label": "Odhady",
"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",
"step": "Krok {step} z {total}",
"label": "Vytvoriť odhad"
},
"toasts": {
"created": {
"success": {
"title": "Bod odhadu vytvorený",
"message": "Bod odhadu bol úspešne vytvorený"
},
"error": {
"title": "Vytvorenie bodu odhadu zlyhalo",
"message": "Nepodarilo sa vytvoriť nový bod odhadu, skúste to prosím znova."
}
},
"updated": {
"success": {
"title": "Odhad upravený",
"message": "Bod odhadu bol aktualizovaný vo vašom projekte."
},
"error": {
"title": "Úprava odhadu zlyhala",
"message": "Nepodarilo sa upraviť odhad, skúste to prosím znova"
}
},
"enabled": {
"success": {
"title": "Úspech!",
"message": "Odhady boli povolené."
}
},
"disabled": {
"success": {
"title": "Úspech!",
"message": "Odhady boli zakázané."
},
"error": {
"title": "Chyba!",
"message": "Odhad sa nepodarilo zakázať. Skúste to prosím znova"
}
}
},
"validation": {
"min_length": "Bod odhadu musí byť väčší ako 0.",
"unable_to_process": "Nemôžeme spracovať vašu požiadavku, skúste to prosím znova.",
"numeric": "Bod odhadu musí byť číselná hodnota.",
"character": "Bod odhadu musí byť znakovou hodnotou.",
"empty": "Hodnota odhadu nemôže byť prázdna.",
"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": {
"label": "Automatizácie",
@@ -2373,5 +2453,11 @@
"module": {
"label": "{count, plural, one {Modul} few {Moduly} other {Modulov}}",
"no_module": "Žiadny modul"
},
"description_versions": {
"last_edited_by": "Naposledy upravené používateľom",
"previously_edited_by": "Predtým upravené používateľom",
"edited_by": "Upravené používateľom"
}
}
+93 -5
View File
@@ -502,7 +502,7 @@
"new_password_must_be_different_from_old_password": "Новий пароль повинен бути відмінним від старого пароля",
"edited": "Редагувано",
"bot": "Бот",
"project_view": {
"sort_by": {
"created_at": "Створено",
@@ -589,7 +589,7 @@
"default": "Поки у вас немає нещодавніх елементів."
},
"filters": {
"all": "Усі елементи",
"all": "Усі",
"projects": "Проєкти",
"pages": "Сторінки",
"issues": "Робочі одиниці"
@@ -864,7 +864,8 @@
"deleting": "Видалення",
"pending": "Очікує",
"invite": "Запросити",
"view": "Подання"
"view": "Подання",
"deactivated_user": "Деактивований користувач"
},
"chart": {
"x_axis": "Вісь X",
@@ -1712,8 +1713,87 @@
}
},
"estimates": {
"title": "Увімкнути оцінки в проєкті",
"description": "Вони допомагають відображати складність і навантаження на команду."
"label": "Оцінки",
"title": "Увімкнути оцінки для мого проєкту",
"description": "Вони допомагають вам повідомляти про складність та навантаження команди.",
"no_estimate": "Без оцінки",
"new": "Нова система оцінок",
"create": {
"custom": "Власний",
"start_from_scratch": "Почати з нуля",
"choose_template": "Вибрати шаблон",
"choose_estimate_system": "Вибрати систему оцінок",
"enter_estimate_point": "Введіть оцінку",
"step": "Крок {step} з {total}",
"label": "Створити оцінку"
},
"toasts": {
"created": {
"success": {
"title": "Оцінку створено",
"message": "Оцінку успішно створено"
},
"error": {
"title": "Не вдалося створити оцінку",
"message": "Не вдалося створити нову оцінку, спробуйте ще раз."
}
},
"updated": {
"success": {
"title": "Оцінку змінено",
"message": "Оцінку оновлено у вашому проєкті."
},
"error": {
"title": "Не вдалося змінити оцінку",
"message": "Не вдалося змінити оцінку, спробуйте ще раз"
}
},
"enabled": {
"success": {
"title": "Успіх!",
"message": "Оцінки увімкнено."
}
},
"disabled": {
"success": {
"title": "Успіх!",
"message": "Оцінки вимкнено."
},
"error": {
"title": "Помилка!",
"message": "Не вдалося вимкнути оцінку. Спробуйте ще раз"
}
}
},
"validation": {
"min_length": "Оцінка має бути більшою за 0.",
"unable_to_process": "Не вдалося обробити ваш запит, спробуйте ще раз.",
"numeric": "Оцінка має бути числовим значенням.",
"character": "Оцінка має бути символьним значенням.",
"empty": "Значення оцінки не може бути порожнім.",
"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": {
"label": "Автоматизація",
@@ -2323,12 +2403,20 @@
"manual": "Вручну"
}
},
"cycle": {
"label": "{count, plural, one {Цикл} few {Цикли} other {Циклів}}",
"no_cycle": "Немає циклу"
},
"module": {
"label": "{count, plural, one {Модуль} few {Модулі} other {Модулів}}",
"no_module": "Немає модуля"
},
"description_versions": {
"last_edited_by": "Останнє редагування",
"previously_edited_by": "Раніше відредаговано",
"edited_by": "Відредаговано"
}
}
@@ -589,7 +589,7 @@
"default": "Bạn chưa có dự án gần đây nào."
},
"filters": {
"all": "Tất cả dự án",
"all": "Tất cả",
"projects": "Dự án",
"pages": "Trang",
"issues": "Mục công việc"
@@ -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",
@@ -1711,8 +1712,86 @@
}
},
"estimates": {
"label": "Ước tính",
"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."
"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",
"step": "Bước {step} của {total}",
"label": "Tạo ước tính"
},
"toasts": {
"created": {
"success": {
"title": "Đã tạo điểm ước tính",
"message": "Điểm ước tính đã được tạo thành công"
},
"error": {
"title": "Không thể tạo điểm ước tính",
"message": "Không thể tạo điểm ước tính mới, vui lòng thử lại"
}
},
"updated": {
"success": {
"title": "Đã cập nhật ước tính",
"message": "Điểm ước tính đã được cập nhật trong dự án của bạn"
},
"error": {
"title": "Không thể cập nhật ước tính",
"message": "Không thể cập nhật ước tính, vui lòng thử lại"
}
},
"enabled": {
"success": {
"title": "Thành công!",
"message": "Đã bật ước tính"
}
},
"disabled": {
"success": {
"title": "Thành công!",
"message": "Đã tắt ước tính"
},
"error": {
"title": "Lỗi!",
"message": "Không thể tắt ước tính. Vui lòng thử lại"
}
}
},
"validation": {
"min_length": "Điểm ước tính phải lớn hơn 0",
"unable_to_process": "Không thể xử lý yêu cầu của bạn, vui lòng thử lại",
"numeric": "Điểm ước tính phải là số",
"character": "Điểm ước tính phải là ký tự",
"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": {
"label": "Tự động hóa",
@@ -2322,12 +2401,20 @@
"manual": "Thủ công"
}
},
"cycle": {
"label": "{count, plural, one {chu kỳ} other {chu kỳ}}",
"no_cycle": "Không có chu kỳ"
},
"module": {
"label": "{count, plural, one {mô-đun} other {mô-đun}}",
"no_module": "Không có mô-đun"
},
"description_versions": {
"last_edited_by": "Chỉnh sửa lần cuối bởi",
"previously_edited_by": "Trước đây được chỉnh sửa bởi",
"edited_by": "Được chỉnh sửa bởi"
}
}
@@ -594,7 +594,7 @@
"default": "您还没有任何最近项目。"
},
"filters": {
"all": "所有项目",
"all": "所有",
"projects": "项目",
"pages": "页面",
"issues": "工作项"
@@ -870,7 +870,8 @@
"deleting": "删除中",
"pending": "待处理",
"invite": "邀请",
"view": "查看"
"view": "查看",
"deactivated_user": "已停用用户"
},
"chart": {
@@ -1736,8 +1737,68 @@
}
},
"estimates": {
"label": "估算",
"title": "为我的项目启用估算",
"description": "它们有助于您传达团队的复杂性和工作量。"
"description": "它们有助于您传达团队的复杂性和工作量。",
"no_estimate": "无估算",
"new": "新估算系统",
"create": {
"custom": "自定义",
"start_from_scratch": "从头开始",
"choose_template": "选择模板",
"choose_estimate_system": "选择估算系统",
"enter_estimate_point": "输入估算点数",
"step": "步骤 {step} 共 {total}",
"label": "创建估算"
},
"toasts": {
"created": {
"success": {
"title": "已创建估算点数",
"message": "估算点数创建成功"
},
"error": {
"title": "无法创建估算点数",
"message": "无法创建新的估算点数,请重试"
}
},
"updated": {
"success": {
"title": "已更新估算",
"message": "您项目中的估算点数已更新"
},
"error": {
"title": "无法更新估算",
"message": "无法更新估算,请重试"
}
},
"enabled": {
"success": {
"title": "成功!",
"message": "已启用估算"
}
},
"disabled": {
"success": {
"title": "成功!",
"message": "已禁用估算"
},
"error": {
"title": "错误!",
"message": "无法禁用估算。请重试"
}
}
},
"validation": {
"min_length": "估算需要大于0。",
"unable_to_process": "我们无法处理您的请求,请重试。",
"numeric": "估算需要是数值。",
"character": "估算需要是字符值。",
"empty": "估算值不能为空。",
"already_exists": "估算值已存在。",
"unsaved_changes": "您有未保存的更改,请在点击完成前保存。",
"remove_empty": "估算不能为空。请在每个字段中输入值或删除没有值的字段。"
}
},
"automations": {
"label": "自动化",
@@ -2374,5 +2435,11 @@
"module": {
"label": "{count, plural, one {模块} other {模块}}",
"no_module": "无模块"
},
"description_versions": {
"last_edited_by": "最后编辑者",
"previously_edited_by": "之前编辑者",
"edited_by": "编辑者"
}
}
@@ -594,7 +594,7 @@
"default": "您還沒有任何最近項目。"
},
"filters": {
"all": "所有項目",
"all": "所有",
"projects": "專案",
"pages": "頁面",
"issues": "工作事項"
@@ -871,7 +871,8 @@
"deleting": "刪除中",
"pending": "待處理",
"invite": "邀請",
"view": "檢視"
"view": "檢視",
"deactivated_user": "已停用用戶"
},
"chart": {
@@ -1738,20 +1739,99 @@
}
},
"estimates": {
"title": "為我的專案啟用評估",
"description": "它們可以協助您傳達團隊的複雜度和工作量。"
"label": "估",
"title": "為我的專案啟用預估",
"description": "幫助你傳達團隊的複雜性和工作負荷。",
"no_estimate": "無預估",
"new": "新估算系統",
"create": {
"custom": "自訂",
"start_from_scratch": "從頭開始",
"choose_template": "選擇範本",
"choose_estimate_system": "選擇預估系統",
"enter_estimate_point": "輸入預估",
"step": "步驟 {step} 共 {total}",
"label": "建立預估"
},
"toasts": {
"created": {
"success": {
"title": "預估已建立",
"message": "預估已成功建立"
},
"error": {
"title": "預估建立失敗",
"message": "我們無法建立新的預估,請重試。"
}
},
"updated": {
"success": {
"title": "預估已修改",
"message": "專案中的預估已更新。"
},
"error": {
"title": "預估修改失敗",
"message": "我們無法修改預估,請重試"
}
},
"enabled": {
"success": {
"title": "成功!",
"message": "預估已啟用。"
}
},
"disabled": {
"success": {
"title": "成功!",
"message": "預估已停用。"
},
"error": {
"title": "錯誤!",
"message": "無法停用預估。請重試"
}
}
},
"validation": {
"min_length": "預估必須大於0。",
"unable_to_process": "我們無法處理你的請求,請重試。",
"numeric": "預估必須是數值。",
"character": "預估必須是字元值。",
"empty": "預估值不能為空。",
"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": {
"label": "自動化",
"auto-archive": {
"title": "自動封存已關閉的工作項",
"description": "Plane 將自動封存已完成或取消的工作項。",
"duration": "自動封存已關閉"
"title": "自動封存已關閉的工作項",
"description": "Plane將自動封存已完成或取消的工作項。",
"duration": "自動封存已關閉的工作項目"
},
"auto-close": {
"title": "自動關閉工作項",
"description": "Plane 將自動關閉未完成或取消的工作項。",
"duration": "自動關閉閒置",
"title": "自動關閉工作項",
"description": "Plane將自動關閉未完成或取消的工作項。",
"duration": "自動關閉非活動工作項目",
"auto_close_status": "自動關閉狀態"
}
},
@@ -2376,5 +2456,11 @@
"module": {
"label": "{count, plural, one {模組} other {模組}}",
"no_module": "無模組"
},
"description_versions": {
"last_edited_by": "最後編輯者",
"previously_edited_by": "先前編輯者",
"edited_by": "編輯者"
}
}
@@ -56,7 +56,6 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
dataKey={bar.key}
stackId={bar.stackId}
opacity={!!activeLegend && activeLegend !== bar.key ? 0.1 : 1}
fill={bar.fill}
shape={(shapeProps: any) => {
const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
@@ -64,6 +63,7 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
return (
<CustomBar
{...shapeProps}
fill={typeof bar.fill === "function" ? bar.fill(shapeProps.payload) : bar.fill}
stackKeys={stackKeys}
textClassName={bar.textClassName}
showPercentage={bar.showPercentage}
@@ -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: {
+2 -1
View File
@@ -43,7 +43,7 @@ type TChartProps<K extends string, T extends string> = {
export type TBarItem<T extends string> = {
key: T;
label: string;
fill: string;
fill: string | ((payload: any) => string);
textClassName: string;
showPercentage?: boolean;
stackId: string;
@@ -116,6 +116,7 @@ export type TPieChartProps<K extends string, T extends string> = Pick<
text?: string | number;
};
tooltipLabel?: string | ((payload: any) => string);
customLegend?: (props: any) => React.ReactNode;
};
export type TreeMapItem = {
+8
View File
@@ -26,3 +26,11 @@ export type TLogoProps = {
export type TNameDescriptionLoader = "submitting" | "submitted" | "saved";
export type TFetchStatus = "partial" | "complete" | undefined;
export type ICustomSearchSelectOption = {
value: any;
query: string;
content: React.ReactNode;
disabled?: boolean;
tooltip?: string | React.ReactNode;
};
+29
View File
@@ -0,0 +1,29 @@
export type TDescriptionVersion = {
created_at: string;
created_by: string | null;
id: string;
last_saved_at: string;
owned_by: string;
project: string;
updated_at: string;
updated_by: string | null;
};
export type TDescriptionVersionDetails = TDescriptionVersion & {
description_binary: string | null;
description_html: string | null;
description_json: object | null;
description_stripped: string | null;
};
export type TDescriptionVersionsListResponse = {
cursor: string;
next_cursor: string | null;
next_page_results: boolean;
page_count: number;
prev_cursor: string | null;
prev_page_results: boolean;
results: TDescriptionVersion[];
total_pages: number;
total_results: number;
};
+4 -7
View File
@@ -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;
+3 -1
View File
@@ -1,6 +1,8 @@
// plane constants
import { TInboxIssue, TInboxIssueStatus } from "@plane/constants";
// plane types
import { TPaginationInfo } from "./common";
import { TIssuePriorities } from "./issues";
import { TIssue } from "./issues/base";
// filters
export type TInboxIssueFilterMemberKeys = "assignees" | "created_by";
+2
View File
@@ -3,6 +3,7 @@ export * from "./workspace";
export * from "./cycle";
export * from "./dashboard";
export * from "./de-dupe";
export * from "./description_version";
export * from "./project";
export * from "./state";
export * from "./issues";
@@ -41,3 +42,4 @@ export * from "./charts";
export * from "./home";
export * from "./stickies";
export * from "./utils";
export * from "./payment";
+2 -3
View File
@@ -37,6 +37,7 @@ export interface IInstance {
}
export interface IInstanceConfig {
enable_signup: boolean;
is_workspace_creation_disabled: boolean;
is_google_enabled: boolean;
is_github_enabled: boolean;
@@ -72,9 +73,7 @@ export interface IInstanceAdmin {
user_detail: IUserLite;
}
export type TInstanceIntercomConfigurationKeys =
| "IS_INTERCOM_ENABLED"
| "INTERCOM_APP_ID";
export type TInstanceIntercomConfigurationKeys = "IS_INTERCOM_ENABLED" | "INTERCOM_APP_ID";
export type TInstanceConfigurationKeys =
| TInstanceAIConfigurationKeys
+4 -1
View File
@@ -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
View File
@@ -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>;
+1 -1
View File
@@ -120,7 +120,7 @@ export type TBulkOperationsPayload = {
export type TIssueDetailWidget = "sub-issues" | "relations" | "links" | "attachments";
export type TIssueServiceType = EIssueServiceType.ISSUES | EIssueServiceType.EPICS;
export type TIssueServiceType = EIssueServiceType.ISSUES | EIssueServiceType.EPICS | EIssueServiceType.WORK_ITEMS;
export interface IPublicIssue
extends Pick<
+17
View File
@@ -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>;
};
+36
View File
@@ -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;
};
+8
View File
@@ -24,3 +24,11 @@ export interface IStateLite {
export interface IStateResponse {
[key: string]: IState[];
}
export type TStateOperationsCallbacks = {
createState: (data: Partial<IState>) => Promise<IState>;
updateState: (stateId: string, data: Partial<IState>) => Promise<IState | undefined>;
deleteState: (stateId: string) => Promise<void>;
moveStatePosition: (stateId: string, data: Partial<IState>) => Promise<void>;
markStateAsDefault: (stateId: string) => Promise<void>;
};
@@ -1,10 +1,10 @@
import React, { FC } from "react";
import { DropdownIcon } from "../icons";
import { cn } from "../../helpers";
import { DropdownIcon } from "../icons";
type Props = {
isOpen: boolean;
title: string;
title: React.ReactNode;
hideChevron?: boolean;
indicatorElement?: React.ReactNode;
actionItemElement?: React.ReactNode;
+3 -2
View File
@@ -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 ? (
@@ -50,6 +50,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
<Combobox.Option
key={keyExtractor(option)}
value={keyExtractor(option)}
disabled={option.disabled}
className={({ active, selected }) =>
cn(
"flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5",
@@ -66,7 +67,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
{({ selected }) => (
<>
{renderItem ? (
<>{renderItem({ value: keyExtractor(option), selected })}</>
<>{renderItem({ value: keyExtractor(option), selected, disabled: option.disabled })}</>
) : (
<>
<span className="flex-grow truncate">{option.value}</span>
+14 -3
View File
@@ -27,7 +27,15 @@ export interface IDropdown {
queryArray?: string[];
sortByKey?: string;
firstItem?: (optionValue: string) => boolean;
renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode;
renderItem?: ({
value,
selected,
disabled,
}: {
value: string;
selected: boolean;
disabled?: boolean;
}) => React.ReactNode;
loader?: React.ReactNode;
disableSorting?: boolean;
}
@@ -35,7 +43,8 @@ export interface IDropdown {
export interface TDropdownOption {
data: any;
value: string;
className?: ({ active, selected }: { active: boolean; selected: boolean }) => string;
className?: ({ active, selected }: { active: boolean; selected?: boolean }) => string;
disabled?: boolean;
}
export interface IMultiSelectDropdown extends IDropdown {
@@ -82,7 +91,9 @@ export interface IDropdownOptions {
handleClose?: () => void;
keyExtractor: (option: TDropdownOption) => string;
renderItem: (({ value, selected }: { value: string; selected: boolean }) => React.ReactNode) | undefined;
renderItem:
| (({ value, selected, disabled }: { value: string; selected: boolean; disabled?: boolean }) => React.ReactNode)
| undefined;
options: TDropdownOption[] | undefined;
loader?: React.ReactNode;
isMobile?: boolean;
+2 -9
View File
@@ -1,5 +1,6 @@
// FIXME: fix this!!!
import { Placement } from "@blueprintjs/popover2";
import { ICustomSearchSelectOption } from "@plane/types";
export interface IDropdownProps {
customButtonClassName?: string;
@@ -44,15 +45,7 @@ interface CustomSearchSelectProps {
onChange: any;
onClose?: () => void;
noResultsMessage?: string;
options:
| {
value: any;
query: string;
content: React.ReactNode;
disabled?: boolean;
tooltip?: string | React.ReactNode;
}[]
| undefined;
options?: ICustomSearchSelectOption[];
}
interface SingleValueProps {
+19
View File
@@ -1,5 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { CompleteOrEmpty } from "@plane/types";
// Support email can be configured by the application
export const getSupportEmail = (defaultEmail: string = ""): string => defaultEmail;
@@ -39,3 +40,21 @@ export const partitionValidIds = (ids: string[], validIds: string[]): { valid: s
return { valid, invalid };
};
/**
* Checks if an object is complete (has properties) rather than empty.
* This helps TypeScript narrow the type from CompleteOrEmpty<T> to T.
*
* @param obj The object to check, typed as CompleteOrEmpty<T>
* @returns A boolean indicating if the object is complete (true) or empty (false)
*/
export const isComplete = <T>(obj: CompleteOrEmpty<T>): obj is T => {
// Check if object is not null or undefined
if (obj == null) return false;
// Check if it's an object
if (typeof obj !== "object") return false;
// Check if it has any own properties
return Object.keys(obj).length > 0;
};
+1
View File
@@ -12,3 +12,4 @@ export * from "./string";
export * from "./theme";
export * from "./workspace";
export * from "./work-item";
export * from "./subscription";
+5 -9
View File
@@ -55,13 +55,6 @@ export const createSimilarString = (str: string) => {
return shuffled;
};
/**
* @description Copies full URL (origin + path) to clipboard
* @param {string} path - URL path to copy
* @returns {Promise<void>} Promise that resolves when copying is complete
* @example
* await copyUrlToClipboard("issues/123") // copies "https://example.com/issues/123"
*/
/**
* @description Copies text to clipboard
* @param {string} text - Text to copy
@@ -86,8 +79,11 @@ export const copyTextToClipboard = async (text: string): Promise<void> => {
* await copyUrlToClipboard("issues/123") // copies "https://example.com/issues/123"
*/
export const copyUrlToClipboard = async (path: string) => {
const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
await copyTextToClipboard(`${originUrl}/${path}`);
// get origin or default to empty string if not in browser
const originUrl = typeof window !== "undefined" ? window.location.origin : "";
// create URL object and ensure proper path formatting
const url = new URL(path, originUrl);
await copyTextToClipboard(url.toString());
};
/**
+106
View File
@@ -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",
},
};
};
@@ -8,13 +8,12 @@ import { Plus, Search } from "lucide-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
import { cn } from "@plane/utils";
import { cn, copyUrlToClipboard } from "@plane/utils";
// components
import { CreateProjectModal } from "@/components/project";
import { SidebarProjectsListItem } from "@/components/workspace";
// hooks
import { orderJoinedProjects } from "@/helpers/project.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
import { useAppTheme, useProject, useUserPermissions } from "@/hooks/store";
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
import { TProject } from "@/plane-web/types";
@@ -1,6 +1,6 @@
"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";
@@ -18,12 +18,18 @@ import {
// i18n
import { useTranslation } from "@plane/i18n";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
import {
ICustomSearchSelectOption,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
} from "@plane/types";
// ui
import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip, Header } from "@plane/ui";
import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { BreadcrumbLink } from "@/components/common";
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
import { CycleQuickActions } from "@/components/cycles";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
@@ -69,6 +75,8 @@ const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => {
};
export const CycleIssuesHeader: React.FC = observer(() => {
// refs
const parentRef = useRef<HTMLDivElement>(null);
// states
const [analyticsModal, setAnalyticsModal] = useState(false);
// router
@@ -159,6 +167,25 @@ export const CycleIssuesHeader: React.FC = observer(() => {
EUserPermissionsLevel.PROJECT
);
const switcherOptions = currentProjectCycleIds
?.map((id) => {
const _cycle = id === cycleId ? cycleDetails : getCycleById(id);
if (!_cycle) return;
const cycleLink = `/${workspaceSlug}/projects/${projectId}/cycles/${_cycle.id}`;
return {
value: _cycle.id,
query: _cycle.name,
content: (
<Link href={cycleLink}>
<SwitcherLabel name={_cycle.name} LabelIcon={ContrastIcon} />
</Link>
),
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
const workItemsCount = getGroupIssueCount(undefined, undefined, false);
const issuesCount = getGroupIssueCount(undefined, undefined, false);
return (
@@ -201,33 +228,29 @@ export const CycleIssuesHeader: React.FC = observer(() => {
<Breadcrumbs.BreadcrumbItem
type="component"
component={
<CustomMenu
<CustomSearchSelect
options={switcherOptions}
value={cycleId}
onChange={() => {}}
label={
<>
<ContrastIcon className="h-3 w-3" />
<div className="flex w-auto max-w-[70px] items-center gap-2 truncate sm:max-w-[200px]">
<p className="truncate">{cycleDetails?.name && cycleDetails.name}</p>
{issuesCount && issuesCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${issuesCount} ${
issuesCount > 1 ? "work items" : "work item"
} in this cycle`}
position="bottom"
>
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2 text-center text-xs font-semibold text-custom-primary-100">
{issuesCount}
</span>
</Tooltip>
) : null}
</div>
</>
<div className="flex items-center gap-1">
<SwitcherLabel name={cycleDetails?.name} LabelIcon={ContrastIcon} />
{workItemsCount && workItemsCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${workItemsCount} ${
workItemsCount > 1 ? "work items" : "work item"
} in this cycle`}
position="bottom"
>
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2 text-center text-xs font-semibold text-custom-primary-100">
{workItemsCount}
</span>
</Tooltip>
) : null}
</div>
}
className="ml-1.5 flex-shrink-0 truncate"
placement="bottom-start"
>
{currentProjectCycleIds?.map((cycleId) => <CycleDropdownOption key={cycleId} cycleId={cycleId} />)}
</CustomMenu>
/>
}
/>
</Breadcrumbs>
@@ -302,19 +325,19 @@ export const CycleIssuesHeader: 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={`h-4 w-4 duration-300 ${isSidebarCollapsed ? "-rotate-180" : ""}`} />
<PanelRight className={cn("h-4 w-4", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
</button>
<CycleQuickActions
parentRef={parentRef}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
customClassName="flex-shrink-0 flex items-center justify-center size-6 bg-custom-background-80/70 rounded"
/>
</div>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 md:hidden"
onClick={toggleSidebar}
>
<PanelRight className={cn("h-4 w-4", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
</button>
</Header.RightItem>
</Header>
</>
@@ -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,
@@ -16,17 +16,22 @@ import {
EUserPermissionsLevel,
} from "@plane/constants";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
import {
ICustomSearchSelectOption,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
} from "@plane/types";
// ui
import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip, Header } from "@plane/ui";
import { Breadcrumbs, Button, DiceIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { BreadcrumbLink } from "@/components/common";
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,
@@ -46,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
@@ -155,7 +139,24 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
EUserPermissionsLevel.PROJECT
);
const issuesCount = getGroupIssueCount(undefined, undefined, false);
const workItemsCount = getGroupIssueCount(undefined, undefined, false);
const switcherOptions = projectModuleIds
?.map((id) => {
const _module = id === moduleId ? moduleDetails : getModuleById(id);
if (!_module) return;
const moduleLink = `/${workspaceSlug}/projects/${projectId}/modules/${_module.id}`;
return {
value: _module.id,
query: _module.name,
content: (
<Link href={moduleLink}>
<SwitcherLabel name={_module.name} LabelIcon={DiceIcon} />
</Link>
),
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
return (
<>
@@ -196,33 +197,29 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
<Breadcrumbs.BreadcrumbItem
type="component"
component={
<CustomMenu
<CustomSearchSelect
options={switcherOptions}
label={
<>
<DiceIcon className="h-3 w-3" />
<div className="flex w-auto max-w-[70px] items-center gap-2 truncate sm:max-w-[200px]">
<p className="truncate">{moduleDetails?.name && moduleDetails.name}</p>
{issuesCount && issuesCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${issuesCount} ${
issuesCount > 1 ? "work items" : "work item"
} in this module`}
position="bottom"
>
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2 text-center text-xs font-semibold text-custom-primary-100">
{issuesCount}
</span>
</Tooltip>
) : null}
</div>
</>
<div className="flex items-center gap-1">
<SwitcherLabel name={moduleDetails?.name} LabelIcon={DiceIcon} />
{workItemsCount && workItemsCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${workItemsCount} ${
workItemsCount > 1 ? "work items" : "work item"
} in this module`}
position="bottom"
>
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2 text-center text-xs font-semibold text-custom-primary-100">
{workItemsCount}
</span>
</Tooltip>
) : null}
</div>
}
className="ml-1.5 flex-shrink-0"
placement="bottom-start"
>
{projectModuleIds?.map((moduleId) => <ModuleDropdownOption key={moduleId} moduleId={moduleId} />)}
</CustomMenu>
value={moduleId}
onChange={() => {}}
/>
}
/>
</Breadcrumbs>
@@ -302,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>
</>
@@ -1,27 +1,37 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { FileText } from "lucide-react";
import { ArchiveIcon, Earth, FileText, Lock } from "lucide-react";
// types
import { TLogoProps } from "@plane/types";
import { EPageAccess } from "@plane/constants";
import { ICustomSearchSelectOption, TPage } from "@plane/types";
// ui
import { Breadcrumbs, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE, Tooltip, setToast, Header } from "@plane/ui";
import { Breadcrumbs, Header, CustomSearchSelect } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
import { PageEditInformationPopover } from "@/components/pages";
// helpers
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
import { getPageName } from "@/helpers/page.helper";
// hooks
import { getPageName } from "@/helpers/page.helper";
import { useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
// plane web hooks
import { EPageStoreType, usePage } from "@/plane-web/hooks/store";
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;
@@ -29,42 +39,39 @@ export interface IPagesHeaderProps {
export const PageDetailsHeader = observer(() => {
// router
const { workspaceSlug, pageId } = useParams();
// state
const [isOpen, setIsOpen] = useState(false);
const { workspaceSlug, pageId, projectId } = useParams();
// store hooks
const { currentProjectDetails, loader } = useProject();
const page = usePage({
pageId: pageId?.toString() ?? "",
storeType: EPageStoreType.PROJECT,
});
if (!page) return null;
const { getPageById, getCurrentProjectPageIds } = usePageStore(EPageStoreType.PROJECT);
// derived values
const { name, logo_props, updatePageLogo, isContentEditable } = page;
// use platform
const { isMobile } = usePlatformOS();
const projectPageIds = getCurrentProjectPageIds(projectId?.toString());
const handlePageLogoUpdate = async (data: TLogoProps) => {
if (data) {
updatePageLogo(data)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Logo Updated successfully.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again.",
});
});
}
};
if (!page) return null;
const switcherOptions = projectPageIds
.map((id) => {
const _page = id === pageId ? page : getPageById(id);
if (!_page) return;
const pageLink = `/${workspaceSlug}/projects/${projectId}/pages/${_page.id}`;
return {
value: _page.id,
query: _page.name,
content: (
<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>
),
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
const pageTitle = getPageName(name);
if (!page) return null;
return (
<Header>
@@ -99,60 +106,16 @@ export const PageDetailsHeader = observer(() => {
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<li className="flex items-center space-x-2" tabIndex={-1}>
<div className="flex flex-wrap items-center gap-2.5">
<div className="flex cursor-default items-center gap-1 text-sm font-medium text-custom-text-100">
<div className="flex h-5 w-5 items-center justify-center overflow-hidden">
<EmojiIconPicker
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
className="flex items-center justify-center"
buttonClassName="flex items-center justify-center"
label={
<>
{logo_props?.in_use ? (
<Logo logo={logo_props} size={16} type="lucide" />
) : (
<FileText className="h-4 w-4 text-custom-text-300" />
)}
</>
}
onChange={(val) => {
let logoValue = {};
if (val?.type === "emoji")
logoValue = {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
};
else if (val?.type === "icon") logoValue = val.value;
handlePageLogoUpdate({
in_use: val?.type,
[val?.type]: logoValue,
}).finally(() => setIsOpen(false));
}}
defaultIconColor={
logo_props?.in_use && logo_props.in_use === "icon" ? logo_props?.icon?.color : undefined
}
defaultOpen={
logo_props?.in_use && logo_props?.in_use === "emoji"
? EmojiIconPickerTypes.EMOJI
: EmojiIconPickerTypes.ICON
}
disabled={!isContentEditable}
/>
</div>
<Tooltip tooltipContent={pageTitle} position="bottom" isMobile={isMobile}>
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">
{pageTitle}
</div>
</Tooltip>
</div>
</div>
</li>
type="component"
component={
<CustomSearchSelect
value={pageId}
options={switcherOptions}
label={
<SwitcherLabel logo_props={page.logo_props} name={getPageName(page.name)} LabelIcon={FileText} />
}
onChange={() => {}}
/>
}
/>
</Breadcrumbs>
@@ -1,11 +1,22 @@
"use client";
// component
import { useParams } from "next/navigation";
import useSWR from "swr";
import { AppHeader, ContentWrapper } from "@/components/core";
// plane web hooks
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
// local components
import { PageDetailsHeader } from "./header";
export default function ProjectPageDetailsLayout({ children }: { children: React.ReactNode }) {
const { workspaceSlug, projectId } = useParams();
const { fetchPagesList } = usePageStore(EPageStoreType.PROJECT);
// fetching pages list
useSWR(
workspaceSlug && projectId ? `PROJECT_PAGES_${projectId}` : null,
workspaceSlug && projectId ? () => fetchPagesList(workspaceSlug.toString(), projectId.toString()) : null
);
return (
<>
<AppHeader header={<PageDetailsHeader />} />
@@ -16,17 +16,21 @@ import {
EUserPermissionsLevel,
} from "@plane/constants";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
import {
ICustomSearchSelectOption,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
} from "@plane/types";
// ui
import { Breadcrumbs, Button, CustomMenu, Tooltip, Header } from "@plane/ui";
import { Breadcrumbs, Button, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// constants
import { ViewQuickActions } from "@/components/views";
// helpers
import { isIssueFilterActive } from "@/helpers/filter.helper";
import { truncateText } from "@/helpers/string.helper";
// hooks
import {
useCommandPalette,
@@ -143,6 +147,23 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
if (!viewDetails) return;
const switcherOptions = projectViewIds
?.map((id) => {
const _view = id === viewId ? viewDetails : getViewById(id);
if (!_view) return;
const viewLink = `/${workspaceSlug}/projects/${projectId}/views/${_view.id}`;
return {
value: _view.id,
query: _view.name,
content: (
<Link href={viewLink}>
<SwitcherLabel logo_props={_view.logo_props} name={_view.name} LabelIcon={Layers} />
</Link>
),
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
return (
<Header>
<Header.LeftItem>
@@ -161,42 +182,12 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
<Breadcrumbs.BreadcrumbItem
type="component"
component={
<CustomMenu
label={
<>
{viewDetails?.logo_props?.in_use ? (
<Logo logo={viewDetails.logo_props} size={12} type="lucide" />
) : (
<Layers height={12} width={12} />
)}
{viewDetails?.name && truncateText(viewDetails.name, 40)}
</>
}
className="ml-1.5"
placement="bottom-start"
>
{projectViewIds?.map((viewId) => {
const view = getViewById(viewId);
if (!view) return;
return (
<CustomMenu.MenuItem key={viewId}>
<Link
href={`/${workspaceSlug}/projects/${projectId}/views/${viewId}`}
className="flex items-center gap-1.5"
>
{view?.logo_props?.in_use ? (
<Logo logo={view.logo_props} size={12} type="lucide" />
) : (
<Layers height={12} width={12} />
)}
{truncateText(view.name, 40)}
</Link>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
<CustomSearchSelect
options={switcherOptions}
value={viewId}
label={<SwitcherLabel logo_props={viewDetails.logo_props} name={viewDetails.name} LabelIcon={Layers} />}
onChange={() => {}}
/>
}
/>
</Breadcrumbs>
@@ -210,17 +201,8 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
) : (
<></>
)}
<div className="hidden md:block">
<ViewQuickActions
parentRef={parentRef}
projectId={projectId.toString()}
view={viewDetails}
workspaceSlug={workspaceSlug.toString()}
/>
</div>
</Header.LeftItem>
<Header.RightItem>
<Header.RightItem className="items-center">
{!viewDetails?.is_locked ? (
<>
<LayoutSelection
@@ -287,6 +269,15 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
) : (
<></>
)}
<div className="hidden md:block">
<ViewQuickActions
parentRef={parentRef}
customClassName="flex-shrink-0 flex items-center justify-center size-6 bg-custom-background-80/70 rounded"
projectId={projectId.toString()}
view={viewDetails}
workspaceSlug={workspaceSlug.toString()}
/>
</div>
</Header.RightItem>
</Header>
);
@@ -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
+26 -20
View File
@@ -17,7 +17,7 @@ import { EPageTypes } from "@/helpers/authentication.helper";
import { cn } from "@/helpers/common.helper";
import { checkEmailValidity } from "@/helpers/string.helper";
// hooks
import { useEventTracker } from "@/hooks/store";
import { useEventTracker, useInstance } from "@/hooks/store";
import useTimer from "@/hooks/use-timer";
// wrappers
import { AuthenticationWrapper } from "@/lib/wrappers";
@@ -48,6 +48,7 @@ const ForgotPasswordPage = observer(() => {
const { t } = useTranslation();
// store hooks
const { captureEvent } = useEventTracker();
const { config } = useInstance();
// hooks
const { resolvedTheme } = useTheme();
// timer
@@ -93,6 +94,9 @@ const ForgotPasswordPage = observer(() => {
});
};
// derived values
const enableSignUpConfig = config?.enable_signup ?? false;
const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo;
return (
@@ -101,39 +105,41 @@ const ForgotPasswordPage = observer(() => {
<div className="absolute inset-0 z-0">
<Image
src={resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern}
className="w-full h-full object-cover"
className="object-cover w-full h-full"
alt="Plane background pattern"
/>
</div>
<div className="relative z-10 w-screen h-screen overflow-hidden overflow-y-auto flex flex-col">
<div className="container min-w-full px-10 lg:px-20 xl:px-36 flex-shrink-0 relative flex items-center justify-between pb-4 transition-all">
<div className="flex items-center gap-x-2 py-10">
<div className="relative z-10 flex flex-col w-screen h-screen overflow-hidden overflow-y-auto">
<div className="container relative flex items-center justify-between flex-shrink-0 min-w-full px-10 pb-4 transition-all lg:px-20 xl:px-36">
<div className="flex items-center py-10 gap-x-2">
<Link href={`/`} className="h-[30px] w-[133px]">
<Image src={logo} alt="Plane logo" />
</Link>
</div>
<div className="flex flex-col items-end sm:items-center sm:gap-2 sm:flex-row text-center text-sm font-medium text-onboarding-text-300">
{t("auth.common.new_to_plane")}
<Link
href="/"
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
className="font-semibold text-custom-primary-100 hover:underline"
>
{t("auth.common.create_account")}
</Link>
</div>
{enableSignUpConfig && (
<div className="flex flex-col items-end text-sm font-medium text-center sm:items-center sm:gap-2 sm:flex-row text-onboarding-text-300">
{t("auth.common.new_to_plane")}
<Link
href="/"
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
className="font-semibold text-custom-primary-100 hover:underline"
>
{t("auth.common.create_account")}
</Link>
</div>
)}
</div>
<div className="flex-grow container mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 py-10 lg:pt-28 transition-all">
<div className="container flex-grow max-w-lg px-10 py-10 mx-auto transition-all lg:max-w-md lg:px-5 lg:pt-28">
<div className="relative flex flex-col space-y-6">
<div className="text-center space-y-1 py-4">
<h3 className="flex gap-4 justify-center text-3xl font-bold text-onboarding-text-100">
<div className="py-4 space-y-1 text-center">
<h3 className="flex justify-center gap-4 text-3xl font-bold text-onboarding-text-100">
{t("auth.forgot_password.title")}
</h3>
<p className="font-medium text-onboarding-text-400">{t("auth.forgot_password.description")}</p>
</div>
<form onSubmit={handleSubmit(handleForgotPassword)} className="mt-5 space-y-4">
<div className="space-y-1">
<label className="text-sm text-onboarding-text-300 font-medium" htmlFor="email">
<label className="text-sm font-medium text-onboarding-text-300" htmlFor="email">
{t("auth.common.email.label")}
</label>
<Controller
@@ -160,7 +166,7 @@ const ForgotPasswordPage = observer(() => {
)}
/>
{resendTimerCode > 0 && (
<p className="flex w-full items-start px-1 gap-1 text-xs font-medium text-green-700">
<p className="flex items-start w-full gap-1 px-1 text-xs font-medium text-green-700">
<CircleCheck height={12} width={12} className="mt-0.5" />
{t("auth.forgot_password.email_sent")}
</p>
+21 -15
View File
@@ -15,7 +15,7 @@ import { PageHead } from "@/components/core";
// helpers
import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper";
// hooks
import { useEventTracker } from "@/hooks/store";
import { useEventTracker, useInstance } from "@/hooks/store";
// layouts
import DefaultLayout from "@/layouts/default-layout";
// wrappers
@@ -32,6 +32,10 @@ const HomePage = observer(() => {
const { t } = useTranslation();
// hooks
const { captureEvent } = useEventTracker();
// store
const { config } = useInstance();
// derived values
const enableSignUpConfig = config?.enable_signup ?? false;
const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo;
@@ -44,27 +48,29 @@ const HomePage = observer(() => {
<div className="absolute inset-0 z-0">
<Image
src={resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern}
className="w-full h-full object-cover"
className="object-cover w-full h-full"
alt="Plane background pattern"
/>
</div>
<div className="relative z-10 w-screen h-screen overflow-hidden overflow-y-auto flex flex-col">
<div className="container min-w-full px-10 lg:px-20 xl:px-36 flex-shrink-0 relative flex items-center justify-between pb-4 transition-all">
<div className="flex items-center gap-x-2 py-10">
<div className="relative z-10 flex flex-col w-screen h-screen overflow-hidden overflow-y-auto">
<div className="container relative flex items-center justify-between flex-shrink-0 min-w-full px-10 pb-4 transition-all lg:px-20 xl:px-36">
<div className="flex items-center py-10 gap-x-2">
<Link href={`/`} className="h-[30px] w-[133px]">
<Image src={logo} alt="Plane logo" />
</Link>
</div>
<div className="flex flex-col items-end sm:items-center sm:gap-2 sm:flex-row text-center text-sm font-medium text-onboarding-text-300">
{t("auth.common.new_to_plane")}
<Link
href="/sign-up"
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
className="font-semibold text-custom-primary-100 hover:underline"
>
{t("auth.common.create_account")}
</Link>
</div>
{enableSignUpConfig && (
<div className="flex flex-col items-end text-sm font-medium text-center sm:items-center sm:gap-2 sm:flex-row text-onboarding-text-300">
{t("auth.common.new_to_plane")}
<Link
href="/sign-up"
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
className="font-semibold text-custom-primary-100 hover:underline"
>
{t("auth.common.create_account")}
</Link>
</div>
)}
</div>
<div className="flex flex-col justify-center flex-grow container h-[100vh-60px] mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 transition-all">
<AuthRoot authMode={EAuthModes.SIGN_IN} />
+1 -1
View File
@@ -50,7 +50,7 @@ const WorkspaceInvitationPage = observer(() => {
})
.then(() => {
if (email === currentUser?.email) {
router.push("/invitations");
router.push(`/${invitationDetail.workspace.slug}`);
} else {
router.push(`/?${searchParams.toString()}`);
}
@@ -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

Some files were not shown because too many files have changed in this diff Show More