Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f278a284c4 | ||
|
|
2bcf6c76cd | ||
|
|
fb3e022042 | ||
|
|
e3fbb7b073 | ||
|
|
cce6dd581c | ||
|
|
d86ac368a4 | ||
|
|
101994840a | ||
|
|
f60f57ef11 | ||
|
|
546217f09b | ||
|
|
6df8323665 | ||
|
|
77d022df71 | ||
|
|
797f150ec4 | ||
|
|
b54f54999e | ||
|
|
dff176be8f | ||
|
|
2bbaaed3ea | ||
|
|
b5ceb94fb2 | ||
|
|
feb6243065 | ||
|
|
5dacba74c9 | ||
|
|
0efb0c239c | ||
|
|
c8be836d6c | ||
|
|
833b82e247 | ||
|
|
280aa7f671 | ||
|
|
eac1115566 | ||
|
|
8166a757a7 | ||
|
|
be5d77d978 | ||
|
|
18fb3b8450 | ||
|
|
ef5616905e | ||
|
|
aeb41e603c | ||
|
|
55eea1a8b7 | ||
|
|
fa87ff14b7 | ||
|
|
7d91b5f8df | ||
|
|
3ce40dfa2f | ||
|
|
f65253c994 | ||
|
|
97fcfaa653 | ||
|
|
0e1ebff978 | ||
|
|
642dabfe35 | ||
|
|
48557cb670 | ||
|
|
608da1465c | ||
|
|
dbcc7bedb4 | ||
|
|
c401b26dd4 | ||
|
|
a4bca0c39c | ||
|
|
24899887b2 | ||
|
|
c6953ff878 | ||
|
|
06be9ab81b | ||
|
|
ed8d00acb1 | ||
|
|
915e374485 | ||
|
|
1d5b93cebd | ||
|
|
df65b8c34a | ||
|
|
4c688b1d25 | ||
|
|
bfc6ed839f | ||
|
|
b68396a4b2 | ||
|
|
b4fc715aba | ||
|
|
33a1b916cb | ||
|
|
2818310619 | ||
|
|
882520b3c7 | ||
|
|
20132e7544 | ||
|
|
0ae57b49d2 | ||
|
|
d347269afb | ||
|
|
a3fd616ec4 | ||
|
|
9eeff158d5 | ||
|
|
ef20b5814e | ||
|
|
14914e8716 | ||
|
|
b738e39a4a |
@@ -273,7 +273,7 @@ jobs:
|
||||
run: |
|
||||
cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh
|
||||
sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deploy/selfhost/docker-compose.yml
|
||||
sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env
|
||||
# sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
|
||||
@@ -15,6 +15,22 @@ Without said minimal reproduction, we won't be able to investigate all [issues](
|
||||
|
||||
You can open a new issue with this [issue form](https://github.com/makeplane/plane/issues/new).
|
||||
|
||||
### Naming conventions for issues
|
||||
|
||||
When opening a new issue, please use a clear and concise title that follows this format:
|
||||
|
||||
- For bugs: `🐛 Bug: [short description]`
|
||||
- For features: `🚀 Feature: [short description]`
|
||||
- For improvements: `🛠️ Improvement: [short description]`
|
||||
- For documentation: `📘 Docs: [short description]`
|
||||
|
||||
**Examples:**
|
||||
- `🐛 Bug: API token expiry time not saving correctly`
|
||||
- `📘 Docs: Clarify RAM requirement for local setup`
|
||||
- `🚀 Feature: Allow custom time selection for token expiration`
|
||||
|
||||
This helps us triage and manage issues more efficiently.
|
||||
|
||||
## Projects setup and Architecture
|
||||
|
||||
### Requirements
|
||||
@@ -23,6 +39,8 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla
|
||||
- Python version 3.8+
|
||||
- Postgres version v14
|
||||
- Redis version v6.2.7
|
||||
- **Memory**: Minimum **12 GB RAM** recommended
|
||||
> ⚠️ Running the project on a system with only 8 GB RAM may lead to setup failures or memory crashes (especially during Docker container build/start or dependency install). Use cloud environments like GitHub Codespaces or upgrade local RAM if possible.
|
||||
|
||||
### Setup the project
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Admin UI for Plane",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"description": "API server powering Plane's backend"
|
||||
|
||||
@@ -39,7 +39,15 @@ class CycleSerializer(BaseSerializer):
|
||||
data.get("start_date", None) is not None
|
||||
and data.get("end_date", None) is not None
|
||||
):
|
||||
project_id = self.initial_data.get("project_id") or self.instance.project_id
|
||||
project_id = self.initial_data.get("project_id") or (
|
||||
self.instance.project_id
|
||||
if self.instance and hasattr(self.instance, "project_id")
|
||||
else None
|
||||
)
|
||||
|
||||
if not project_id:
|
||||
raise serializers.ValidationError("Project ID is required")
|
||||
|
||||
is_start_date_end_date_equal = (
|
||||
True
|
||||
if str(data.get("start_date")) == str(data.get("end_date"))
|
||||
|
||||
@@ -141,8 +141,10 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
if pk:
|
||||
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
|
||||
data = CycleSerializer(
|
||||
queryset, fields=self.fields,
|
||||
expand=self.expand, context={"project": project}
|
||||
queryset,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
).data
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
queryset = self.get_queryset().filter(archived_at__isnull=True)
|
||||
@@ -154,8 +156,11 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
start_date__lte=timezone.now(), end_date__gte=timezone.now()
|
||||
)
|
||||
data = CycleSerializer(
|
||||
queryset, many=True, fields=self.fields,
|
||||
expand=self.expand, context={"project": project}
|
||||
queryset,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
).data
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -166,8 +171,11 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles, many=True, fields=self.fields,
|
||||
expand=self.expand, context={"project": project}
|
||||
cycles,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
).data,
|
||||
)
|
||||
|
||||
@@ -178,8 +186,11 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles, many=True, fields=self.fields,
|
||||
expand=self.expand, context={"project": project}
|
||||
cycles,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
).data,
|
||||
)
|
||||
|
||||
@@ -190,8 +201,11 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles, many=True, fields=self.fields,
|
||||
expand=self.expand, context={"project": project}
|
||||
cycles,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
).data,
|
||||
)
|
||||
|
||||
@@ -204,16 +218,22 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles, many=True, fields=self.fields,
|
||||
expand=self.expand, context={"project": project}
|
||||
cycles,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
).data,
|
||||
)
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles, many=True, fields=self.fields,
|
||||
expand=self.expand, context={"project": project}
|
||||
cycles,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
context={"project": project},
|
||||
).data,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State
|
||||
from plane.utils.host import base_host
|
||||
from .base import BaseAPIView
|
||||
from plane.db.models.intake import SourceType
|
||||
|
||||
|
||||
class IntakeIssueAPIEndpoint(BaseAPIView):
|
||||
@@ -125,7 +126,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
|
||||
intake_id=intake.id,
|
||||
project_id=project_id,
|
||||
issue=issue,
|
||||
source=request.data.get("source", "IN-APP"),
|
||||
source=SourceType.IN_APP,
|
||||
)
|
||||
# Create an Issue Activity
|
||||
issue_activity.delay(
|
||||
|
||||
@@ -57,6 +57,7 @@ from plane.settings.storage import S3Storage
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.host import base_host
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
|
||||
|
||||
class WorkspaceIssueAPIEndpoint(BaseAPIView):
|
||||
@@ -322,6 +323,17 @@ class IssueAPIEndpoint(BaseAPIView):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(serializer.data["id"]),
|
||||
requested_data=request.data,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ProjectAPIEndpoint(BaseAPIView):
|
||||
"""Project Endpoints to create, update, list, retrieve and delete endpoint"""
|
||||
|
||||
@@ -238,7 +239,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except Workspace.DoesNotExist:
|
||||
return Response(
|
||||
@@ -247,7 +248,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
def patch(self, request, slug, pk):
|
||||
@@ -305,7 +306,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except (Project.DoesNotExist, Workspace.DoesNotExist):
|
||||
return Response(
|
||||
@@ -314,7 +315,7 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
except ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
def delete(self, request, slug, pk):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import APIToken, APIActivityLog
|
||||
from rest_framework import serializers
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class APITokenSerializer(BaseSerializer):
|
||||
@@ -17,10 +19,17 @@ class APITokenSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class APITokenReadSerializer(BaseSerializer):
|
||||
is_active = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = APIToken
|
||||
exclude = ("token",)
|
||||
|
||||
def get_is_active(self, obj: APIToken) -> bool:
|
||||
if obj.expired_at is None:
|
||||
return True
|
||||
return timezone.now() < obj.expired_at
|
||||
|
||||
|
||||
class APIActivityLogSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -137,7 +137,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
||||
if type not in allowed_types:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
|
||||
"error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -351,7 +351,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||
if type not in allowed_types:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
|
||||
"error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -552,7 +552,7 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||
if type not in allowed_types:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
|
||||
"error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -683,7 +683,7 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
|
||||
# For some cases, the bulk api is called after the issue is deleted creating
|
||||
# an integrity error
|
||||
try:
|
||||
assets.update(issue_id=entity_id)
|
||||
assets.update(issue_id=entity_id, project_id=project_id)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ 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
|
||||
from plane.db.models.intake import SourceType
|
||||
|
||||
|
||||
class IntakeViewSet(BaseViewSet):
|
||||
@@ -278,7 +279,7 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
intake_id=intake_id.id,
|
||||
project_id=project_id,
|
||||
issue_id=serializer.data["id"],
|
||||
source=request.data.get("source", "IN-APP"),
|
||||
source=SourceType.IN_APP,
|
||||
)
|
||||
# Create an Issue Activity
|
||||
issue_activity.delay(
|
||||
@@ -408,7 +409,6 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if issue_serializer.is_valid():
|
||||
|
||||
# Log all the updates
|
||||
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
|
||||
if issue is not None:
|
||||
@@ -607,7 +607,6 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class IntakeWorkItemDescriptionVersionEndpoint(BaseAPIView):
|
||||
|
||||
def process_paginated_result(self, fields, results, timezone):
|
||||
paginated_data = results.values(*fields)
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ProjectViewSet(BaseViewSet):
|
||||
serializer_class = ProjectListSerializer
|
||||
model = Project
|
||||
@@ -341,7 +342,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except Workspace.DoesNotExist:
|
||||
return Response(
|
||||
@@ -350,7 +351,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
except serializers.ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, pk=None):
|
||||
@@ -419,7 +420,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The project name is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
except (Project.DoesNotExist, Workspace.DoesNotExist):
|
||||
return Response(
|
||||
@@ -428,7 +429,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
except serializers.ValidationError:
|
||||
return Response(
|
||||
{"identifier": "The project identifier is already taken"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, pk):
|
||||
|
||||
@@ -29,7 +29,7 @@ class WebhookEndpoint(BaseAPIView):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"error": "URL already exists for the workspace"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
raise IntegrityError
|
||||
|
||||
|
||||
@@ -119,7 +119,9 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# Get total members and role
|
||||
total_members=WorkspaceMember.objects.filter(workspace_id=serializer.data["id"]).count()
|
||||
total_members = WorkspaceMember.objects.filter(
|
||||
workspace_id=serializer.data["id"]
|
||||
).count()
|
||||
data = serializer.data
|
||||
data["total_members"] = total_members
|
||||
data["role"] = 20
|
||||
@@ -134,7 +136,7 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"slug": "The workspace with the slug already exists"},
|
||||
status=status.HTTP_410_GONE,
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
@@ -167,10 +169,9 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
|
||||
role = (
|
||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True)
|
||||
.values("role")
|
||||
)
|
||||
role = WorkspaceMember.objects.filter(
|
||||
workspace=OuterRef("id"), member=request.user, is_active=True
|
||||
).values("role")
|
||||
|
||||
workspace = (
|
||||
Workspace.objects.prefetch_related(
|
||||
|
||||
@@ -307,6 +307,10 @@ def track_labels(
|
||||
|
||||
# Set of newly added labels
|
||||
for added_label in added_labels:
|
||||
# validate uuids
|
||||
if not is_valid_uuid(added_label):
|
||||
continue
|
||||
|
||||
label = Label.objects.get(pk=added_label)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -327,6 +331,10 @@ def track_labels(
|
||||
|
||||
# Set of dropped labels
|
||||
for dropped_label in dropped_labels:
|
||||
# validate uuids
|
||||
if not is_valid_uuid(dropped_label):
|
||||
continue
|
||||
|
||||
label = Label.objects.get(pk=dropped_label)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -373,6 +381,10 @@ def track_assignees(
|
||||
|
||||
bulk_subscribers = []
|
||||
for added_asignee in added_assignees:
|
||||
# validate uuids
|
||||
if not is_valid_uuid(added_asignee):
|
||||
continue
|
||||
|
||||
assignee = User.objects.get(pk=added_asignee)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -406,6 +418,10 @@ def track_assignees(
|
||||
)
|
||||
|
||||
for dropped_assignee in dropped_assginees:
|
||||
# validate uuids
|
||||
if not is_valid_uuid(dropped_assignee):
|
||||
continue
|
||||
|
||||
assignee = User.objects.get(pk=dropped_assignee)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -466,7 +482,7 @@ def track_estimate_points(
|
||||
),
|
||||
old_value=old_estimate.value if old_estimate else None,
|
||||
new_value=new_estimate.value if new_estimate else None,
|
||||
field="estimate_point",
|
||||
field="estimate_" + new_estimate.estimate.type,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment="updated the estimate point to ",
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
# Python imports
|
||||
import os
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import Celery
|
||||
from plane.settings.redis import redis_instance
|
||||
from pythonjsonlogger.jsonlogger import JsonFormatter
|
||||
from celery.signals import after_setup_logger, after_setup_task_logger
|
||||
from celery.schedules import crontab
|
||||
|
||||
# Module imports
|
||||
from plane.settings.redis import redis_instance
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
|
||||
|
||||
@@ -47,6 +55,28 @@ app.conf.beat_schedule = {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Setup logging
|
||||
@after_setup_logger.connect
|
||||
def setup_loggers(logger, *args, **kwargs):
|
||||
formatter = JsonFormatter(
|
||||
'"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s'
|
||||
)
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(fmt=formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
@after_setup_task_logger.connect
|
||||
def setup_task_loggers(logger, *args, **kwargs):
|
||||
formatter = JsonFormatter(
|
||||
'"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s'
|
||||
)
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(fmt=formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
app.autodiscover_tasks()
|
||||
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 4.2.17 on 2025-03-04 19:29
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0092_alter_deprecateddashboardwidget_unique_together_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="page",
|
||||
name="moved_to_page",
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="page",
|
||||
name="moved_to_project",
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="pageversion",
|
||||
name="sub_pages_data",
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
]
|
||||
@@ -82,4 +82,4 @@ from .label import Label
|
||||
|
||||
from .device import Device, DeviceSession
|
||||
|
||||
from .sticky import Sticky
|
||||
from .sticky import Sticky
|
||||
@@ -31,6 +31,10 @@ class Intake(ProjectBaseModel):
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
class SourceType(models.TextChoices):
|
||||
IN_APP = "IN_APP"
|
||||
|
||||
|
||||
class IntakeIssue(ProjectBaseModel):
|
||||
intake = models.ForeignKey(
|
||||
"db.Intake", related_name="issue_intake", on_delete=models.CASCADE
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# Module imports
|
||||
from plane.db.models import APIActivityLog
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
|
||||
class APITokenLogMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
request_body = request.body
|
||||
response = self.get_response(request)
|
||||
self.process_request(request, response, request_body)
|
||||
return response
|
||||
|
||||
def process_request(self, request, response, request_body):
|
||||
api_key_header = "X-Api-Key"
|
||||
api_key = request.headers.get(api_key_header)
|
||||
# If the API key is present, log the request
|
||||
if api_key:
|
||||
try:
|
||||
APIActivityLog.objects.create(
|
||||
token_identifier=api_key,
|
||||
path=request.path,
|
||||
method=request.method,
|
||||
query_params=request.META.get("QUERY_STRING", ""),
|
||||
headers=str(request.headers),
|
||||
body=(request_body.decode("utf-8") if request_body else None),
|
||||
response_body=(
|
||||
response.content.decode("utf-8") if response.content else None
|
||||
),
|
||||
response_code=response.status_code,
|
||||
ip_address=get_client_ip(request=request),
|
||||
user_agent=request.META.get("HTTP_USER_AGENT", None),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
# If the token does not exist, you can decide whether to log this as an invalid attempt
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,111 @@
|
||||
# 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
|
||||
from plane.db.models import APIActivityLog
|
||||
|
||||
|
||||
api_logger = logging.getLogger("plane.api.request")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class APITokenLogMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
request_body = request.body
|
||||
response = self.get_response(request)
|
||||
self.process_request(request, response, request_body)
|
||||
return response
|
||||
|
||||
def process_request(self, request, response, request_body):
|
||||
api_key_header = "X-Api-Key"
|
||||
api_key = request.headers.get(api_key_header)
|
||||
# If the API key is present, log the request
|
||||
if api_key:
|
||||
try:
|
||||
APIActivityLog.objects.create(
|
||||
token_identifier=api_key,
|
||||
path=request.path,
|
||||
method=request.method,
|
||||
query_params=request.META.get("QUERY_STRING", ""),
|
||||
headers=str(request.headers),
|
||||
body=(request_body.decode("utf-8") if request_body else None),
|
||||
response_body=(
|
||||
response.content.decode("utf-8") if response.content else None
|
||||
),
|
||||
response_code=response.status_code,
|
||||
ip_address=get_client_ip(request=request),
|
||||
user_agent=request.META.get("HTTP_USER_AGENT", None),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
api_logger.exception(e)
|
||||
# If the token does not exist, you can decide whether to log this as an invalid attempt
|
||||
|
||||
return None
|
||||
@@ -58,7 +58,8 @@ MIDDLEWARE = [
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"crum.CurrentRequestUserMiddleware",
|
||||
"django.middleware.gzip.GZipMiddleware",
|
||||
"plane.middleware.api_log_middleware.APITokenLogMiddleware",
|
||||
"plane.middleware.logger.APITokenLogMiddleware",
|
||||
"plane.middleware.logger.RequestLoggerMiddleware",
|
||||
]
|
||||
|
||||
# Rest Framework settings
|
||||
@@ -390,4 +391,8 @@ ATTACHMENT_MIME_TYPES = [
|
||||
"text/xml",
|
||||
"text/csv",
|
||||
"application/xml",
|
||||
# SQL
|
||||
"application/x-sql",
|
||||
# Gzip
|
||||
"application/x-gzip",
|
||||
]
|
||||
|
||||
@@ -37,26 +37,41 @@ 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.request": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.api": {"level": "INFO", "handlers": ["console"], "propagate": False},
|
||||
"plane.worker": {"level": "INFO", "handlers": ["console"], "propagate": False},
|
||||
"plane.exception": {
|
||||
"level": "ERROR",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.external": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
"plane": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -26,11 +26,10 @@ if not os.path.exists(LOG_DIR):
|
||||
# Logging configuration
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"disable_existing_loggers": True,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
|
||||
"style": "{",
|
||||
"format": "%(asctime)s [%(process)d] %(levelname)s %(name)s: %(message)s"
|
||||
},
|
||||
"json": {
|
||||
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
|
||||
@@ -40,7 +39,7 @@ LOGGING = {
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
"formatter": "json",
|
||||
"level": "INFO",
|
||||
},
|
||||
"file": {
|
||||
@@ -59,16 +58,30 @@ LOGGING = {
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"django": {"handlers": ["console", "file"], "level": "INFO", "propagate": True},
|
||||
"django.request": {
|
||||
"handlers": ["console", "file"],
|
||||
"level": "INFO",
|
||||
"plane.api.request": {
|
||||
"level": "DEBUG" if DEBUG else "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane": {
|
||||
"plane.api": {
|
||||
"level": "DEBUG" if DEBUG else "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.worker": {
|
||||
"level": "DEBUG" if DEBUG else "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.exception": {
|
||||
"level": "DEBUG" if DEBUG else "ERROR",
|
||||
"handlers": ["console", "file"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.external": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Q, UUIDField, Value, F, Case, When, JSONField, CharField
|
||||
from django.db.models.functions import Coalesce, JSONObject, Concat
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
@@ -17,13 +20,25 @@ from plane.db.models import (
|
||||
)
|
||||
|
||||
|
||||
def issue_queryset_grouper(queryset, group_by, sub_group_by):
|
||||
def issue_queryset_grouper(
|
||||
queryset: QuerySet[Issue], group_by: Optional[str], sub_group_by: Optional[str]
|
||||
) -> QuerySet[Issue]:
|
||||
FIELD_MAPPER = {
|
||||
"label_ids": "labels__id",
|
||||
"assignee_ids": "assignees__id",
|
||||
"module_ids": "issue_module__module_id",
|
||||
}
|
||||
|
||||
GROUP_FILTER_MAPPER = {
|
||||
"assignees__id": Q(issue_assignee__deleted_at__isnull=True),
|
||||
"labels__id": Q(label_issue__deleted_at__isnull=True),
|
||||
"issue_module__module_id": Q(issue_module__deleted_at__isnull=True),
|
||||
}
|
||||
|
||||
for group_key in [group_by, sub_group_by]:
|
||||
if group_key in GROUP_FILTER_MAPPER:
|
||||
queryset = queryset.filter(GROUP_FILTER_MAPPER[group_key])
|
||||
|
||||
annotations_map = {
|
||||
"assignee_ids": (
|
||||
"assignees__id",
|
||||
@@ -50,7 +65,9 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
|
||||
return queryset.annotate(**default_annotations)
|
||||
|
||||
|
||||
def issue_on_results(issues, group_by, sub_group_by):
|
||||
def issue_on_results(
|
||||
issues: QuerySet[Issue], group_by: Optional[str], sub_group_by: Optional[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
FIELD_MAPPER = {
|
||||
"labels__id": "label_ids",
|
||||
"assignees__id": "assignee_ids",
|
||||
@@ -160,7 +177,12 @@ def issue_on_results(issues, group_by, sub_group_by):
|
||||
return issues
|
||||
|
||||
|
||||
def issue_group_values(field, slug, project_id=None, filters=dict):
|
||||
def issue_group_values(
|
||||
field: str,
|
||||
slug: str,
|
||||
project_id: Optional[str] = None,
|
||||
filters: Dict[str, Any] = {},
|
||||
) -> List[Union[str, Any]]:
|
||||
if field == "state_id":
|
||||
queryset = State.objects.filter(
|
||||
is_triage=False, workspace__slug=slug
|
||||
|
||||
@@ -96,7 +96,7 @@ class EntityAssetEndpoint(BaseAPIView):
|
||||
if type not in allowed_types:
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
|
||||
"error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -41,4 +41,4 @@ class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest):
|
||||
response = self.client.post(
|
||||
url, {"name": "Plane", "slug": "pla-ne"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_410_GONE)
|
||||
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
|
||||
|
||||
@@ -8,8 +8,8 @@ from django.conf import settings
|
||||
|
||||
def log_exception(e):
|
||||
# Log the error
|
||||
logger = logging.getLogger("plane")
|
||||
logger.error(e)
|
||||
logger = logging.getLogger("plane.exception")
|
||||
logger.exception(e)
|
||||
|
||||
if settings.DEBUG:
|
||||
# Print the traceback if in debug mode
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Q, UUIDField, Value
|
||||
from django.db.models import Q, UUIDField, Value, QuerySet
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Module imports
|
||||
@@ -15,16 +15,31 @@ from plane.db.models import (
|
||||
State,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from typing import Optional, Dict, Tuple, Any, Union, List
|
||||
|
||||
|
||||
def issue_queryset_grouper(queryset, group_by, sub_group_by):
|
||||
FIELD_MAPPER = {
|
||||
def issue_queryset_grouper(
|
||||
queryset: QuerySet[Issue],
|
||||
group_by: Optional[str],
|
||||
sub_group_by: Optional[str],
|
||||
) -> QuerySet[Issue]:
|
||||
FIELD_MAPPER: Dict[str, str] = {
|
||||
"label_ids": "labels__id",
|
||||
"assignee_ids": "assignees__id",
|
||||
"module_ids": "issue_module__module_id",
|
||||
}
|
||||
|
||||
annotations_map = {
|
||||
GROUP_FILTER_MAPPER: Dict[str, Q] = {
|
||||
"assignees__id": Q(issue_assignee__deleted_at__isnull=True),
|
||||
"labels__id": Q(label_issue__deleted_at__isnull=True),
|
||||
"issue_module__module_id": Q(issue_module__deleted_at__isnull=True),
|
||||
}
|
||||
|
||||
for group_key in [group_by, sub_group_by]:
|
||||
if group_key in GROUP_FILTER_MAPPER:
|
||||
queryset = queryset.filter(GROUP_FILTER_MAPPER[group_key])
|
||||
|
||||
annotations_map: Dict[str, Tuple[str, Q]] = {
|
||||
"assignee_ids": (
|
||||
"assignees__id",
|
||||
~Q(assignees__id__isnull=True) & Q(issue_assignee__deleted_at__isnull=True),
|
||||
@@ -42,7 +57,8 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
|
||||
),
|
||||
),
|
||||
}
|
||||
default_annotations = {
|
||||
|
||||
default_annotations: Dict[str, Any] = {
|
||||
key: Coalesce(
|
||||
ArrayAgg(field, distinct=True, filter=condition),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
@@ -54,16 +70,20 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
|
||||
return queryset.annotate(**default_annotations)
|
||||
|
||||
|
||||
def issue_on_results(issues, group_by, sub_group_by):
|
||||
FIELD_MAPPER = {
|
||||
def issue_on_results(
|
||||
issues: QuerySet[Issue],
|
||||
group_by: Optional[str],
|
||||
sub_group_by: Optional[str],
|
||||
) -> List[Dict[str, Any]]:
|
||||
FIELD_MAPPER: Dict[str, str] = {
|
||||
"labels__id": "label_ids",
|
||||
"assignees__id": "assignee_ids",
|
||||
"issue_module__module_id": "module_ids",
|
||||
}
|
||||
|
||||
original_list = ["assignee_ids", "label_ids", "module_ids"]
|
||||
original_list: List[str] = ["assignee_ids", "label_ids", "module_ids"]
|
||||
|
||||
required_fields = [
|
||||
required_fields: List[str] = [
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
@@ -98,62 +118,72 @@ def issue_on_results(issues, group_by, sub_group_by):
|
||||
original_list.append(sub_group_by)
|
||||
|
||||
required_fields.extend(original_list)
|
||||
return issues.values(*required_fields)
|
||||
return list(issues.values(*required_fields))
|
||||
|
||||
|
||||
def issue_group_values(field, slug, project_id=None, filters=dict):
|
||||
def issue_group_values(
|
||||
field: str,
|
||||
slug: str,
|
||||
project_id: Optional[str] = None,
|
||||
filters: Dict[str, Any] = {},
|
||||
) -> List[Union[str, Any]]:
|
||||
if field == "state_id":
|
||||
queryset = State.objects.filter(
|
||||
is_triage=False, workspace__slug=slug
|
||||
).values_list("id", flat=True)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
return list(queryset)
|
||||
|
||||
if field == "labels__id":
|
||||
queryset = Label.objects.filter(workspace__slug=slug).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id)) + ["None"]
|
||||
else:
|
||||
return list(queryset) + ["None"]
|
||||
return list(queryset) + ["None"]
|
||||
|
||||
if field == "assignees__id":
|
||||
if project_id:
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, is_active=True
|
||||
).values_list("member_id", flat=True)
|
||||
else:
|
||||
return list(
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, is_active=True
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, is_active=True
|
||||
).values_list("member_id", flat=True)
|
||||
)
|
||||
return list(
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, is_active=True
|
||||
).values_list("member_id", flat=True)
|
||||
)
|
||||
|
||||
if field == "issue_module__module_id":
|
||||
queryset = Module.objects.filter(workspace__slug=slug).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id)) + ["None"]
|
||||
else:
|
||||
return list(queryset) + ["None"]
|
||||
return list(queryset) + ["None"]
|
||||
|
||||
if field == "cycle_id":
|
||||
queryset = Cycle.objects.filter(workspace__slug=slug).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id)) + ["None"]
|
||||
else:
|
||||
return list(queryset) + ["None"]
|
||||
return list(queryset) + ["None"]
|
||||
|
||||
if field == "project_id":
|
||||
queryset = Project.objects.filter(workspace__slug=slug).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
return list(queryset)
|
||||
|
||||
if field == "priority":
|
||||
return ["low", "medium", "high", "urgent", "none"]
|
||||
|
||||
if field == "state__group":
|
||||
return ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
|
||||
if field == "target_date":
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug)
|
||||
@@ -163,8 +193,8 @@ def issue_group_values(field, slug, project_id=None, filters=dict):
|
||||
)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
return list(queryset)
|
||||
|
||||
if field == "start_date":
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug)
|
||||
@@ -174,8 +204,7 @@ def issue_group_values(field, slug, project_id=None, filters=dict):
|
||||
)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
return list(queryset)
|
||||
|
||||
if field == "created_by":
|
||||
queryset = (
|
||||
@@ -186,7 +215,6 @@ def issue_group_values(field, slug, project_id=None, filters=dict):
|
||||
)
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
return list(queryset)
|
||||
|
||||
return []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,10 +51,9 @@ x-app-env: &app-env
|
||||
API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute}
|
||||
MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0}
|
||||
|
||||
|
||||
services:
|
||||
web:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable}
|
||||
image: artifacts.plane.so/makeplane/plane-frontend:${APP_RELEASE:-stable}
|
||||
command: node web/server.js web
|
||||
deploy:
|
||||
replicas: ${WEB_REPLICAS:-1}
|
||||
@@ -65,7 +64,7 @@ services:
|
||||
- worker
|
||||
|
||||
space:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable}
|
||||
image: artifacts.plane.so/makeplane/plane-space:${APP_RELEASE:-stable}
|
||||
command: node space/server.js space
|
||||
deploy:
|
||||
replicas: ${SPACE_REPLICAS:-1}
|
||||
@@ -77,7 +76,7 @@ services:
|
||||
- web
|
||||
|
||||
admin:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable}
|
||||
image: artifacts.plane.so/makeplane/plane-admin:${APP_RELEASE:-stable}
|
||||
command: node admin/server.js admin
|
||||
deploy:
|
||||
replicas: ${ADMIN_REPLICAS:-1}
|
||||
@@ -88,7 +87,7 @@ services:
|
||||
- web
|
||||
|
||||
live:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-live:${APP_RELEASE:-stable}
|
||||
image: artifacts.plane.so/makeplane/plane-live:${APP_RELEASE:-stable}
|
||||
command: node live/dist/server.js live
|
||||
environment:
|
||||
<<: [*live-env]
|
||||
@@ -101,7 +100,7 @@ services:
|
||||
- web
|
||||
|
||||
api:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-stable}
|
||||
command: ./bin/docker-entrypoint-api.sh
|
||||
deploy:
|
||||
replicas: ${API_REPLICAS:-1}
|
||||
@@ -117,7 +116,7 @@ services:
|
||||
- plane-mq
|
||||
|
||||
worker:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-stable}
|
||||
command: ./bin/docker-entrypoint-worker.sh
|
||||
deploy:
|
||||
replicas: ${WORKER_REPLICAS:-1}
|
||||
@@ -134,7 +133,7 @@ services:
|
||||
- plane-mq
|
||||
|
||||
beat-worker:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-stable}
|
||||
command: ./bin/docker-entrypoint-beat.sh
|
||||
deploy:
|
||||
replicas: ${BEAT_WORKER_REPLICAS:-1}
|
||||
@@ -151,7 +150,7 @@ services:
|
||||
- plane-mq
|
||||
|
||||
migrator:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-stable}
|
||||
command: ./bin/docker-entrypoint-migrator.sh
|
||||
deploy:
|
||||
replicas: 1
|
||||
@@ -213,7 +212,7 @@ services:
|
||||
|
||||
# Comment this if you already have a reverse proxy running
|
||||
proxy:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable}
|
||||
image: artifacts.plane.so/makeplane/plane-proxy:${APP_RELEASE:-stable}
|
||||
ports:
|
||||
- target: 80
|
||||
published: ${NGINX_PORT:-80}
|
||||
|
||||
@@ -5,7 +5,7 @@ SCRIPT_DIR=$PWD
|
||||
SERVICE_FOLDER=plane-app
|
||||
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
|
||||
export APP_RELEASE=stable
|
||||
export DOCKERHUB_USER=makeplane
|
||||
export DOCKERHUB_USER=artifacts.plane.so/makeplane
|
||||
export PULL_POLICY=${PULL_POLICY:-if_not_present}
|
||||
export GH_REPO=makeplane/plane
|
||||
export RELEASE_DOWNLOAD_URL="https://github.com/$GH_REPO/releases/download"
|
||||
@@ -631,7 +631,7 @@ if [ -f "$DOCKER_ENV_PATH" ]; then
|
||||
CUSTOM_BUILD=$(getEnvValue "CUSTOM_BUILD" "$DOCKER_ENV_PATH")
|
||||
|
||||
if [ -z "$DOCKERHUB_USER" ]; then
|
||||
DOCKERHUB_USER=makeplane
|
||||
DOCKERHUB_USER=artifacts.plane.so/makeplane
|
||||
updateEnvFile "DOCKERHUB_USER" "$DOCKERHUB_USER" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ SERVICE_FOLDER=plane-app
|
||||
SCRIPT_DIR=$PWD
|
||||
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
|
||||
export APP_RELEASE="stable"
|
||||
export DOCKERHUB_USER=makeplane
|
||||
export DOCKERHUB_USER=artifacts.plane.so/makeplane
|
||||
|
||||
export GH_REPO=makeplane/plane
|
||||
export RELEASE_DOWNLOAD_URL="https://github.com/$GH_REPO/releases/download"
|
||||
@@ -596,7 +596,7 @@ if [ -f "$DOCKER_ENV_PATH" ]; then
|
||||
APP_RELEASE=$(getEnvValue "APP_RELEASE" "$DOCKER_ENV_PATH")
|
||||
|
||||
if [ -z "$DOCKERHUB_USER" ]; then
|
||||
DOCKERHUB_USER=makeplane
|
||||
DOCKERHUB_USER=artifacts.plane.so/makeplane
|
||||
updateEnvFile "DOCKERHUB_USER" "$DOCKERHUB_USER" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
|
||||
|
||||
@@ -60,4 +60,4 @@ GUNICORN_WORKERS=1
|
||||
MINIO_ENDPOINT_SSL=0
|
||||
|
||||
# API key rate limit
|
||||
API_KEY_RATE_LIMIT="60/minute"
|
||||
API_KEY_RATE_LIMIT=60/minute
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "A realtime collaborative server powers Plane's rich text editor",
|
||||
"main": "./src/server.ts",
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"name": "plane",
|
||||
"description": "Open-source project management that unlocks customer value",
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
@@ -24,7 +24,7 @@
|
||||
"devDependencies": {
|
||||
"prettier": "latest",
|
||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||
"turbo": "^2.4.2"
|
||||
"turbo": "^2.5.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"nanoid": "3.3.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"license": "AGPL-3.0"
|
||||
|
||||
@@ -1,91 +1,97 @@
|
||||
import { TInboxDuplicateIssueDetails, TIssue } from "@plane/types";
|
||||
|
||||
export enum EInboxIssueCurrentTab {
|
||||
OPEN = "open",
|
||||
CLOSED = "closed",
|
||||
OPEN = "open",
|
||||
CLOSED = "closed",
|
||||
}
|
||||
|
||||
export enum EInboxIssueStatus {
|
||||
PENDING = -2,
|
||||
DECLINED = -1,
|
||||
SNOOZED = 0,
|
||||
ACCEPTED = 1,
|
||||
DUPLICATE = 2,
|
||||
PENDING = -2,
|
||||
DECLINED = -1,
|
||||
SNOOZED = 0,
|
||||
ACCEPTED = 1,
|
||||
DUPLICATE = 2,
|
||||
}
|
||||
|
||||
export enum EInboxIssueSource {
|
||||
IN_APP = "IN_APP",
|
||||
FORMS = "FORMS",
|
||||
EMAIL = "EMAIL",
|
||||
}
|
||||
|
||||
export type TInboxIssueCurrentTab = EInboxIssueCurrentTab;
|
||||
export type TInboxIssueStatus = EInboxIssueStatus;
|
||||
export type TInboxIssue = {
|
||||
id: string;
|
||||
status: TInboxIssueStatus;
|
||||
snoozed_till: Date | null;
|
||||
duplicate_to: string | undefined;
|
||||
source: string;
|
||||
issue: TIssue;
|
||||
created_by: string;
|
||||
duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined;
|
||||
id: string;
|
||||
status: TInboxIssueStatus;
|
||||
snoozed_till: Date | null;
|
||||
duplicate_to: string | undefined;
|
||||
source: EInboxIssueSource | undefined;
|
||||
issue: TIssue;
|
||||
created_by: string;
|
||||
duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined;
|
||||
};
|
||||
|
||||
export const INBOX_STATUS: {
|
||||
key: string;
|
||||
status: TInboxIssueStatus;
|
||||
i18n_title: string;
|
||||
i18n_description: () => string;
|
||||
key: string;
|
||||
status: TInboxIssueStatus;
|
||||
i18n_title: string;
|
||||
i18n_description: () => string;
|
||||
}[] = [
|
||||
{
|
||||
key: "pending",
|
||||
i18n_title: "inbox_issue.status.pending.title",
|
||||
status: EInboxIssueStatus.PENDING,
|
||||
i18n_description: () => `inbox_issue.status.pending.description`,
|
||||
},
|
||||
{
|
||||
key: "declined",
|
||||
i18n_title: "inbox_issue.status.declined.title",
|
||||
status: EInboxIssueStatus.DECLINED,
|
||||
i18n_description: () => `inbox_issue.status.declined.description`,
|
||||
},
|
||||
{
|
||||
key: "snoozed",
|
||||
i18n_title: "inbox_issue.status.snoozed.title",
|
||||
status: EInboxIssueStatus.SNOOZED,
|
||||
i18n_description: () => `inbox_issue.status.snoozed.description`,
|
||||
},
|
||||
{
|
||||
key: "accepted",
|
||||
i18n_title: "inbox_issue.status.accepted.title",
|
||||
status: EInboxIssueStatus.ACCEPTED,
|
||||
i18n_description: () => `inbox_issue.status.accepted.description`,
|
||||
},
|
||||
{
|
||||
key: "duplicate",
|
||||
i18n_title: "inbox_issue.status.duplicate.title",
|
||||
status: EInboxIssueStatus.DUPLICATE,
|
||||
i18n_description: () => `inbox_issue.status.duplicate.description`,
|
||||
},
|
||||
{
|
||||
key: "pending",
|
||||
i18n_title: "inbox_issue.status.pending.title",
|
||||
status: EInboxIssueStatus.PENDING,
|
||||
i18n_description: () => `inbox_issue.status.pending.description`,
|
||||
},
|
||||
{
|
||||
key: "declined",
|
||||
i18n_title: "inbox_issue.status.declined.title",
|
||||
status: EInboxIssueStatus.DECLINED,
|
||||
i18n_description: () => `inbox_issue.status.declined.description`,
|
||||
},
|
||||
{
|
||||
key: "snoozed",
|
||||
i18n_title: "inbox_issue.status.snoozed.title",
|
||||
status: EInboxIssueStatus.SNOOZED,
|
||||
i18n_description: () => `inbox_issue.status.snoozed.description`,
|
||||
},
|
||||
{
|
||||
key: "accepted",
|
||||
i18n_title: "inbox_issue.status.accepted.title",
|
||||
status: EInboxIssueStatus.ACCEPTED,
|
||||
i18n_description: () => `inbox_issue.status.accepted.description`,
|
||||
},
|
||||
{
|
||||
key: "duplicate",
|
||||
i18n_title: "inbox_issue.status.duplicate.title",
|
||||
status: EInboxIssueStatus.DUPLICATE,
|
||||
i18n_description: () => `inbox_issue.status.duplicate.description`,
|
||||
},
|
||||
];
|
||||
|
||||
export const INBOX_ISSUE_ORDER_BY_OPTIONS = [
|
||||
{
|
||||
key: "issue__created_at",
|
||||
i18n_label: "inbox_issue.order_by.created_at",
|
||||
},
|
||||
{
|
||||
key: "issue__updated_at",
|
||||
i18n_label: "inbox_issue.order_by.updated_at",
|
||||
},
|
||||
{
|
||||
key: "issue__sequence_id",
|
||||
i18n_label: "inbox_issue.order_by.id",
|
||||
},
|
||||
{
|
||||
key: "issue__created_at",
|
||||
i18n_label: "inbox_issue.order_by.created_at",
|
||||
},
|
||||
{
|
||||
key: "issue__updated_at",
|
||||
i18n_label: "inbox_issue.order_by.updated_at",
|
||||
},
|
||||
{
|
||||
key: "issue__sequence_id",
|
||||
i18n_label: "inbox_issue.order_by.id",
|
||||
},
|
||||
];
|
||||
|
||||
export const INBOX_ISSUE_SORT_BY_OPTIONS = [
|
||||
{
|
||||
key: "asc",
|
||||
i18n_label: "common.sort.asc",
|
||||
},
|
||||
{
|
||||
key: "desc",
|
||||
i18n_label: "common.sort.desc",
|
||||
},
|
||||
{
|
||||
key: "asc",
|
||||
i18n_label: "common.sort.asc",
|
||||
},
|
||||
{
|
||||
key: "desc",
|
||||
i18n_label: "common.sort.desc",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -14,6 +14,7 @@ export * from "./state";
|
||||
export * from "./swr";
|
||||
export * from "./tab-indices";
|
||||
export * from "./user";
|
||||
export * from "./payment";
|
||||
export * from "./workspace";
|
||||
export * from "./stickies";
|
||||
export * from "./cycle";
|
||||
@@ -30,3 +31,4 @@ export * from "./spreadsheet";
|
||||
export * from "./dashboard";
|
||||
export * from "./page";
|
||||
export * from "./emoji";
|
||||
export * from "./subscription";
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { IPaymentProduct, TBillingFrequency, TProductBillingFrequency } from "@plane/types";
|
||||
|
||||
/**
|
||||
* Enum representing different product subscription types
|
||||
*/
|
||||
export enum EProductSubscriptionEnum {
|
||||
FREE = "FREE",
|
||||
ONE = "ONE",
|
||||
PRO = "PRO",
|
||||
BUSINESS = "BUSINESS",
|
||||
ENTERPRISE = "ENTERPRISE",
|
||||
}
|
||||
|
||||
/**
|
||||
* Default billing frequency for each product subscription type
|
||||
*/
|
||||
export const DEFAULT_PRODUCT_BILLING_FREQUENCY: TProductBillingFrequency = {
|
||||
[EProductSubscriptionEnum.FREE]: undefined,
|
||||
[EProductSubscriptionEnum.ONE]: undefined,
|
||||
[EProductSubscriptionEnum.PRO]: "month",
|
||||
[EProductSubscriptionEnum.BUSINESS]: "month",
|
||||
[EProductSubscriptionEnum.ENTERPRISE]: "month",
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscription types that support billing frequency toggle (monthly/yearly)
|
||||
*/
|
||||
export const SUBSCRIPTION_WITH_BILLING_FREQUENCY = [
|
||||
EProductSubscriptionEnum.PRO,
|
||||
EProductSubscriptionEnum.BUSINESS,
|
||||
EProductSubscriptionEnum.ENTERPRISE,
|
||||
];
|
||||
|
||||
/**
|
||||
* Mapping of product subscription types to their respective payment product details
|
||||
* Used to provide information about each product's pricing and features
|
||||
*/
|
||||
export const PLANE_COMMUNITY_PRODUCTS: Record<string, IPaymentProduct> = {
|
||||
[EProductSubscriptionEnum.PRO]: {
|
||||
id: EProductSubscriptionEnum.PRO,
|
||||
name: "Plane Pro",
|
||||
description:
|
||||
"More views, more cycles powers, more pages features, new reports, and better dashboards are waiting to be unlocked.",
|
||||
type: "PRO",
|
||||
prices: [
|
||||
{
|
||||
id: `price_monthly_${EProductSubscriptionEnum.PRO}`,
|
||||
unit_amount: 800,
|
||||
recurring: "month",
|
||||
currency: "usd",
|
||||
workspace_amount: 800,
|
||||
product: EProductSubscriptionEnum.PRO,
|
||||
},
|
||||
{
|
||||
id: `price_yearly_${EProductSubscriptionEnum.PRO}`,
|
||||
unit_amount: 7200,
|
||||
recurring: "year",
|
||||
currency: "usd",
|
||||
workspace_amount: 7200,
|
||||
product: EProductSubscriptionEnum.PRO,
|
||||
},
|
||||
],
|
||||
payment_quantity: 1,
|
||||
is_active: true,
|
||||
},
|
||||
[EProductSubscriptionEnum.BUSINESS]: {
|
||||
id: EProductSubscriptionEnum.BUSINESS,
|
||||
name: "Plane Business",
|
||||
description:
|
||||
"The earliest packaging of Business at $10 a seat a month billed annually, $12 a seat a month billed monthly for Plane Cloud",
|
||||
type: "BUSINESS",
|
||||
prices: [
|
||||
{
|
||||
id: `price_yearly_${EProductSubscriptionEnum.BUSINESS}`,
|
||||
unit_amount: 0,
|
||||
recurring: "year",
|
||||
currency: "usd",
|
||||
workspace_amount: 0,
|
||||
product: EProductSubscriptionEnum.BUSINESS,
|
||||
},
|
||||
{
|
||||
id: `price_monthly_${EProductSubscriptionEnum.BUSINESS}`,
|
||||
unit_amount: 0,
|
||||
recurring: "month",
|
||||
currency: "usd",
|
||||
workspace_amount: 0,
|
||||
product: EProductSubscriptionEnum.BUSINESS,
|
||||
},
|
||||
],
|
||||
payment_quantity: 1,
|
||||
is_active: false,
|
||||
},
|
||||
[EProductSubscriptionEnum.ENTERPRISE]: {
|
||||
id: EProductSubscriptionEnum.ENTERPRISE,
|
||||
name: "Plane Enterprise",
|
||||
description: "",
|
||||
type: "ENTERPRISE",
|
||||
prices: [
|
||||
{
|
||||
id: `price_yearly_${EProductSubscriptionEnum.ENTERPRISE}`,
|
||||
unit_amount: 0,
|
||||
recurring: "year",
|
||||
currency: "usd",
|
||||
workspace_amount: 0,
|
||||
product: EProductSubscriptionEnum.ENTERPRISE,
|
||||
},
|
||||
{
|
||||
id: `price_monthly_${EProductSubscriptionEnum.ENTERPRISE}`,
|
||||
unit_amount: 0,
|
||||
recurring: "month",
|
||||
currency: "usd",
|
||||
workspace_amount: 0,
|
||||
product: EProductSubscriptionEnum.ENTERPRISE,
|
||||
},
|
||||
],
|
||||
payment_quantity: 1,
|
||||
is_active: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* URL for the "Talk to Sales" page where users can contact sales team
|
||||
*/
|
||||
export const TALK_TO_SALES_URL = "https://plane.so/talk-to-sales";
|
||||
|
||||
/**
|
||||
* Mapping of subscription types to their respective upgrade/redirection URLs based on billing frequency
|
||||
* Used for self-hosted installations to redirect users to appropriate upgrade pages
|
||||
*/
|
||||
export const SUBSCRIPTION_REDIRECTION_URLS: Record<EProductSubscriptionEnum, Record<TBillingFrequency, string>> = {
|
||||
[EProductSubscriptionEnum.FREE]: {
|
||||
month: TALK_TO_SALES_URL,
|
||||
year: TALK_TO_SALES_URL,
|
||||
},
|
||||
[EProductSubscriptionEnum.ONE]: {
|
||||
month: TALK_TO_SALES_URL,
|
||||
year: TALK_TO_SALES_URL,
|
||||
},
|
||||
[EProductSubscriptionEnum.PRO]: {
|
||||
month: "https://app.plane.so/upgrade/pro/self-hosted?plan=month",
|
||||
year: "https://app.plane.so/upgrade/pro/self-hosted?plan=year",
|
||||
},
|
||||
[EProductSubscriptionEnum.BUSINESS]: {
|
||||
month: TALK_TO_SALES_URL,
|
||||
year: TALK_TO_SALES_URL,
|
||||
},
|
||||
[EProductSubscriptionEnum.ENTERPRISE]: {
|
||||
month: TALK_TO_SALES_URL,
|
||||
year: TALK_TO_SALES_URL,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping of subscription types to their respective marketing webpage URLs
|
||||
* Used to direct users to learn more about each plan's features and pricing
|
||||
*/
|
||||
export const SUBSCRIPTION_WEBPAGE_URLS: Record<EProductSubscriptionEnum, string> = {
|
||||
[EProductSubscriptionEnum.FREE]: TALK_TO_SALES_URL,
|
||||
[EProductSubscriptionEnum.ONE]: TALK_TO_SALES_URL,
|
||||
[EProductSubscriptionEnum.PRO]: "https://plane.so/pro",
|
||||
[EProductSubscriptionEnum.BUSINESS]: "https://plane.so/business",
|
||||
[EProductSubscriptionEnum.ENTERPRISE]: "https://plane.so/business",
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
export const ENTERPRISE_PLAN_FEATURES = [
|
||||
"Private + managed deployments",
|
||||
"GAC",
|
||||
"LDAP support",
|
||||
"Databases + Formulas",
|
||||
"Unlimited and full Automation Flows",
|
||||
"Full-suite professional services",
|
||||
];
|
||||
|
||||
export const BUSINESS_PLAN_FEATURES = [
|
||||
"Project Templates",
|
||||
"Workflows + Approvals",
|
||||
"Decision + Loops Automation",
|
||||
"Custom Reports",
|
||||
"Nested Pages",
|
||||
"Intake Forms",
|
||||
];
|
||||
|
||||
export const PRO_PLAN_FEATURES = [
|
||||
"Dashboards + Reports",
|
||||
"Full Time Tracking + Bulk Ops",
|
||||
"Teamspaces",
|
||||
"Trigger And Action",
|
||||
"Wikis",
|
||||
"Popular integrations",
|
||||
];
|
||||
|
||||
export const ONE_PLAN_FEATURES = [
|
||||
"OIDC + SAML for SSO",
|
||||
"Active Cycles",
|
||||
"Real-time collab + public views and page",
|
||||
"Link pages in issues and vice-versa",
|
||||
"Time-tracking + limited bulk ops",
|
||||
"Docker, Kubernetes and more",
|
||||
];
|
||||
|
||||
export const FREE_PLAN_UPGRADE_FEATURES = [
|
||||
"OIDC + SAML for SSO",
|
||||
"Time Tracking and Bulk Ops",
|
||||
"Integrations",
|
||||
"Public Views and Pages",
|
||||
];
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custo
|
||||
import { TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
const { getAssetSrc } = props;
|
||||
const { getAssetSrc, restore: restoreImageFn } = props;
|
||||
|
||||
return Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
@@ -66,6 +66,9 @@ export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => async () => await getAssetSrc(path),
|
||||
restoreImage: (src) => async () => {
|
||||
await restoreImageFn(src);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -151,11 +151,11 @@
|
||||
/* end font size and style */
|
||||
|
||||
/* layout config */
|
||||
#page-header-container {
|
||||
container-name: page-header-container;
|
||||
#page-toolbar-container {
|
||||
container-name: page-toolbar-container;
|
||||
container-type: inline-size;
|
||||
|
||||
.page-header-content {
|
||||
.page-toolbar-content {
|
||||
--header-width: var(--normal-content-width);
|
||||
|
||||
&.wide-layout {
|
||||
@@ -186,23 +186,23 @@
|
||||
}
|
||||
|
||||
/* keep a static padding of 96px for wide layouts for container width >912px and <1344px */
|
||||
@container page-header-container (min-width: 912px) and (max-width: 1344px) {
|
||||
.page-header-content.wide-layout {
|
||||
@container page-toolbar-container (min-width: 912px) and (max-width: 1344px) {
|
||||
.page-toolbar-content.wide-layout {
|
||||
padding-left: var(--wide-content-margin) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* keep a static padding of 96px for wide layouts for container width <912px */
|
||||
@container page-header-container (max-width: 912) {
|
||||
.page-header-content.wide-layout {
|
||||
@container page-toolbar-container (max-width: 912) {
|
||||
.page-toolbar-content.wide-layout {
|
||||
padding-left: var(--wide-content-margin) !important;
|
||||
}
|
||||
}
|
||||
/* end layout config */
|
||||
|
||||
/* keep a static padding of 20px for wide layouts for container width <760px */
|
||||
@container page-header-container (max-width: 760px) {
|
||||
.page-header-content {
|
||||
@container page-toolbar-container (max-width: 760px) {
|
||||
.page-toolbar-content {
|
||||
padding-left: var(--normal-content-margin) !important;
|
||||
}
|
||||
}
|
||||
@@ -211,7 +211,7 @@
|
||||
/* keep a static padding of 96px for wide layouts for container width >912px and <1344px */
|
||||
@container page-content-container (min-width: 912px) and (max-width: 1344px) {
|
||||
.editor-container.wide-layout,
|
||||
.page-title-container {
|
||||
.page-header-container {
|
||||
padding-left: var(--wide-content-margin);
|
||||
padding-right: var(--wide-content-margin);
|
||||
}
|
||||
@@ -220,7 +220,7 @@
|
||||
/* keep a static padding of 20px for wide layouts for container width <912px */
|
||||
@container page-content-container (max-width: 912px) {
|
||||
.editor-container.wide-layout,
|
||||
.page-title-container {
|
||||
.page-header-container {
|
||||
padding-left: var(--normal-content-margin);
|
||||
padding-right: var(--normal-content-margin);
|
||||
}
|
||||
@@ -229,7 +229,7 @@
|
||||
/* keep a static padding of 20px for normal layouts for container width <760px */
|
||||
@container page-content-container (max-width: 760px) {
|
||||
.editor-container:not(.wide-layout),
|
||||
.page-title-container {
|
||||
.page-header-container {
|
||||
padding-left: var(--normal-content-margin);
|
||||
padding-right: var(--normal-content-margin);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@plane/eslint-config",
|
||||
"private": true,
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"license": "AGPL-3.0",
|
||||
"files": [
|
||||
"library.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/hooks",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "React hooks that are shared across multiple apps internally",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/i18n",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "I18n shared across multiple apps internally",
|
||||
"private": true,
|
||||
|
||||
@@ -21,6 +21,7 @@ export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
|
||||
{ label: "Indonesian", value: "id" },
|
||||
{ label: "Română", value: "ro" },
|
||||
{ label: "Tiếng việt", value: "vi-VN" },
|
||||
{ label: "Türkçe", value: "tr-TR" },
|
||||
];
|
||||
|
||||
export const LANGUAGE_STORAGE_KEY = "userLanguage";
|
||||
|
||||
@@ -348,7 +348,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "Nepodařilo se odstranit projekt z oblíbených. Zkuste to prosím znovu.",
|
||||
"add_to_favorites": "Přidat do oblíbených",
|
||||
"remove_from_favorites": "Odebrat z oblíbených",
|
||||
"publish_settings": "Nastavení publikování",
|
||||
"publish_project": "Publikovat projekt",
|
||||
"publish": "Publikovat",
|
||||
"copy_link": "Kopírovat odkaz",
|
||||
"leave_project": "Opustit projekt",
|
||||
@@ -867,7 +867,8 @@
|
||||
"deleting": "Mazání",
|
||||
"pending": "Čekající",
|
||||
"invite": "Pozvat",
|
||||
"view": "Pohled"
|
||||
"view": "Pohled",
|
||||
"deactivated_user": "Deaktivovaný uživatel"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1738,12 +1739,15 @@
|
||||
"title": "Povolit odhady pro můj projekt",
|
||||
"description": "Pomáhají vám komunikovat složitost a pracovní zátěž týmu.",
|
||||
"no_estimate": "Bez odhadu",
|
||||
"new": "Nový systém odhadů",
|
||||
"create": {
|
||||
"custom": "Vlastní",
|
||||
"start_from_scratch": "Začít od nuly",
|
||||
"choose_template": "Vybrat šablonu",
|
||||
"choose_estimate_system": "Vybrat systém odhadů",
|
||||
"enter_estimate_point": "Zadat odhad"
|
||||
"enter_estimate_point": "Zadat odhad",
|
||||
"step": "Krok {step} z {total}",
|
||||
"label": "Vytvořit odhad"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1792,6 +1796,25 @@
|
||||
"already_exists": "Hodnota odhadu již existuje.",
|
||||
"unsaved_changes": "Máte neuložené změny. Před kliknutím na hotovo je prosím uložte",
|
||||
"remove_empty": "Odhad nemůže být prázdný. Zadejte hodnotu do každého pole nebo odstraňte ta, pro která nemáte hodnoty."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Body",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Lineární",
|
||||
"squares": "Čtverce",
|
||||
"custom": "Vlastní"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Kategorie",
|
||||
"t_shirt_sizes": "Velikosti triček",
|
||||
"easy_to_hard": "Od snadného po těžké",
|
||||
"custom": "Vlastní"
|
||||
},
|
||||
"time": {
|
||||
"label": "Čas",
|
||||
"hours": "Hodiny"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -348,7 +348,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "Projekt konnte nicht aus den Favoriten entfernt werden. Bitte versuchen Sie es erneut.",
|
||||
"add_to_favorites": "Zu Favoriten hinzufügen",
|
||||
"remove_from_favorites": "Aus Favoriten entfernen",
|
||||
"publish_settings": "Veröffentlichungseinstellungen",
|
||||
"publish_project": "Projekt veröffentlichen",
|
||||
"publish": "Veröffentlichen",
|
||||
"copy_link": "Link kopieren",
|
||||
"leave_project": "Projekt verlassen",
|
||||
@@ -862,7 +862,8 @@
|
||||
"deleting": "Wird gelöscht",
|
||||
"pending": "Ausstehend",
|
||||
"invite": "Einladen",
|
||||
"view": "Ansicht"
|
||||
"view": "Ansicht",
|
||||
"deactivated_user": "Deaktivierter Benutzer"
|
||||
},
|
||||
"chart": {
|
||||
"x_axis": "X-Achse",
|
||||
@@ -1714,12 +1715,15 @@
|
||||
"title": "Schätzungen für mein Projekt aktivieren",
|
||||
"description": "Sie helfen dir, die Komplexität und Arbeitsbelastung des Teams zu kommunizieren.",
|
||||
"no_estimate": "Keine Schätzung",
|
||||
"new": "Neues Schätzungssystem",
|
||||
"create": {
|
||||
"custom": "Benutzerdefiniert",
|
||||
"start_from_scratch": "Von Grund auf neu",
|
||||
"choose_template": "Vorlage wählen",
|
||||
"choose_estimate_system": "Schätzungssystem wählen",
|
||||
"enter_estimate_point": "Schätzung eingeben"
|
||||
"enter_estimate_point": "Schätzung eingeben",
|
||||
"step": "Schritt {step} von {total}",
|
||||
"label": "Schätzung erstellen"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1768,6 +1772,25 @@
|
||||
"already_exists": "Der Schätzungswert existiert bereits.",
|
||||
"unsaved_changes": "Du hast ungespeicherte Änderungen. Bitte speichere sie, bevor du auf Fertig klickst",
|
||||
"remove_empty": "Die Schätzung darf nicht leer sein. Gib einen Wert in jedes Feld ein oder entferne die Felder, für die du keine Werte hast."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Punkte",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Linear",
|
||||
"squares": "Quadrate",
|
||||
"custom": "Benutzerdefiniert"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Kategorien",
|
||||
"t_shirt_sizes": "T-Shirt-Größen",
|
||||
"easy_to_hard": "Einfach bis schwer",
|
||||
"custom": "Benutzerdefiniert"
|
||||
},
|
||||
"time": {
|
||||
"label": "Zeit",
|
||||
"hours": "Stunden"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "Couldn't remove the project from favorites. Please try again.",
|
||||
"add_to_favorites": "Add to favorites",
|
||||
"remove_from_favorites": "Remove from favorites",
|
||||
"publish_settings": "Publish settings",
|
||||
"publish_project": "Publish project",
|
||||
"publish": "Publish",
|
||||
"copy_link": "Copy link",
|
||||
"leave_project": "Leave project",
|
||||
@@ -702,7 +702,8 @@
|
||||
"deleting": "Deleting",
|
||||
"pending": "Pending",
|
||||
"invite": "Invite",
|
||||
"view": "View"
|
||||
"view": "View",
|
||||
"deactivated_user": "Deactivated user"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1573,12 +1574,15 @@
|
||||
"title": "Enable estimates for my project",
|
||||
"description": "They help you in communicating complexity and workload of the team.",
|
||||
"no_estimate": "No estimate",
|
||||
"new": "New estimate system",
|
||||
"create": {
|
||||
"custom": "Custom",
|
||||
"start_from_scratch": "Start from scratch",
|
||||
"choose_template": "Choose a template",
|
||||
"choose_estimate_system": "Choose an estimate system",
|
||||
"enter_estimate_point": "Enter estimate"
|
||||
"enter_estimate_point": "Enter estimate",
|
||||
"step": "Step {step} of {total}",
|
||||
"label": "Create estimate"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1627,6 +1631,25 @@
|
||||
"already_exists": "Estimate value already exists.",
|
||||
"unsaved_changes": "You have some unsaved changes, Please save them before clicking on done",
|
||||
"remove_empty": "Estimate can't be empty. Enter a value in each field or remove those you don't have values for."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Points",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Linear",
|
||||
"squares": "Squares",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Categories",
|
||||
"t_shirt_sizes": "T-Shirt Sizes",
|
||||
"easy_to_hard": "Easy to hard",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"time": {
|
||||
"label": "Time",
|
||||
"hours": "Hours"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -352,7 +352,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "No se pudo eliminar el proyecto de favoritos. Por favor, inténtalo de nuevo.",
|
||||
"add_to_favorites": "Agregar a favoritos",
|
||||
"remove_from_favorites": "Eliminar de favoritos",
|
||||
"publish_settings": "Configuración de publicación",
|
||||
"publish_project": "Publicar proyecto",
|
||||
"publish": "Publicar",
|
||||
"copy_link": "Copiar enlace",
|
||||
"leave_project": "Abandonar proyecto",
|
||||
@@ -872,7 +872,8 @@
|
||||
"deleting": "Eliminando",
|
||||
"pending": "Pendiente",
|
||||
"invite": "Invitar",
|
||||
"view": "Ver"
|
||||
"view": "Ver",
|
||||
"deactivated_user": "Usuario desactivado"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1742,12 +1743,15 @@
|
||||
"title": "Activar estimaciones para mi proyecto",
|
||||
"description": "Te ayudan a comunicar la complejidad y la carga de trabajo del equipo.",
|
||||
"no_estimate": "Sin estimación",
|
||||
"new": "Nuevo sistema de estimación",
|
||||
"create": {
|
||||
"custom": "Personalizado",
|
||||
"start_from_scratch": "Comenzar desde cero",
|
||||
"choose_template": "Elegir una plantilla",
|
||||
"choose_estimate_system": "Elegir un sistema de estimación",
|
||||
"enter_estimate_point": "Ingresar estimación"
|
||||
"enter_estimate_point": "Ingresar estimación",
|
||||
"step": "Paso {step} de {total}",
|
||||
"label": "Crear estimación"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1796,6 +1800,25 @@
|
||||
"already_exists": "El valor de la estimación ya existe.",
|
||||
"unsaved_changes": "Tienes cambios sin guardar. Por favor guárdalos antes de hacer clic en Hecho",
|
||||
"remove_empty": "La estimación no puede estar vacía. Ingresa un valor en cada campo o elimina aquellos para los que no tienes valores."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Puntos",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Lineal",
|
||||
"squares": "Cuadrados",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Categorías",
|
||||
"t_shirt_sizes": "Tallas de camiseta",
|
||||
"easy_to_hard": "Fácil a difícil",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tiempo",
|
||||
"hours": "Horas"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "Impossible de supprimer le projet des favoris. Veuillez réessayer.",
|
||||
"add_to_favorites": "Ajouter aux favoris",
|
||||
"remove_from_favorites": "Supprimer des favoris",
|
||||
"publish_settings": "Paramètres de publication",
|
||||
"publish_project": "Publier le projet",
|
||||
"publish": "Publier",
|
||||
"copy_link": "Copier le lien",
|
||||
"leave_project": "Quitter le projet",
|
||||
@@ -870,7 +870,8 @@
|
||||
"deleting": "Suppression",
|
||||
"pending": "En attente",
|
||||
"invite": "Inviter",
|
||||
"view": "Afficher"
|
||||
"view": "Afficher",
|
||||
"deactivated_user": "Utilisateur désactivé"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1740,12 +1741,15 @@
|
||||
"title": "Activer les estimations pour mon projet",
|
||||
"description": "Elles vous aident à communiquer la complexité et la charge de travail de l'équipe.",
|
||||
"no_estimate": "Sans estimation",
|
||||
"new": "Nouveau système d'estimation",
|
||||
"create": {
|
||||
"custom": "Personnalisé",
|
||||
"start_from_scratch": "Commencer depuis zéro",
|
||||
"choose_template": "Choisir un modèle",
|
||||
"choose_estimate_system": "Choisir un système d'estimation",
|
||||
"enter_estimate_point": "Saisir une estimation"
|
||||
"enter_estimate_point": "Saisir une estimation",
|
||||
"step": "Étape {step} de {total}",
|
||||
"label": "Créer une estimation"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1794,6 +1798,25 @@
|
||||
"already_exists": "La valeur de l'estimation existe déjà.",
|
||||
"unsaved_changes": "Vous avez des modifications non enregistrées. Veuillez les enregistrer avant de cliquer sur Terminé",
|
||||
"remove_empty": "L'estimation ne peut pas être vide. Saisissez une valeur dans chaque champ ou supprimez ceux pour lesquels vous n'avez pas de valeurs."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Points",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Linéaire",
|
||||
"squares": "Carrés",
|
||||
"custom": "Personnalisé"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Catégories",
|
||||
"t_shirt_sizes": "Tailles de T-Shirt",
|
||||
"easy_to_hard": "Facile à difficile",
|
||||
"custom": "Personnalisé"
|
||||
},
|
||||
"time": {
|
||||
"label": "Temps",
|
||||
"hours": "Heures"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "Tidak dapat menghapus proyek dari favorit. Silakan coba lagi.",
|
||||
"add_to_favorites": "Tambah ke favorit",
|
||||
"remove_from_favorites": "Hapus dari favorit",
|
||||
"publish_settings": "Pengaturan publikasi",
|
||||
"publish_project": "Publikasikan proyek",
|
||||
"publish": "Publikasikan",
|
||||
"copy_link": "Salin tautan",
|
||||
"leave_project": "Tinggalkan proyek",
|
||||
@@ -869,7 +869,8 @@
|
||||
"deleting": "Menghapus",
|
||||
"pending": "Tertunda",
|
||||
"invite": "Undang",
|
||||
"view": "Lihat"
|
||||
"view": "Lihat",
|
||||
"deactivated_user": "Pengguna dinonaktifkan"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1740,12 +1741,15 @@
|
||||
"title": "Aktifkan perkiraan untuk proyek saya",
|
||||
"description": "Ini membantu Anda dalam mengkomunikasikan kompleksitas dan beban kerja tim.",
|
||||
"no_estimate": "Tidak ada perkiraan",
|
||||
"new": "Sistem perkiraan baru",
|
||||
"create": {
|
||||
"custom": "Kustom",
|
||||
"start_from_scratch": "Mulai dari awal",
|
||||
"choose_template": "Pilih template",
|
||||
"choose_estimate_system": "Pilih sistem perkiraan",
|
||||
"enter_estimate_point": "Masukkan perkiraan"
|
||||
"enter_estimate_point": "Masukkan perkiraan",
|
||||
"step": "Langkah {step} dari {total}",
|
||||
"label": "Buat perkiraan"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1794,6 +1798,25 @@
|
||||
"already_exists": "Nilai perkiraan sudah ada.",
|
||||
"unsaved_changes": "Anda memiliki beberapa perubahan yang belum disimpan, Harap simpan sebelum mengklik selesai",
|
||||
"remove_empty": "Perkiraan tidak boleh kosong. Masukkan nilai di setiap bidang atau hapus yang tidak memiliki nilai."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Poin",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Linear",
|
||||
"squares": "Kuadrat",
|
||||
"custom": "Kustom"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Kategori",
|
||||
"t_shirt_sizes": "Ukuran Baju",
|
||||
"easy_to_hard": "Mudah ke sulit",
|
||||
"custom": "Kustom"
|
||||
},
|
||||
"time": {
|
||||
"label": "Waktu",
|
||||
"hours": "Jam"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -349,7 +349,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "Impossibile rimuovere il progetto dai preferiti. Per favore, riprova.",
|
||||
"add_to_favorites": "Aggiungi ai preferiti",
|
||||
"remove_from_favorites": "Rimuovi dai preferiti",
|
||||
"publish_settings": "Impostazioni di pubblicazione",
|
||||
"publish_project": "Pubblica progetto",
|
||||
"publish": "Pubblica",
|
||||
"copy_link": "Copia link",
|
||||
"leave_project": "Lascia progetto",
|
||||
@@ -868,7 +868,8 @@
|
||||
"deleting": "Eliminazione in corso",
|
||||
"pending": "In sospeso",
|
||||
"invite": "Invita",
|
||||
"view": "Visualizza"
|
||||
"view": "Visualizza",
|
||||
"deactivated_user": "Utente disattivato"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1739,12 +1740,15 @@
|
||||
"title": "Abilita le stime per il mio progetto",
|
||||
"description": "Ti aiutano a comunicare la complessità e il carico di lavoro del team.",
|
||||
"no_estimate": "Nessuna stima",
|
||||
"new": "Nuovo sistema di stima",
|
||||
"create": {
|
||||
"custom": "Personalizzato",
|
||||
"start_from_scratch": "Inizia da zero",
|
||||
"choose_template": "Scegli un modello",
|
||||
"choose_estimate_system": "Scegli un sistema di stima",
|
||||
"enter_estimate_point": "Inserisci stima"
|
||||
"enter_estimate_point": "Inserisci stima",
|
||||
"step": "Passo {step} di {total}",
|
||||
"label": "Crea stima"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1793,6 +1797,25 @@
|
||||
"already_exists": "Il valore della stima esiste già.",
|
||||
"unsaved_changes": "Hai delle modifiche non salvate. Salva prima di cliccare su Fatto",
|
||||
"remove_empty": "La stima non può essere vuota. Inserisci un valore in ogni campo o rimuovi quelli per cui non hai valori."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Punti",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Lineare",
|
||||
"squares": "Quadrati",
|
||||
"custom": "Personalizzato"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Categorie",
|
||||
"t_shirt_sizes": "Taglie T-Shirt",
|
||||
"easy_to_hard": "Da facile a difficile",
|
||||
"custom": "Personalizzato"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tempo",
|
||||
"hours": "Ore"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "プロジェクトをお気に入りから削除できませんでした。もう一度お試しください。",
|
||||
"add_to_favorites": "お気に入りに追加",
|
||||
"remove_from_favorites": "お気に入りから削除",
|
||||
"publish_settings": "公開設定",
|
||||
"publish_project": "プロジェクトを公開",
|
||||
"publish": "公開",
|
||||
"copy_link": "リンクをコピー",
|
||||
"leave_project": "プロジェクトを退出",
|
||||
@@ -870,7 +870,8 @@
|
||||
"deleting": "デリーティング",
|
||||
"pending": "保留中",
|
||||
"invite": "招待",
|
||||
"view": "ビュー"
|
||||
"view": "ビュー",
|
||||
"deactivated_user": "無効化されたユーザー"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1740,12 +1741,15 @@
|
||||
"title": "プロジェクトの見積もりを有効にする",
|
||||
"description": "チームの複雑さと作業負荷を伝えるのに役立ちます。",
|
||||
"no_estimate": "見積もりなし",
|
||||
"new": "新しい見積もりシステム",
|
||||
"create": {
|
||||
"custom": "カスタム",
|
||||
"start_from_scratch": "最初から開始",
|
||||
"choose_template": "テンプレートを選択",
|
||||
"choose_estimate_system": "見積もりシステムを選択",
|
||||
"enter_estimate_point": "見積もりを入力"
|
||||
"enter_estimate_point": "見積もりを入力",
|
||||
"step": "ステップ {step} の {total}",
|
||||
"label": "見積もりを作成"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1794,6 +1798,25 @@
|
||||
"already_exists": "見積もり値は既に存在します。",
|
||||
"unsaved_changes": "未保存の変更があります。完了をクリックする前に保存してください",
|
||||
"remove_empty": "見積もりは空にできません。各フィールドに値を入力するか、値がないフィールドを削除してください。"
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "ポイント",
|
||||
"fibonacci": "フィボナッチ",
|
||||
"linear": "リニア",
|
||||
"squares": "二乗",
|
||||
"custom": "カスタム"
|
||||
},
|
||||
"categories": {
|
||||
"label": "カテゴリー",
|
||||
"t_shirt_sizes": "Tシャツサイズ",
|
||||
"easy_to_hard": "簡単から難しい",
|
||||
"custom": "カスタム"
|
||||
},
|
||||
"time": {
|
||||
"label": "時間",
|
||||
"hours": "時間"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "프로젝트를 즐겨찾기에서 제거하지 못했습니다. 다시 시도해주세요.",
|
||||
"add_to_favorites": "즐겨찾기에 추가",
|
||||
"remove_from_favorites": "즐겨찾기에서 제거",
|
||||
"publish_settings": "설정 게시",
|
||||
"publish_project": "프로젝트 게시",
|
||||
"publish": "게시",
|
||||
"copy_link": "링크 복사",
|
||||
"leave_project": "프로젝트 떠나기",
|
||||
@@ -871,7 +871,8 @@
|
||||
"deleting": "삭제 중",
|
||||
"pending": "보류 중",
|
||||
"invite": "초대",
|
||||
"view": "보기"
|
||||
"view": "보기",
|
||||
"deactivated_user": "비활성화된 사용자"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1742,12 +1743,15 @@
|
||||
"title": "프로젝트 추정 활성화",
|
||||
"description": "팀의 복잡성과 작업량을 전달하는 데 도움이 됩니다.",
|
||||
"no_estimate": "추정 없음",
|
||||
"new": "새 추정 시스템",
|
||||
"create": {
|
||||
"custom": "사용자 지정",
|
||||
"start_from_scratch": "처음부터 시작",
|
||||
"choose_template": "템플릿 선택",
|
||||
"choose_estimate_system": "추정 시스템 선택",
|
||||
"enter_estimate_point": "추정 입력"
|
||||
"enter_estimate_point": "추정 입력",
|
||||
"step": "단계 {step}/{total}",
|
||||
"label": "추정 생성"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1796,6 +1800,25 @@
|
||||
"already_exists": "추정 값이 이미 존재합니다.",
|
||||
"unsaved_changes": "저장되지 않은 변경 사항이 있습니다. 완료를 클릭하기 전에 저장하세요",
|
||||
"remove_empty": "추정은 비어있을 수 없습니다. 각 필드에 값을 입력하거나 값이 없는 필드를 제거하세요."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "포인트",
|
||||
"fibonacci": "피보나치",
|
||||
"linear": "선형",
|
||||
"squares": "제곱",
|
||||
"custom": "사용자 정의"
|
||||
},
|
||||
"categories": {
|
||||
"label": "카테고리",
|
||||
"t_shirt_sizes": "티셔츠 사이즈",
|
||||
"easy_to_hard": "쉬움에서 어려움",
|
||||
"custom": "사용자 정의"
|
||||
},
|
||||
"time": {
|
||||
"label": "시간",
|
||||
"hours": "시간"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -348,7 +348,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "Nie udało się usunąć projektu z ulubionych. Spróbuj ponownie.",
|
||||
"add_to_favorites": "Dodaj do ulubionych",
|
||||
"remove_from_favorites": "Usuń z ulubionych",
|
||||
"publish_settings": "Ustawienia publikowania",
|
||||
"publish_project": "Opublikuj projekt",
|
||||
"publish": "Opublikuj",
|
||||
"copy_link": "Kopiuj link",
|
||||
"leave_project": "Opuść projekt",
|
||||
@@ -865,7 +865,8 @@
|
||||
"deleting": "Usuwanie",
|
||||
"pending": "Oczekujące",
|
||||
"invite": "Zaproś",
|
||||
"view": "Widok"
|
||||
"view": "Widok",
|
||||
"deactivated_user": "Dezaktywowany użytkownik"
|
||||
},
|
||||
"chart": {
|
||||
"x_axis": "Oś X",
|
||||
@@ -1717,12 +1718,15 @@
|
||||
"title": "Włącz szacunki dla mojego projektu",
|
||||
"description": "Pomagają w komunikacji o złożoności i obciążeniu zespołu.",
|
||||
"no_estimate": "Bez szacunku",
|
||||
"new": "Nowy system szacowania",
|
||||
"create": {
|
||||
"custom": "Niestandardowy",
|
||||
"start_from_scratch": "Zacznij od zera",
|
||||
"choose_template": "Wybierz szablon",
|
||||
"choose_estimate_system": "Wybierz system szacowania",
|
||||
"enter_estimate_point": "Wprowadź punkt szacunkowy"
|
||||
"enter_estimate_point": "Wprowadź punkt szacunkowy",
|
||||
"step": "Krok {step} z {total}",
|
||||
"label": "Utwórz szacunek"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1771,6 +1775,25 @@
|
||||
"already_exists": "Wartość szacunku już istnieje.",
|
||||
"unsaved_changes": "Masz niezapisane zmiany. Zapisz je przed kliknięciem 'gotowe'",
|
||||
"remove_empty": "Szacunek nie może być pusty. Wprowadź wartość w każde pole lub usuń te, dla których nie masz wartości."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Punkty",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Liniowy",
|
||||
"squares": "Kwadraty",
|
||||
"custom": "Własny"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Kategorie",
|
||||
"t_shirt_sizes": "Rozmiary koszulek",
|
||||
"easy_to_hard": "Od łatwego do trudnego",
|
||||
"custom": "Własne"
|
||||
},
|
||||
"time": {
|
||||
"label": "Czas",
|
||||
"hours": "Godziny"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "Não foi possível remover o projeto dos favoritos. Por favor, tente novamente.",
|
||||
"add_to_favorites": "Adicionar aos favoritos",
|
||||
"remove_from_favorites": "Remover dos favoritos",
|
||||
"publish_settings": "Configurações de publicação",
|
||||
"publish_project": "Publicar projeto",
|
||||
"publish": "Publicar",
|
||||
"copy_link": "Copiar link",
|
||||
"leave_project": "Sair do projeto",
|
||||
@@ -871,7 +871,8 @@
|
||||
"deleting": "Excluindo",
|
||||
"pending": "Pendente",
|
||||
"invite": "Convidar",
|
||||
"view": "Visualizar"
|
||||
"view": "Visualizar",
|
||||
"deactivated_user": "Usuário desativado"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1742,12 +1743,15 @@
|
||||
"title": "Habilitar estimativas para meu projeto",
|
||||
"description": "Elas ajudam você a comunicar a complexidade e a carga de trabalho da equipe.",
|
||||
"no_estimate": "Sem estimativa",
|
||||
"new": "Novo sistema de estimativa",
|
||||
"create": {
|
||||
"custom": "Personalizado",
|
||||
"start_from_scratch": "Começar do zero",
|
||||
"choose_template": "Escolher um modelo",
|
||||
"choose_estimate_system": "Escolher um sistema de estimativa",
|
||||
"enter_estimate_point": "Inserir estimativa"
|
||||
"enter_estimate_point": "Inserir estimativa",
|
||||
"step": "Passo {step} de {total}",
|
||||
"label": "Criar estimativa"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1796,6 +1800,25 @@
|
||||
"already_exists": "O valor da estimativa já existe.",
|
||||
"unsaved_changes": "Você tem algumas alterações não salvas. Por favor, salve-as antes de clicar em concluir",
|
||||
"remove_empty": "A estimativa não pode estar vazia. Insira um valor em cada campo ou remova aqueles para os quais você não tem valores."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Pontos",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Linear",
|
||||
"squares": "Quadrados",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Categorias",
|
||||
"t_shirt_sizes": "Tamanhos de Camiseta",
|
||||
"easy_to_hard": "Fácil a difícil",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"time": {
|
||||
"label": "Tempo",
|
||||
"hours": "Horas"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "Nu s-a putut elimina proiectul din favorite. Încearcă din nou.",
|
||||
"add_to_favorites": "Adaugă la favorite",
|
||||
"remove_from_favorites": "Elimină din favorite",
|
||||
"publish_settings": "Setări de publicare",
|
||||
"publish_project": "Publică proiectul",
|
||||
"publish": "Publică",
|
||||
"copy_link": "Copiază link-ul",
|
||||
"leave_project": "Părăsește proiectul",
|
||||
@@ -869,7 +869,8 @@
|
||||
"deleting": "Se șterge",
|
||||
"pending": "În așteptare",
|
||||
"invite": "Invită",
|
||||
"view": "Vizualizează"
|
||||
"view": "Vizualizează",
|
||||
"deactivated_user": "Utilizator dezactivat"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1740,12 +1741,15 @@
|
||||
"title": "Activează estimările pentru proiectul meu",
|
||||
"description": "Te ajută să comunici complexitatea și volumul de muncă al echipei.",
|
||||
"no_estimate": "Fără estimare",
|
||||
"new": "Noul sistem de estimare",
|
||||
"create": {
|
||||
"custom": "Personalizat",
|
||||
"start_from_scratch": "Începe de la zero",
|
||||
"choose_template": "Alege un șablon",
|
||||
"choose_estimate_system": "Alege un sistem de estimare",
|
||||
"enter_estimate_point": "Introdu estimarea"
|
||||
"enter_estimate_point": "Introdu estimarea",
|
||||
"step": "Pasul {step} de {total}",
|
||||
"label": "Creează estimare"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1794,6 +1798,25 @@
|
||||
"already_exists": "Valoarea estimării există deja.",
|
||||
"unsaved_changes": "Ai modificări nesalvate, te rugăm să le salvezi înainte de a finaliza",
|
||||
"remove_empty": "Estimarea nu poate fi goală. Introdu o valoare în fiecare câmp sau elimină câmpurile pentru care nu ai valori."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Puncte",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Linear",
|
||||
"squares": "Pătrate",
|
||||
"custom": "Personalizat"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Categorii",
|
||||
"t_shirt_sizes": "Mărimi tricou",
|
||||
"easy_to_hard": "De la ușor la greu",
|
||||
"custom": "Personalizat"
|
||||
},
|
||||
"time": {
|
||||
"label": "Timp",
|
||||
"hours": "Ore"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -348,7 +348,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "Не удалось удалить проект из избранного. Попробуйте снова.",
|
||||
"add_to_favorites": "Добавить в избранное",
|
||||
"remove_from_favorites": "Удалить из избранного",
|
||||
"publish_settings": "Настройки публикации",
|
||||
"publish_project": "Опубликовать проект",
|
||||
"publish": "Опубликовать",
|
||||
"copy_link": "Копировать ссылку",
|
||||
"leave_project": "Покинуть проект",
|
||||
@@ -869,7 +869,8 @@
|
||||
"deleting": "Удаление",
|
||||
"pending": "Ожидание",
|
||||
"invite": "Пригласить",
|
||||
"view": "Просмотр"
|
||||
"view": "Просмотр",
|
||||
"deactivated_user": "Деактивированный пользователь"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1740,12 +1741,15 @@
|
||||
"title": "Включить оценки для моего проекта",
|
||||
"description": "Они помогают вам в общении о сложности и рабочей нагрузке команды.",
|
||||
"no_estimate": "Без оценки",
|
||||
"new": "Новая система оценок",
|
||||
"create": {
|
||||
"custom": "Пользовательская",
|
||||
"start_from_scratch": "Начать с нуля",
|
||||
"choose_template": "Выбрать шаблон",
|
||||
"choose_estimate_system": "Выбрать систему оценок",
|
||||
"enter_estimate_point": "Ввести оценку"
|
||||
"enter_estimate_point": "Ввести оценку",
|
||||
"step": "Шаг {step} из {total}",
|
||||
"label": "Создать оценку"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1794,6 +1798,25 @@
|
||||
"already_exists": "Значение оценки уже существует.",
|
||||
"unsaved_changes": "У вас есть несохраненные изменения. Пожалуйста, сохраните их перед нажатием на готово",
|
||||
"remove_empty": "Оценка не может быть пустой. Введите значение в каждое поле или удалите те, для которых у вас нет значений."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Баллы",
|
||||
"fibonacci": "Фибоначчи",
|
||||
"linear": "Линейная",
|
||||
"squares": "Квадраты",
|
||||
"custom": "Пользовательская"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Категории",
|
||||
"t_shirt_sizes": "Размеры футболок",
|
||||
"easy_to_hard": "От простого к сложному",
|
||||
"custom": "Пользовательская"
|
||||
},
|
||||
"time": {
|
||||
"label": "Время",
|
||||
"hours": "Часы"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -348,7 +348,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "Nepodarilo sa odstrániť projekt z obľúbených. Skúste to prosím znova.",
|
||||
"add_to_favorites": "Pridať do obľúbených",
|
||||
"remove_from_favorites": "Odstrániť z obľúbených",
|
||||
"publish_settings": "Nastavenia publikovania",
|
||||
"publish_project": "Publikovať projekt",
|
||||
"publish": "Publikovať",
|
||||
"copy_link": "Kopírovať odkaz",
|
||||
"leave_project": "Opustiť projekt",
|
||||
@@ -869,7 +869,8 @@
|
||||
"deleting": "Mazanie",
|
||||
"pending": "Čakajúce",
|
||||
"invite": "Pozvať",
|
||||
"view": "Zobraziť"
|
||||
"view": "Zobraziť",
|
||||
"deactivated_user": "Deaktivovaný používateľ"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1739,12 +1740,15 @@
|
||||
"title": "Povoliť odhady pre môj projekt",
|
||||
"description": "Pomáhajú vám komunikovať zložitosť a pracovné zaťaženie tímu.",
|
||||
"no_estimate": "Bez odhadu",
|
||||
"new": "Nový systém odhadov",
|
||||
"create": {
|
||||
"custom": "Vlastné",
|
||||
"start_from_scratch": "Začať od nuly",
|
||||
"choose_template": "Vybrať šablónu",
|
||||
"choose_estimate_system": "Vybrať systém odhadov",
|
||||
"enter_estimate_point": "Zadať bod odhadu"
|
||||
"enter_estimate_point": "Zadať bod odhadu",
|
||||
"step": "Krok {step} z {total}",
|
||||
"label": "Vytvoriť odhad"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1793,6 +1797,25 @@
|
||||
"already_exists": "Hodnota odhadu už existuje.",
|
||||
"unsaved_changes": "Máte neuložené zmeny. Prosím, uložte ich pred kliknutím na hotovo",
|
||||
"remove_empty": "Odhad nemôže byť prázdny. Zadajte hodnotu do každého poľa alebo odstráňte prázdne polia."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Body",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Lineárne",
|
||||
"squares": "Štvorce",
|
||||
"custom": "Vlastné"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Kategórie",
|
||||
"t_shirt_sizes": "Veľkosti tričiek",
|
||||
"easy_to_hard": "Od jednoduchého po náročné",
|
||||
"custom": "Vlastné"
|
||||
},
|
||||
"time": {
|
||||
"label": "Čas",
|
||||
"hours": "Hodiny"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -348,7 +348,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "Не вдалося вилучити проєкт із вибраного. Спробуйте ще раз.",
|
||||
"add_to_favorites": "Додати у вибране",
|
||||
"remove_from_favorites": "Вилучити з вибраного",
|
||||
"publish_settings": "Налаштування публікації",
|
||||
"publish_project": "Опублікувати проєкт",
|
||||
"publish": "Опублікувати",
|
||||
"copy_link": "Скопіювати посилання",
|
||||
"leave_project": "Вийти з проєкту",
|
||||
@@ -864,7 +864,8 @@
|
||||
"deleting": "Видалення",
|
||||
"pending": "Очікує",
|
||||
"invite": "Запросити",
|
||||
"view": "Подання"
|
||||
"view": "Подання",
|
||||
"deactivated_user": "Деактивований користувач"
|
||||
},
|
||||
"chart": {
|
||||
"x_axis": "Вісь X",
|
||||
@@ -1716,12 +1717,15 @@
|
||||
"title": "Увімкнути оцінки для мого проєкту",
|
||||
"description": "Вони допомагають вам повідомляти про складність та навантаження команди.",
|
||||
"no_estimate": "Без оцінки",
|
||||
"new": "Нова система оцінок",
|
||||
"create": {
|
||||
"custom": "Власний",
|
||||
"start_from_scratch": "Почати з нуля",
|
||||
"choose_template": "Вибрати шаблон",
|
||||
"choose_estimate_system": "Вибрати систему оцінок",
|
||||
"enter_estimate_point": "Введіть оцінку"
|
||||
"enter_estimate_point": "Введіть оцінку",
|
||||
"step": "Крок {step} з {total}",
|
||||
"label": "Створити оцінку"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1770,6 +1774,25 @@
|
||||
"already_exists": "Таке значення оцінки вже існує.",
|
||||
"unsaved_changes": "У вас є незбережені зміни. Збережіть їх перед тим, як натиснути 'готово'",
|
||||
"remove_empty": "Оцінка не може бути порожньою. Введіть значення в кожне поле або видаліть ті, для яких у вас немає значень."
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Бали",
|
||||
"fibonacci": "Фібоначчі",
|
||||
"linear": "Лінійна",
|
||||
"squares": "Квадрати",
|
||||
"custom": "Власна"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Категорії",
|
||||
"t_shirt_sizes": "Розміри футболок",
|
||||
"easy_to_hard": "Від легкого до складного",
|
||||
"custom": "Власна"
|
||||
},
|
||||
"time": {
|
||||
"label": "Час",
|
||||
"hours": "Години"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -348,7 +348,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "Không thể xóa dự án khỏi mục yêu thích. Vui lòng thử lại.",
|
||||
"add_to_favorites": "Thêm vào mục yêu thích",
|
||||
"remove_from_favorites": "Xóa khỏi mục yêu thích",
|
||||
"publish_settings": "Cài đặt xuất bản",
|
||||
"publish_project": "Xuất bản dự án",
|
||||
"publish": "Xuất bản",
|
||||
"copy_link": "Sao chép liên kết",
|
||||
"leave_project": "Rời dự án",
|
||||
@@ -863,7 +863,8 @@
|
||||
"deleting": "Đang xóa",
|
||||
"pending": "Đang chờ xử lý",
|
||||
"invite": "Mời",
|
||||
"view": "Xem"
|
||||
"view": "Xem",
|
||||
"deactivated_user": "Người dùng bị vô hiệu hóa"
|
||||
},
|
||||
"chart": {
|
||||
"x_axis": "Trục X",
|
||||
@@ -1715,12 +1716,15 @@
|
||||
"title": "Bật ước tính cho dự án của tôi",
|
||||
"description": "Chúng giúp bạn truyền đạt độ phức tạp và khối lượng công việc của nhóm.",
|
||||
"no_estimate": "Không có ước tính",
|
||||
"new": "Hệ thống ước tính mới",
|
||||
"create": {
|
||||
"custom": "Tùy chỉnh",
|
||||
"start_from_scratch": "Bắt đầu từ đầu",
|
||||
"choose_template": "Chọn mẫu",
|
||||
"choose_estimate_system": "Chọn hệ thống ước tính",
|
||||
"enter_estimate_point": "Nhập điểm ước tính"
|
||||
"enter_estimate_point": "Nhập điểm ước tính",
|
||||
"step": "Bước {step} của {total}",
|
||||
"label": "Tạo ước tính"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1768,6 +1772,25 @@
|
||||
"empty": "Giá trị ước tính không được để trống",
|
||||
"already_exists": "Giá trị ước tính này đã tồn tại",
|
||||
"unsaved_changes": "Bạn có thay đổi chưa lưu. Vui lòng lưu trước khi nhấn 'xong'"
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "Điểm",
|
||||
"fibonacci": "Fibonacci",
|
||||
"linear": "Tuyến tính",
|
||||
"squares": "Bình phương",
|
||||
"custom": "Tùy chỉnh"
|
||||
},
|
||||
"categories": {
|
||||
"label": "Danh mục",
|
||||
"t_shirt_sizes": "Kích cỡ áo",
|
||||
"easy_to_hard": "Dễ đến khó",
|
||||
"custom": "Tùy chỉnh"
|
||||
},
|
||||
"time": {
|
||||
"label": "Thời gian",
|
||||
"hours": "Giờ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "无法从收藏中移除项目。请重试。",
|
||||
"add_to_favorites": "添加到收藏",
|
||||
"remove_from_favorites": "从收藏中移除",
|
||||
"publish_settings": "发布设置",
|
||||
"publish_project": "发布项目",
|
||||
"publish": "发布",
|
||||
"copy_link": "复制链接",
|
||||
"leave_project": "离开项目",
|
||||
@@ -870,7 +870,8 @@
|
||||
"deleting": "删除中",
|
||||
"pending": "待处理",
|
||||
"invite": "邀请",
|
||||
"view": "查看"
|
||||
"view": "查看",
|
||||
"deactivated_user": "已停用用户"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1740,12 +1741,15 @@
|
||||
"title": "为我的项目启用估算",
|
||||
"description": "它们有助于您传达团队的复杂性和工作量。",
|
||||
"no_estimate": "无估算",
|
||||
"new": "新估算系统",
|
||||
"create": {
|
||||
"custom": "自定义",
|
||||
"start_from_scratch": "从头开始",
|
||||
"choose_template": "选择模板",
|
||||
"choose_estimate_system": "选择估算系统",
|
||||
"enter_estimate_point": "输入估算点数"
|
||||
"enter_estimate_point": "输入估算点数",
|
||||
"step": "步骤 {step} 共 {total}",
|
||||
"label": "创建估算"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1784,6 +1788,16 @@
|
||||
"message": "无法禁用估算。请重试"
|
||||
}
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"min_length": "估算需要大于0。",
|
||||
"unable_to_process": "我们无法处理您的请求,请重试。",
|
||||
"numeric": "估算需要是数值。",
|
||||
"character": "估算需要是字符值。",
|
||||
"empty": "估算值不能为空。",
|
||||
"already_exists": "估算值已存在。",
|
||||
"unsaved_changes": "您有未保存的更改,请在点击完成前保存。",
|
||||
"remove_empty": "估算不能为空。请在每个字段中输入值或删除没有值的字段。"
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
"couldnt_remove_the_project_from_favorites": "無法從我的最愛移除專案。請再試一次。",
|
||||
"add_to_favorites": "加入我的最愛",
|
||||
"remove_from_favorites": "從我的最愛移除",
|
||||
"publish_settings": "發布設定",
|
||||
"publish_project": "發佈專案",
|
||||
"publish": "發布",
|
||||
"copy_link": "複製連結",
|
||||
"leave_project": "離開專案",
|
||||
@@ -871,7 +871,8 @@
|
||||
"deleting": "刪除中",
|
||||
"pending": "待處理",
|
||||
"invite": "邀請",
|
||||
"view": "檢視"
|
||||
"view": "檢視",
|
||||
"deactivated_user": "已停用用戶"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
@@ -1742,12 +1743,15 @@
|
||||
"title": "為我的專案啟用預估",
|
||||
"description": "幫助你傳達團隊的複雜性和工作負荷。",
|
||||
"no_estimate": "無預估",
|
||||
"new": "新估算系統",
|
||||
"create": {
|
||||
"custom": "自訂",
|
||||
"start_from_scratch": "從頭開始",
|
||||
"choose_template": "選擇範本",
|
||||
"choose_estimate_system": "選擇預估系統",
|
||||
"enter_estimate_point": "輸入預估"
|
||||
"enter_estimate_point": "輸入預估",
|
||||
"step": "步驟 {step} 共 {total}",
|
||||
"label": "建立預估"
|
||||
},
|
||||
"toasts": {
|
||||
"created": {
|
||||
@@ -1796,6 +1800,25 @@
|
||||
"already_exists": "預估值已存在。",
|
||||
"unsaved_changes": "你有未儲存的變更。請在點擊完成前儲存",
|
||||
"remove_empty": "預估不能為空。在每個欄位中輸入值或移除沒有值的欄位。"
|
||||
},
|
||||
"systems": {
|
||||
"points": {
|
||||
"label": "點數",
|
||||
"fibonacci": "費波那契數列",
|
||||
"linear": "線性",
|
||||
"squares": "平方數",
|
||||
"custom": "自訂"
|
||||
},
|
||||
"categories": {
|
||||
"label": "類別",
|
||||
"t_shirt_sizes": "T恤尺寸",
|
||||
"easy_to_hard": "簡單到困難",
|
||||
"custom": "自訂"
|
||||
},
|
||||
"time": {
|
||||
"label": "時間",
|
||||
"hours": "小時"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automations": {
|
||||
|
||||
@@ -173,6 +173,8 @@ export class TranslationStore {
|
||||
return import("../locales/ro/translations.json");
|
||||
case "vi-VN":
|
||||
return import("../locales/vi-VN/translations.json");
|
||||
case "tr-TR":
|
||||
return import("../locales/tr-TR/translations.json");
|
||||
default:
|
||||
throw new Error(`Unsupported language: ${language}`);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ export type TLanguage =
|
||||
| "pt-BR"
|
||||
| "id"
|
||||
| "ro"
|
||||
| "vi-VN";
|
||||
| "vi-VN"
|
||||
| "tr-TR";
|
||||
|
||||
export interface ILanguageOption {
|
||||
label: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/logger",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Logger shared across multiple apps internally",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/propel",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/services",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/shared-state",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Shared state shared across multiple apps internally",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/tailwind-config",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "common tailwind configuration across monorepo",
|
||||
"main": "tailwind.config.js",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/types",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"types": "./src/index.d.ts",
|
||||
|
||||
Vendored
+4
-7
@@ -14,10 +14,7 @@ export interface IEstimatePoint {
|
||||
updated_by: string | undefined;
|
||||
}
|
||||
|
||||
export type TEstimateSystemKeys =
|
||||
| EEstimateSystem.POINTS
|
||||
| EEstimateSystem.CATEGORIES
|
||||
| EEstimateSystem.TIME;
|
||||
export type TEstimateSystemKeys = EEstimateSystem.POINTS | EEstimateSystem.CATEGORIES | EEstimateSystem.TIME;
|
||||
|
||||
export interface IEstimate {
|
||||
id: string | undefined;
|
||||
@@ -55,12 +52,14 @@ export type TEstimatePointsObject = {
|
||||
|
||||
export type TTemplateValues = {
|
||||
title: string;
|
||||
i18n_title: string;
|
||||
values: TEstimatePointsObject[];
|
||||
hide?: boolean;
|
||||
};
|
||||
|
||||
export type TEstimateSystem = {
|
||||
name: string;
|
||||
i18n_name: string;
|
||||
templates: Record<string, TTemplateValues>;
|
||||
is_available: boolean;
|
||||
is_ee: boolean;
|
||||
@@ -82,6 +81,4 @@ export type TEstimateTypeErrorObject = {
|
||||
message: string | undefined;
|
||||
};
|
||||
|
||||
export type TEstimateTypeError =
|
||||
| Record<number, TEstimateTypeErrorObject>
|
||||
| undefined;
|
||||
export type TEstimateTypeError = Record<number, TEstimateTypeErrorObject> | undefined;
|
||||
|
||||
Vendored
+1
@@ -35,6 +35,7 @@ export type TIssueEntityData = {
|
||||
sequence_id: number;
|
||||
project_id: string;
|
||||
project_identifier: string;
|
||||
is_epic: boolean;
|
||||
};
|
||||
|
||||
export type TActivityEntityData = {
|
||||
|
||||
Vendored
+1
@@ -42,3 +42,4 @@ export * from "./charts";
|
||||
export * from "./home";
|
||||
export * from "./stickies";
|
||||
export * from "./utils";
|
||||
export * from "./payment";
|
||||
|
||||
+4
-1
@@ -1,3 +1,6 @@
|
||||
// plane imports
|
||||
import { EInboxIssueSource } from "@plane/constants";
|
||||
// local imports
|
||||
import {
|
||||
TIssueActivityWorkspaceDetail,
|
||||
TIssueActivityProjectDetail,
|
||||
@@ -31,7 +34,7 @@ export type TIssueActivity = {
|
||||
epoch: number;
|
||||
issue_comment: string | null;
|
||||
source_data: {
|
||||
source: "IN_APP" | "FORM" | "EMAIL";
|
||||
source: EInboxIssueSource;
|
||||
source_email?: string;
|
||||
extra: {
|
||||
username?: string;
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ export type TIssueComment = {
|
||||
};
|
||||
|
||||
export type TCommentsOperations = {
|
||||
createComment: (data: Partial<TIssueComment>) => Promise<void>;
|
||||
createComment: (data: Partial<TIssueComment>) => Promise<Partial<TIssueComment> | undefined>;
|
||||
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
|
||||
removeComment: (commentId: string) => Promise<void>;
|
||||
uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
|
||||
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
import { EProductSubscriptionEnum } from "@plane/constants";
|
||||
|
||||
export type TBillingFrequency = "month" | "year";
|
||||
|
||||
export type IPaymentProductPrice = {
|
||||
currency: string;
|
||||
id: string;
|
||||
product: string;
|
||||
recurring: TBillingFrequency;
|
||||
unit_amount: number;
|
||||
workspace_amount: number;
|
||||
};
|
||||
|
||||
export type TProductSubscriptionType = "FREE" | "ONE" | "PRO" | "BUSINESS" | "ENTERPRISE";
|
||||
|
||||
export type IPaymentProduct = {
|
||||
description: string;
|
||||
id: string;
|
||||
name: string;
|
||||
type: Omit<TProductSubscriptionType, "FREE">;
|
||||
payment_quantity: number;
|
||||
prices: IPaymentProductPrice[];
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type TSubscriptionPrice = {
|
||||
key: string;
|
||||
id: string | undefined;
|
||||
currency: string;
|
||||
price: number;
|
||||
recurring: TBillingFrequency;
|
||||
};
|
||||
|
||||
export type TProductBillingFrequency = {
|
||||
[key in EProductSubscriptionEnum]: TBillingFrequency | undefined;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/typescript-config",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@plane/ui",
|
||||
"description": "UI components shared across multiple apps internally",
|
||||
"private": true,
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -2,12 +2,12 @@ import React, { useEffect, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// components
|
||||
import { ContextMenuItem } from "./item";
|
||||
// helpers
|
||||
import { cn } from "../../../helpers";
|
||||
// hooks
|
||||
import { usePlatformOS } from "../../hooks/use-platform-os";
|
||||
// components
|
||||
import { ContextMenuItem } from "./item";
|
||||
|
||||
export type TContextMenuItem = {
|
||||
key: string;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { ChevronDown, MoreHorizontal } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { ChevronDown, MoreHorizontal } from "lucide-react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// hooks
|
||||
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||
// helpers
|
||||
import { cn } from "../../helpers";
|
||||
// hooks
|
||||
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
|
||||
// types
|
||||
import { ICustomMenuDropdownProps, ICustomMenuItemProps } from "./helper";
|
||||
|
||||
|
||||
@@ -32,3 +32,4 @@ export * from "./tag";
|
||||
export * from "./tabs";
|
||||
export * from "./calendar";
|
||||
export * from "./color-picker";
|
||||
export * from "./link";
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { FC } from "react";
|
||||
// plane utils
|
||||
import { calculateTimeAgo, cn, getIconForLink } from "@plane/utils";
|
||||
// plane ui
|
||||
import { TContextMenuItem } from "../dropdowns/context-menu/root";
|
||||
import { CustomMenu } from "../dropdowns/custom-menu";
|
||||
|
||||
export type TLinkItemBlockProps = {
|
||||
title: string;
|
||||
url: string;
|
||||
createdAt?: Date | string;
|
||||
menuItems?: TContextMenuItem[];
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const LinkItemBlock: FC<TLinkItemBlockProps> = (props) => {
|
||||
// props
|
||||
const { title, url, createdAt, menuItems, onClick } = props;
|
||||
// icons
|
||||
const Icon = getIconForLink(url);
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="cursor-pointer group flex items-center bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4"
|
||||
>
|
||||
<div className="flex-shrink-0 size-8 rounded p-2 bg-custom-background-90 grid place-items-center">
|
||||
<Icon className="size-4 stroke-2 text-custom-text-350 group-hover:text-custom-text-100" />
|
||||
</div>
|
||||
<div className="flex-1 truncate">
|
||||
<div className="text-sm font-medium truncate">{title}</div>
|
||||
{createdAt && <div className="text-xs font-medium text-custom-text-400">{calculateTimeAgo(createdAt)}</div>}
|
||||
</div>
|
||||
{menuItems && (
|
||||
<div className="hidden group-hover:block">
|
||||
<CustomMenu placement="bottom-end" menuItemsClassName="z-20" closeOnSelect verticalEllipsis>
|
||||
{menuItems.map((item) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
className={cn("flex items-center gap-2 w-full ", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./block";
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/utils",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"description": "Helper functions shared across multiple apps internally",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Github,
|
||||
Linkedin,
|
||||
Twitter,
|
||||
Facebook,
|
||||
Instagram,
|
||||
Youtube,
|
||||
Dribbble,
|
||||
Figma,
|
||||
FileText,
|
||||
FileImage,
|
||||
FileVideo,
|
||||
FileAudio,
|
||||
FileArchive,
|
||||
FileSpreadsheet,
|
||||
FileCode,
|
||||
Mail,
|
||||
Chrome,
|
||||
Link2,
|
||||
} from "lucide-react";
|
||||
|
||||
type IconMatcher = {
|
||||
pattern: RegExp;
|
||||
icon: typeof Github;
|
||||
};
|
||||
|
||||
const SOCIAL_MEDIA_MATCHERS: IconMatcher[] = [
|
||||
{ pattern: /github\.com/, icon: Github },
|
||||
{ pattern: /linkedin\.com/, icon: Linkedin },
|
||||
{ pattern: /(twitter\.com|x\.com)/, icon: Twitter },
|
||||
{ pattern: /facebook\.com/, icon: Facebook },
|
||||
{ pattern: /instagram\.com/, icon: Instagram },
|
||||
{ pattern: /youtube\.com/, icon: Youtube },
|
||||
{ pattern: /dribbble\.com/, icon: Dribbble },
|
||||
];
|
||||
|
||||
const PRODUCTIVITY_MATCHERS: IconMatcher[] = [
|
||||
{ pattern: /figma\.com/, icon: Figma },
|
||||
{ pattern: /(google\.com|docs\.|doc\.)/, icon: FileText },
|
||||
];
|
||||
|
||||
const FILE_TYPE_MATCHERS: IconMatcher[] = [
|
||||
{ pattern: /\.(jpg|jpeg|png|gif|bmp|svg|webp)$/, icon: FileImage },
|
||||
{ pattern: /\.(mp4|mov|avi|wmv|flv|mkv)$/, icon: FileVideo },
|
||||
{ pattern: /\.(mp3|wav|ogg)$/, icon: FileAudio },
|
||||
{ pattern: /\.(zip|rar|7z|tar|gz)$/, icon: FileArchive },
|
||||
{ pattern: /\.(xls|xlsx|csv)$/, icon: FileSpreadsheet },
|
||||
{ pattern: /\.(pdf|doc|docx|txt)$/, icon: FileText },
|
||||
{ pattern: /\.(html|js|ts|jsx|tsx|css|scss)$/, icon: FileCode },
|
||||
];
|
||||
|
||||
const OTHER_MATCHERS: IconMatcher[] = [
|
||||
{ pattern: /^mailto:/, icon: Mail },
|
||||
{ pattern: /^http/, icon: Chrome },
|
||||
];
|
||||
|
||||
export const getIconForLink = (url: string) => {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
|
||||
const allMatchers = [...SOCIAL_MEDIA_MATCHERS, ...PRODUCTIVITY_MATCHERS, ...FILE_TYPE_MATCHERS, ...OTHER_MATCHERS];
|
||||
|
||||
const matchedIcon = allMatchers.find(({ pattern }) => pattern.test(lowerUrl));
|
||||
return matchedIcon?.icon ?? Link2;
|
||||
};
|
||||
@@ -12,3 +12,8 @@ export * from "./string";
|
||||
export * from "./theme";
|
||||
export * from "./workspace";
|
||||
export * from "./work-item";
|
||||
|
||||
export * from "./get-icon-for-link";
|
||||
|
||||
export * from "./subscription";
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import orderBy from "lodash/orderBy";
|
||||
// plane imports
|
||||
import { EProductSubscriptionEnum } from "@plane/constants";
|
||||
import { IPaymentProduct, TProductSubscriptionType, TSubscriptionPrice } from "@plane/types";
|
||||
|
||||
/**
|
||||
* Calculates the yearly discount percentage when switching from monthly to yearly billing
|
||||
* @param monthlyPrice - The monthly subscription price
|
||||
* @param yearlyPricePerMonth - The monthly equivalent price when billed yearly
|
||||
* @returns The discount percentage as a whole number (floored)
|
||||
*/
|
||||
export const calculateYearlyDiscount = (monthlyPrice: number, yearlyPricePerMonth: number): number => {
|
||||
const monthlyCost = monthlyPrice * 12;
|
||||
const yearlyCost = yearlyPricePerMonth * 12;
|
||||
const amountSaved = monthlyCost - yearlyCost;
|
||||
const discountPercentage = (amountSaved / monthlyCost) * 100;
|
||||
return Math.floor(discountPercentage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the display name for a subscription plan variant
|
||||
* @param planVariant - The subscription plan variant enum
|
||||
* @returns The human-readable name of the plan
|
||||
*/
|
||||
export const getSubscriptionName = (planVariant: EProductSubscriptionEnum): string => {
|
||||
switch (planVariant) {
|
||||
case EProductSubscriptionEnum.FREE:
|
||||
return "Free";
|
||||
case EProductSubscriptionEnum.ONE:
|
||||
return "One";
|
||||
case EProductSubscriptionEnum.PRO:
|
||||
return "Pro";
|
||||
case EProductSubscriptionEnum.BUSINESS:
|
||||
return "Business";
|
||||
case EProductSubscriptionEnum.ENTERPRISE:
|
||||
return "Enterprise";
|
||||
default:
|
||||
return "--";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the base subscription name for upgrade/downgrade paths
|
||||
* @param planVariant - The current subscription plan variant
|
||||
* @param isSelfHosted - Whether the instance is self-hosted / community
|
||||
* @returns The name of the base subscription plan
|
||||
*
|
||||
* @remarks
|
||||
* - For self-hosted / community instances, the upgrade path differs from cloud instances
|
||||
* - Returns the immediate lower tier subscription name
|
||||
*/
|
||||
export const getBaseSubscriptionName = (planVariant: TProductSubscriptionType, isSelfHosted: boolean): string => {
|
||||
switch (planVariant) {
|
||||
case EProductSubscriptionEnum.ONE:
|
||||
return getSubscriptionName(EProductSubscriptionEnum.FREE);
|
||||
case EProductSubscriptionEnum.PRO:
|
||||
return isSelfHosted
|
||||
? getSubscriptionName(EProductSubscriptionEnum.ONE)
|
||||
: getSubscriptionName(EProductSubscriptionEnum.FREE);
|
||||
case EProductSubscriptionEnum.BUSINESS:
|
||||
return getSubscriptionName(EProductSubscriptionEnum.PRO);
|
||||
case EProductSubscriptionEnum.ENTERPRISE:
|
||||
return getSubscriptionName(EProductSubscriptionEnum.BUSINESS);
|
||||
default:
|
||||
return "--";
|
||||
}
|
||||
};
|
||||
|
||||
export type TSubscriptionPriceDetail = {
|
||||
monthlyPriceDetails: TSubscriptionPrice;
|
||||
yearlyPriceDetails: TSubscriptionPrice;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the price details for a subscription product
|
||||
* @param product - The payment product to get price details for
|
||||
* @returns Array of price details for monthly and yearly plans
|
||||
*/
|
||||
export const getSubscriptionPriceDetails = (product: IPaymentProduct | undefined): TSubscriptionPriceDetail => {
|
||||
const productPrices = product?.prices || [];
|
||||
const monthlyPriceDetails = orderBy(productPrices, ["recurring"], ["desc"])?.find(
|
||||
(price) => price.recurring === "month"
|
||||
);
|
||||
const monthlyPriceAmount = Number(((monthlyPriceDetails?.unit_amount || 0) / 100).toFixed(2));
|
||||
const yearlyPriceDetails = orderBy(productPrices, ["recurring"], ["desc"])?.find(
|
||||
(price) => price.recurring === "year"
|
||||
);
|
||||
const yearlyPriceAmount = Number(((yearlyPriceDetails?.unit_amount || 0) / 1200).toFixed(2));
|
||||
|
||||
return {
|
||||
monthlyPriceDetails: {
|
||||
key: "monthly",
|
||||
id: monthlyPriceDetails?.id,
|
||||
currency: "$",
|
||||
price: monthlyPriceAmount,
|
||||
recurring: "month",
|
||||
},
|
||||
yearlyPriceDetails: {
|
||||
key: "yearly",
|
||||
id: yearlyPriceDetails?.id,
|
||||
currency: "$",
|
||||
price: yearlyPriceAmount,
|
||||
recurring: "year",
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,15 +1,89 @@
|
||||
#!/bin/bash
|
||||
cp ./.env.example ./.env
|
||||
|
||||
# Export for tr error in mac
|
||||
# Plane Project Setup Script
|
||||
# This script prepares the local development environment by setting up all necessary .env files
|
||||
# https://github.com/makeplane/plane
|
||||
|
||||
# Set colors for output messages
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Print header
|
||||
echo -e "${BOLD}${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BOLD}${BLUE} Plane - Project Management Tool ${NC}"
|
||||
echo -e "${BOLD}${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BOLD}Setting up your development environment...${NC}\n"
|
||||
|
||||
# Function to handle file copying with error checking
|
||||
copy_env_file() {
|
||||
local source=$1
|
||||
local destination=$2
|
||||
|
||||
if [ ! -f "$source" ]; then
|
||||
echo -e "${RED}Error: Source file $source does not exist.${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
cp "$source" "$destination"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✓${NC} Copied $destination"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Failed to copy $destination"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Export character encoding settings for macOS compatibility
|
||||
export LC_ALL=C
|
||||
export LC_CTYPE=C
|
||||
echo -e "${YELLOW}Setting up environment files...${NC}"
|
||||
|
||||
cp ./web/.env.example ./web/.env
|
||||
cp ./apiserver/.env.example ./apiserver/.env
|
||||
cp ./space/.env.example ./space/.env
|
||||
cp ./admin/.env.example ./admin/.env
|
||||
cp ./live/.env.example ./live/.env
|
||||
# Copy all environment example files
|
||||
services=("" "web" "apiserver" "space" "admin" "live")
|
||||
success=true
|
||||
|
||||
# Generate the SECRET_KEY that will be used by django
|
||||
echo -e "\nSECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env
|
||||
for service in "${services[@]}"; do
|
||||
prefix="./"
|
||||
if [ "$service" != "" ]; then
|
||||
prefix="./$service/"
|
||||
fi
|
||||
|
||||
copy_env_file "${prefix}.env.example" "${prefix}.env" || success=false
|
||||
done
|
||||
|
||||
# Generate SECRET_KEY for Django
|
||||
if [ -f "./apiserver/.env" ]; then
|
||||
echo -e "\n${YELLOW}Generating Django SECRET_KEY...${NC}"
|
||||
SECRET_KEY=$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)
|
||||
|
||||
if [ -z "$SECRET_KEY" ]; then
|
||||
echo -e "${RED}Error: Failed to generate SECRET_KEY.${NC}"
|
||||
echo -e "${RED}Ensure 'tr' and 'head' commands are available on your system.${NC}"
|
||||
success=false
|
||||
else
|
||||
echo -e "SECRET_KEY=\"$SECRET_KEY\"" >> ./apiserver/.env
|
||||
echo -e "${GREEN}✓${NC} Added SECRET_KEY to apiserver/.env"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}✗${NC} apiserver/.env not found. SECRET_KEY not added."
|
||||
success=false
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo -e "\n${YELLOW}Setup status:${NC}"
|
||||
if [ "$success" = true ]; then
|
||||
echo -e "${GREEN}✓${NC} Environment setup completed successfully!\n"
|
||||
echo -e "${BOLD}Next steps:${NC}"
|
||||
echo -e "1. Review the .env files in each folder if needed"
|
||||
echo -e "2. Start the services with: ${BOLD}docker compose -f docker-compose-local.yml up -d${NC}"
|
||||
echo -e "\n${GREEN}Happy coding! 🚀${NC}"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Some issues occurred during setup. Please check the errors above.\n"
|
||||
echo -e "For help, visit: ${BLUE}https://github.com/makeplane/plane${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user