Compare commits

...
Author SHA1 Message Date
Akshat JainandGitHub f278a284c4 chore: comment out APP_RELEASE variable update in build-branch workflow (#6989) 2025-04-28 17:45:44 +05:30
sriram veeraghanta 2bcf6c76cd chore: remove dockerhub user varible from compose 2025-04-28 16:28:48 +05:30
Akshat JainandGitHub fb3e022042 [INFRA-134] fix: Setup and Swarm scripts for DOCKERHUB_USERNAME #6988 2025-04-28 14:37:51 +05:30
Akshat JainandGitHub e3fbb7b073 [INFRA-134]: Update Docker images to use new artifact repository path #6978 2025-04-25 18:09:43 +05:30
Anmol Singh BhatiaandGitHub cce6dd581c [WEB-3948] chore: recent work item improvement (#6976)
* chore: issue entity data type updated

* chore: HomePeekOverviewsRoot component added

* chore: recent work item improvement and code refactor
2025-04-25 15:08:10 +05:30
Akshita GoyalandGitHub d86ac368a4 [WEB-3863] fix: handled error handling for link editing #6968 2025-04-25 14:31:35 +05:30
101994840a [WEB-3944] fix: Error Toast message content update while uploading images (#6969)
* fix: handled svg uploads

* chore: proper error message with all allowed types

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
2025-04-25 14:30:12 +05:30
Anmol Singh BhatiaandGitHub f60f57ef11 [WEB-3494] chore: platform ux copy changes (#6970)
* chore: project quick action option ux copy updated

* chore: project tour copy updated
2025-04-25 14:29:09 +05:30
Prateek ShouryaandGitHub 546217f09b [WEB-3953] fix: issue description assets upload when project id is switched (#6975) 2025-04-25 14:27:40 +05:30
sriram veeraghanta 6df8323665 fix: add gzip upload support 2025-04-24 17:50:37 +05:30
SangeethaandGitHub 77d022df71 [WEB-3919] chore: support .sql file attachment #6966 2025-04-24 17:39:16 +05:30
M. PalanikannanandGitHub 797f150ec4 [WIKI-331] fix: editor ref issues while locking/unlocking page #6965 2025-04-24 17:38:41 +05:30
sriram veeraghanta b54f54999e chore: bump up the package version 2025-04-24 17:37:50 +05:30
SangeethaandGitHub dff176be8f [WEB-3930] chore: set IN_APP as default source value for intake issue (#6963)
* chore: chore: only allow intake issues with source IN_APP to be created

* chore: set IN_APP as default intake issue
2025-04-24 16:25:15 +05:30
2bbaaed3ea [WEB-3918] fix: api tokens is_active (#6941)
* fix: is_active always returning true
chore: formate expired_at to iso date

* Display exact expiration timestamp for API tokens

* chore: remove conversion to iso

* chore: remove unwanted imports

* fix: added timestamp for api token expiry

* fix: handle none value in expired_at

* fix: fix: handle none value in expired_at

* chore: add type hints

* fix: refactor

---------

Co-authored-by: Alaaeddine bousselmi <alaaeddine.bousselmi@medtech.tn>
Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
Co-authored-by: Akshita Goyal <36129505+gakshita@users.noreply.github.com>
2025-04-24 01:28:29 +05:30
Prateek ShouryaandGitHub b5ceb94fb2 [WEB-3930] fix: application crash on accessing intake work items (#6958) 2025-04-23 15:12:54 +05:30
alaabousselmiandGitHub feb6243065 docs: document minimum RAM requirement and issue naming conventions (#6954) 2025-04-22 18:00:19 +05:30
Anmol Singh BhatiaandGitHub 5dacba74c9 [WEB-3923] fix: applied filters list #6957 2025-04-22 17:58:16 +05:30
bIaOandGitHub 0efb0c239c feat: improve setup.sh script with better error handling and user feedback (#6758) 2025-04-22 17:56:34 +05:30
Vamsi KrishnaandGitHub c8be836d6c [WEB-3920]fix: estimate activity #6950 2025-04-22 17:45:15 +05:30
NikhilandGitHub 833b82e247 [WEB-3927] chore: add logging to support json logging (#6955)
* chore: update logging to json based logging

* chore: add logging to file
2025-04-22 17:41:58 +05:30
Akshita GoyalandGitHub 280aa7f671 [WEB-3399] fix: progress data for cycle list item #6956 2025-04-22 17:41:06 +05:30
Aaryan KhandelwalandGitHub eac1115566 [WIKI-320] refactor: page header actions (#6946)
* refactor: page header actions

* chore: update toolbar component

* chore: update archived and lock badge colors

* chore: added observer to favorite control
2025-04-17 20:52:33 +05:30
sriram veeraghanta 8166a757a7 fix: removed @plane alias from ui package 2025-04-17 20:51:52 +05:30
Anmol Singh BhatiaandGitHub be5d77d978 [WEB-3892] chore: link item improvements (#6944)
* chore: code refactor

* chore: global link block component added

* chore: link item improvement and code refactor
2025-04-17 20:08:53 +05:30
Anmol Singh BhatiaandGitHub 18fb3b8450 [WEB-3904] fix: sub work item fetching #6945 2025-04-17 20:07:13 +05:30
sriram veeraghanta ef5616905e chore: upgrade turbo repo version 2025-04-17 17:51:59 +05:30
SangeethaandGitHub aeb41e603c [WEB-3826] feat: estimate activitites #6937 2025-04-17 17:16:57 +05:30
Vamsi KrishnaandGitHub 55eea1a8b7 [WEB-3872]chore: header switcher enhancements (#6935)
* * chore: alignment and size for header
* fix: switcher close on click

* chore: moved acces icon component to components
2025-04-17 17:15:53 +05:30
Aaryan KhandelwalandGitHub fa87ff14b7 [WIKI-319] chore: remove bottom border when toolbar is hidden (#6943)
* chore: remove border when toolbar is hidden

* chore: add stricter conditions
2025-04-17 17:13:21 +05:30
khalilzitouni2058andGitHub 7d91b5f8df [WEB-3892] feat: add icon to Quicklinks (#6927)
* [feature]: add icon to Quicklinks

* fix: moving  getIconForLink to utils packages
2025-04-17 17:11:57 +05:30
Anmol Singh BhatiaandGitHub 3ce40dfa2f [WIKI-316] fix: list item overflow #6942 2025-04-17 17:08:13 +05:30
Anmol Singh BhatiaandGitHub f65253c994 [WEB-2561] chore: favicon icon updated #6938 (#6940)
* chore: favicon icon updated

* chore: code refactor
2025-04-17 15:38:42 +05:30
Anmol Singh BhatiaandGitHub 97fcfaa653 [WEB-2561] chore: favicon icon updated #6938 2025-04-16 20:34:12 +05:30
Anmol Singh BhatiaandGitHub 0e1ebff978 [WEB-3871] fix: sidebar label property #6934 2025-04-15 19:42:02 +05:30
Anmol Singh BhatiaandGitHub 642dabfe35 [WEB-3870] fix: sidebar comment scroll #6932 2025-04-15 17:47:22 +05:30
Aaryan KhandelwalandGitHub 48557cb670 [WEB-3868] fix: issue detail widget modals #6933 2025-04-15 17:46:45 +05:30
Bavisetti NarayanandGitHub 608da1465c [WEB-3860] chore: added deleted filter in the grouper (#6931)
* chore: added deleted filter in the grouper

* chore: added type hints for the function
2025-04-15 17:42:45 +05:30
dbcc7bedb4 [WEB-3855] feat: Turkish language support (#6922)
* add Turkish language support (#6874)

* add turkish language support

* fix indentation

* chore: extended core translation added

* chore: code refactor

---------

Co-authored-by: Farahat Abdrabouh <88924701+fasdjkherig@users.noreply.github.com>
2025-04-15 16:36:02 +05:30
Vamsi KrishnaandGitHub c401b26dd4 [WEB-3856]chore: refactor work item activity (#6923)
* chore: refactor work item activity

* chore: added estimate render for notifications
2025-04-15 16:35:28 +05:30
Aaryan KhandelwalandGitHub a4bca0c39c [WEB-3859] fix: work item links #6930 2025-04-15 13:46:29 +05:30
Saurabh KumarandGitHub 24899887b2 chore: Add workspace slug to should render setting link method (#6886)
* add workspace slug to setting link function

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

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

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

* chore: update status 410 Gone to 409 conflicts

* chore: add webhook trigger for issue created through apis

* chore: remove pks from post

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

* chore: estimate activiy formatting

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

* fix: dynamically setting the position of the comment box

* fix: refactor

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

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

* chore: handle undefined date

* fix: project typo

---------

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

* fix: remove autofocus from the copy markdown button

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

* fix: updated pages header swithcer
2025-04-09 19:36:15 +05:30
Vamsi KrishnaandGitHub b738e39a4a [WEB-3798]chore: updated language support to estimates (#6900) 2025-04-09 19:34:01 +05:30
259 changed files with 8440 additions and 1594 deletions
+1 -1
View File
@@ -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
+18
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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"
+9 -1
View File
@@ -39,7 +39,15 @@ class CycleSerializer(BaseSerializer):
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
):
project_id = self.initial_data.get("project_id") or self.instance.project_id
project_id = self.initial_data.get("project_id") or (
self.instance.project_id
if self.instance and hasattr(self.instance, "project_id")
else None
)
if not project_id:
raise serializers.ValidationError("Project ID is required")
is_start_date_end_date_equal = (
True
if str(data.get("start_date")) == str(data.get("end_date"))
+34 -14
View File
@@ -141,8 +141,10 @@ class CycleAPIEndpoint(BaseAPIView):
if pk:
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
data = CycleSerializer(
queryset, fields=self.fields,
expand=self.expand, context={"project": project}
queryset,
fields=self.fields,
expand=self.expand,
context={"project": project},
).data
return Response(data, status=status.HTTP_200_OK)
queryset = self.get_queryset().filter(archived_at__isnull=True)
@@ -154,8 +156,11 @@ class CycleAPIEndpoint(BaseAPIView):
start_date__lte=timezone.now(), end_date__gte=timezone.now()
)
data = CycleSerializer(
queryset, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
queryset,
many=True,
fields=self.fields,
expand=self.expand,
context={"project": project},
).data
return Response(data, status=status.HTTP_200_OK)
@@ -166,8 +171,11 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
cycles,
many=True,
fields=self.fields,
expand=self.expand,
context={"project": project},
).data,
)
@@ -178,8 +186,11 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
cycles,
many=True,
fields=self.fields,
expand=self.expand,
context={"project": project},
).data,
)
@@ -190,8 +201,11 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
cycles,
many=True,
fields=self.fields,
expand=self.expand,
context={"project": project},
).data,
)
@@ -204,16 +218,22 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
cycles,
many=True,
fields=self.fields,
expand=self.expand,
context={"project": project},
).data,
)
return self.paginate(
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
cycles,
many=True,
fields=self.fields,
expand=self.expand,
context={"project": project},
).data,
)
+2 -1
View File
@@ -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(
+12
View File
@@ -57,6 +57,7 @@ from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.bgtasks.webhook_task import model_activity
class WorkspaceIssueAPIEndpoint(BaseAPIView):
@@ -322,6 +323,17 @@ class IssueAPIEndpoint(BaseAPIView):
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
# Send the model activity
model_activity.delay(
model_name="issue",
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+5 -4
View File
@@ -32,6 +32,7 @@ from plane.bgtasks.webhook_task import model_activity, webhook_activity
from .base import BaseAPIView
from plane.utils.host import base_host
class ProjectAPIEndpoint(BaseAPIView):
"""Project Endpoints to create, update, list, retrieve and delete endpoint"""
@@ -238,7 +239,7 @@ class ProjectAPIEndpoint(BaseAPIView):
if "already exists" in str(e):
return Response(
{"name": "The project name is already taken"},
status=status.HTTP_410_GONE,
status=status.HTTP_409_CONFLICT,
)
except Workspace.DoesNotExist:
return Response(
@@ -247,7 +248,7 @@ class ProjectAPIEndpoint(BaseAPIView):
except ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_410_GONE,
status=status.HTTP_409_CONFLICT,
)
def patch(self, request, slug, pk):
@@ -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):
+9
View File
@@ -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:
+24 -2
View File
@@ -352,8 +352,19 @@ class IssueRelationSerializer(BaseSerializer):
"state_id",
"priority",
"assignee_ids",
"created_by",
"created_at",
"updated_at",
"updated_by",
]
read_only_fields = [
"workspace",
"project",
"created_by",
"created_at",
"updated_by",
"updated_at",
]
read_only_fields = ["workspace", "project"]
class RelatedIssueSerializer(BaseSerializer):
@@ -383,8 +394,19 @@ class RelatedIssueSerializer(BaseSerializer):
"state_id",
"priority",
"assignee_ids",
"created_by",
"created_at",
"updated_by",
"updated_at",
]
read_only_fields = [
"workspace",
"project",
"created_by",
"created_at",
"updated_by",
"updated_at",
]
read_only_fields = ["workspace", "project"]
class IssueAssigneeSerializer(BaseSerializer):
+4 -4
View File
@@ -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
+2 -3
View File
@@ -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)
+5 -4
View File
@@ -41,6 +41,7 @@ from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.exception_logger import log_exception
from plane.utils.host import base_host
class ProjectViewSet(BaseViewSet):
serializer_class = ProjectListSerializer
model = Project
@@ -341,7 +342,7 @@ class ProjectViewSet(BaseViewSet):
if "already exists" in str(e):
return Response(
{"name": "The project name is already taken"},
status=status.HTTP_410_GONE,
status=status.HTTP_409_CONFLICT,
)
except Workspace.DoesNotExist:
return Response(
@@ -350,7 +351,7 @@ class ProjectViewSet(BaseViewSet):
except serializers.ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_410_GONE,
status=status.HTTP_409_CONFLICT,
)
def partial_update(self, request, slug, pk=None):
@@ -419,7 +420,7 @@ class ProjectViewSet(BaseViewSet):
if "already exists" in str(e):
return Response(
{"name": "The project name is already taken"},
status=status.HTTP_410_GONE,
status=status.HTTP_409_CONFLICT,
)
except (Project.DoesNotExist, Workspace.DoesNotExist):
return Response(
@@ -428,7 +429,7 @@ class ProjectViewSet(BaseViewSet):
except serializers.ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_410_GONE,
status=status.HTTP_409_CONFLICT,
)
def destroy(self, request, slug, pk):
+1 -1
View File
@@ -29,7 +29,7 @@ class WebhookEndpoint(BaseAPIView):
if "already exists" in str(e):
return Response(
{"error": "URL already exists for the workspace"},
status=status.HTTP_410_GONE,
status=status.HTTP_409_CONFLICT,
)
raise IntegrityError
+7 -6
View File
@@ -119,7 +119,9 @@ class WorkSpaceViewSet(BaseViewSet):
)
# Get total members and role
total_members=WorkspaceMember.objects.filter(workspace_id=serializer.data["id"]).count()
total_members = WorkspaceMember.objects.filter(
workspace_id=serializer.data["id"]
).count()
data = serializer.data
data["total_members"] = total_members
data["role"] = 20
@@ -134,7 +136,7 @@ class WorkSpaceViewSet(BaseViewSet):
if "already exists" in str(e):
return Response(
{"slug": "The workspace with the slug already exists"},
status=status.HTTP_410_GONE,
status=status.HTTP_409_CONFLICT,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
@@ -167,10 +169,9 @@ class UserWorkSpacesEndpoint(BaseAPIView):
.values("count")
)
role = (
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True)
.values("role")
)
role = WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member=request.user, is_active=True
).values("role")
workspace = (
Workspace.objects.prefetch_related(
@@ -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 ",
+31 -1
View File
@@ -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()
@@ -0,0 +1,31 @@
# Generated by Django 4.2.17 on 2025-03-04 19:29
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
("db", "0092_alter_deprecateddashboardwidget_unique_together_and_more"),
]
operations = [
migrations.AddField(
model_name="page",
name="moved_to_page",
field=models.UUIDField(blank=True, null=True),
),
migrations.AddField(
model_name="page",
name="moved_to_project",
field=models.UUIDField(blank=True, null=True),
),
migrations.AddField(
model_name="pageversion",
name="sub_pages_data",
field=models.JSONField(blank=True, default=dict),
),
]
+1 -1
View File
@@ -82,4 +82,4 @@ from .label import Label
from .device import Device, DeviceSession
from .sticky import Sticky
from .sticky import Sticky
+4
View File
@@ -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
+3
View File
@@ -50,6 +50,8 @@ class Page(BaseModel):
projects = models.ManyToManyField(
"db.Project", related_name="pages", through="db.ProjectPage"
)
moved_to_page = models.UUIDField(null=True, blank=True)
moved_to_project = models.UUIDField(null=True, blank=True)
class Meta:
verbose_name = "Page"
@@ -172,6 +174,7 @@ class PageVersion(BaseModel):
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_json = models.JSONField(default=dict, blank=True)
sub_pages_data = models.JSONField(default=dict, blank=True)
class Meta:
verbose_name = "Page Version"
@@ -109,5 +109,5 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
if "already exists" in str(e):
return Response(
{"slug": "The workspace with the slug already exists"},
status=status.HTTP_410_GONE,
status=status.HTTP_409_CONFLICT,
)
@@ -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
+111
View File
@@ -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
+6 -1
View File
@@ -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",
]
+21 -6
View File
@@ -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},
},
}
+22 -9
View File
@@ -26,11 +26,10 @@ if not os.path.exists(LOG_DIR):
# Logging configuration
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"disable_existing_loggers": True,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
"format": "%(asctime)s [%(process)d] %(levelname)s %(name)s: %(message)s"
},
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
@@ -40,7 +39,7 @@ LOGGING = {
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
"formatter": "json",
"level": "INFO",
},
"file": {
@@ -59,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,
},
},
}
+25 -3
View File
@@ -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
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -41,4 +41,4 @@ class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest):
response = self.client.post(
url, {"name": "Plane", "slug": "pla-ne"}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_410_GONE)
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
+2 -2
View File
@@ -8,8 +8,8 @@ from django.conf import settings
def log_exception(e):
# Log the error
logger = logging.getLogger("plane")
logger.error(e)
logger = logging.getLogger("plane.exception")
logger.exception(e)
if settings.DEBUG:
# Print the traceback if in debug mode
+59 -31
View File
@@ -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 []
+1 -1
View File
@@ -43,7 +43,7 @@ scout-apm==3.1.0
# xlsx generation
openpyxl==3.1.2
# logging
python-json-logger==2.0.7
python-json-logger==3.3.0
# html parser
beautifulsoup4==4.12.3
# analytics
+9 -10
View File
@@ -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}
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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"
+75 -69
View File
@@ -1,91 +1,97 @@
import { TInboxDuplicateIssueDetails, TIssue } from "@plane/types";
export enum EInboxIssueCurrentTab {
OPEN = "open",
CLOSED = "closed",
OPEN = "open",
CLOSED = "closed",
}
export enum EInboxIssueStatus {
PENDING = -2,
DECLINED = -1,
SNOOZED = 0,
ACCEPTED = 1,
DUPLICATE = 2,
PENDING = -2,
DECLINED = -1,
SNOOZED = 0,
ACCEPTED = 1,
DUPLICATE = 2,
}
export enum EInboxIssueSource {
IN_APP = "IN_APP",
FORMS = "FORMS",
EMAIL = "EMAIL",
}
export type TInboxIssueCurrentTab = EInboxIssueCurrentTab;
export type TInboxIssueStatus = EInboxIssueStatus;
export type TInboxIssue = {
id: string;
status: TInboxIssueStatus;
snoozed_till: Date | null;
duplicate_to: string | undefined;
source: string;
issue: TIssue;
created_by: string;
duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined;
id: string;
status: TInboxIssueStatus;
snoozed_till: Date | null;
duplicate_to: string | undefined;
source: EInboxIssueSource | undefined;
issue: TIssue;
created_by: string;
duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined;
};
export const INBOX_STATUS: {
key: string;
status: TInboxIssueStatus;
i18n_title: string;
i18n_description: () => string;
key: string;
status: TInboxIssueStatus;
i18n_title: string;
i18n_description: () => string;
}[] = [
{
key: "pending",
i18n_title: "inbox_issue.status.pending.title",
status: EInboxIssueStatus.PENDING,
i18n_description: () => `inbox_issue.status.pending.description`,
},
{
key: "declined",
i18n_title: "inbox_issue.status.declined.title",
status: EInboxIssueStatus.DECLINED,
i18n_description: () => `inbox_issue.status.declined.description`,
},
{
key: "snoozed",
i18n_title: "inbox_issue.status.snoozed.title",
status: EInboxIssueStatus.SNOOZED,
i18n_description: () => `inbox_issue.status.snoozed.description`,
},
{
key: "accepted",
i18n_title: "inbox_issue.status.accepted.title",
status: EInboxIssueStatus.ACCEPTED,
i18n_description: () => `inbox_issue.status.accepted.description`,
},
{
key: "duplicate",
i18n_title: "inbox_issue.status.duplicate.title",
status: EInboxIssueStatus.DUPLICATE,
i18n_description: () => `inbox_issue.status.duplicate.description`,
},
{
key: "pending",
i18n_title: "inbox_issue.status.pending.title",
status: EInboxIssueStatus.PENDING,
i18n_description: () => `inbox_issue.status.pending.description`,
},
{
key: "declined",
i18n_title: "inbox_issue.status.declined.title",
status: EInboxIssueStatus.DECLINED,
i18n_description: () => `inbox_issue.status.declined.description`,
},
{
key: "snoozed",
i18n_title: "inbox_issue.status.snoozed.title",
status: EInboxIssueStatus.SNOOZED,
i18n_description: () => `inbox_issue.status.snoozed.description`,
},
{
key: "accepted",
i18n_title: "inbox_issue.status.accepted.title",
status: EInboxIssueStatus.ACCEPTED,
i18n_description: () => `inbox_issue.status.accepted.description`,
},
{
key: "duplicate",
i18n_title: "inbox_issue.status.duplicate.title",
status: EInboxIssueStatus.DUPLICATE,
i18n_description: () => `inbox_issue.status.duplicate.description`,
},
];
export const INBOX_ISSUE_ORDER_BY_OPTIONS = [
{
key: "issue__created_at",
i18n_label: "inbox_issue.order_by.created_at",
},
{
key: "issue__updated_at",
i18n_label: "inbox_issue.order_by.updated_at",
},
{
key: "issue__sequence_id",
i18n_label: "inbox_issue.order_by.id",
},
{
key: "issue__created_at",
i18n_label: "inbox_issue.order_by.created_at",
},
{
key: "issue__updated_at",
i18n_label: "inbox_issue.order_by.updated_at",
},
{
key: "issue__sequence_id",
i18n_label: "inbox_issue.order_by.id",
},
];
export const INBOX_ISSUE_SORT_BY_OPTIONS = [
{
key: "asc",
i18n_label: "common.sort.asc",
},
{
key: "desc",
i18n_label: "common.sort.desc",
},
{
key: "asc",
i18n_label: "common.sort.asc",
},
{
key: "desc",
i18n_label: "common.sort.desc",
},
];
+2
View File
@@ -14,6 +14,7 @@ export * from "./state";
export * from "./swr";
export * from "./tab-indices";
export * from "./user";
export * from "./payment";
export * from "./workspace";
export * from "./stickies";
export * from "./cycle";
@@ -30,3 +31,4 @@ export * from "./spreadsheet";
export * from "./dashboard";
export * from "./page";
export * from "./emoji";
export * from "./subscription";
+163
View File
@@ -0,0 +1,163 @@
import { IPaymentProduct, TBillingFrequency, TProductBillingFrequency } from "@plane/types";
/**
* Enum representing different product subscription types
*/
export enum EProductSubscriptionEnum {
FREE = "FREE",
ONE = "ONE",
PRO = "PRO",
BUSINESS = "BUSINESS",
ENTERPRISE = "ENTERPRISE",
}
/**
* Default billing frequency for each product subscription type
*/
export const DEFAULT_PRODUCT_BILLING_FREQUENCY: TProductBillingFrequency = {
[EProductSubscriptionEnum.FREE]: undefined,
[EProductSubscriptionEnum.ONE]: undefined,
[EProductSubscriptionEnum.PRO]: "month",
[EProductSubscriptionEnum.BUSINESS]: "month",
[EProductSubscriptionEnum.ENTERPRISE]: "month",
};
/**
* Subscription types that support billing frequency toggle (monthly/yearly)
*/
export const SUBSCRIPTION_WITH_BILLING_FREQUENCY = [
EProductSubscriptionEnum.PRO,
EProductSubscriptionEnum.BUSINESS,
EProductSubscriptionEnum.ENTERPRISE,
];
/**
* Mapping of product subscription types to their respective payment product details
* Used to provide information about each product's pricing and features
*/
export const PLANE_COMMUNITY_PRODUCTS: Record<string, IPaymentProduct> = {
[EProductSubscriptionEnum.PRO]: {
id: EProductSubscriptionEnum.PRO,
name: "Plane Pro",
description:
"More views, more cycles powers, more pages features, new reports, and better dashboards are waiting to be unlocked.",
type: "PRO",
prices: [
{
id: `price_monthly_${EProductSubscriptionEnum.PRO}`,
unit_amount: 800,
recurring: "month",
currency: "usd",
workspace_amount: 800,
product: EProductSubscriptionEnum.PRO,
},
{
id: `price_yearly_${EProductSubscriptionEnum.PRO}`,
unit_amount: 7200,
recurring: "year",
currency: "usd",
workspace_amount: 7200,
product: EProductSubscriptionEnum.PRO,
},
],
payment_quantity: 1,
is_active: true,
},
[EProductSubscriptionEnum.BUSINESS]: {
id: EProductSubscriptionEnum.BUSINESS,
name: "Plane Business",
description:
"The earliest packaging of Business at $10 a seat a month billed annually, $12 a seat a month billed monthly for Plane Cloud",
type: "BUSINESS",
prices: [
{
id: `price_yearly_${EProductSubscriptionEnum.BUSINESS}`,
unit_amount: 0,
recurring: "year",
currency: "usd",
workspace_amount: 0,
product: EProductSubscriptionEnum.BUSINESS,
},
{
id: `price_monthly_${EProductSubscriptionEnum.BUSINESS}`,
unit_amount: 0,
recurring: "month",
currency: "usd",
workspace_amount: 0,
product: EProductSubscriptionEnum.BUSINESS,
},
],
payment_quantity: 1,
is_active: false,
},
[EProductSubscriptionEnum.ENTERPRISE]: {
id: EProductSubscriptionEnum.ENTERPRISE,
name: "Plane Enterprise",
description: "",
type: "ENTERPRISE",
prices: [
{
id: `price_yearly_${EProductSubscriptionEnum.ENTERPRISE}`,
unit_amount: 0,
recurring: "year",
currency: "usd",
workspace_amount: 0,
product: EProductSubscriptionEnum.ENTERPRISE,
},
{
id: `price_monthly_${EProductSubscriptionEnum.ENTERPRISE}`,
unit_amount: 0,
recurring: "month",
currency: "usd",
workspace_amount: 0,
product: EProductSubscriptionEnum.ENTERPRISE,
},
],
payment_quantity: 1,
is_active: false,
},
};
/**
* URL for the "Talk to Sales" page where users can contact sales team
*/
export const TALK_TO_SALES_URL = "https://plane.so/talk-to-sales";
/**
* Mapping of subscription types to their respective upgrade/redirection URLs based on billing frequency
* Used for self-hosted installations to redirect users to appropriate upgrade pages
*/
export const SUBSCRIPTION_REDIRECTION_URLS: Record<EProductSubscriptionEnum, Record<TBillingFrequency, string>> = {
[EProductSubscriptionEnum.FREE]: {
month: TALK_TO_SALES_URL,
year: TALK_TO_SALES_URL,
},
[EProductSubscriptionEnum.ONE]: {
month: TALK_TO_SALES_URL,
year: TALK_TO_SALES_URL,
},
[EProductSubscriptionEnum.PRO]: {
month: "https://app.plane.so/upgrade/pro/self-hosted?plan=month",
year: "https://app.plane.so/upgrade/pro/self-hosted?plan=year",
},
[EProductSubscriptionEnum.BUSINESS]: {
month: TALK_TO_SALES_URL,
year: TALK_TO_SALES_URL,
},
[EProductSubscriptionEnum.ENTERPRISE]: {
month: TALK_TO_SALES_URL,
year: TALK_TO_SALES_URL,
},
};
/**
* Mapping of subscription types to their respective marketing webpage URLs
* Used to direct users to learn more about each plan's features and pricing
*/
export const SUBSCRIPTION_WEBPAGE_URLS: Record<EProductSubscriptionEnum, string> = {
[EProductSubscriptionEnum.FREE]: TALK_TO_SALES_URL,
[EProductSubscriptionEnum.ONE]: TALK_TO_SALES_URL,
[EProductSubscriptionEnum.PRO]: "https://plane.so/pro",
[EProductSubscriptionEnum.BUSINESS]: "https://plane.so/business",
[EProductSubscriptionEnum.ENTERPRISE]: "https://plane.so/business",
};
+42
View File
@@ -0,0 +1,42 @@
export const ENTERPRISE_PLAN_FEATURES = [
"Private + managed deployments",
"GAC",
"LDAP support",
"Databases + Formulas",
"Unlimited and full Automation Flows",
"Full-suite professional services",
];
export const BUSINESS_PLAN_FEATURES = [
"Project Templates",
"Workflows + Approvals",
"Decision + Loops Automation",
"Custom Reports",
"Nested Pages",
"Intake Forms",
];
export const PRO_PLAN_FEATURES = [
"Dashboards + Reports",
"Full Time Tracking + Bulk Ops",
"Teamspaces",
"Trigger And Action",
"Wikis",
"Popular integrations",
];
export const ONE_PLAN_FEATURES = [
"OIDC + SAML for SSO",
"Active Cycles",
"Real-time collab + public views and page",
"Link pages in issues and vice-versa",
"Time-tracking + limited bulk ops",
"Docker, Kubernetes and more",
];
export const FREE_PLAN_UPGRADE_FEATURES = [
"OIDC + SAML for SSO",
"Time Tracking and Bulk Ops",
"Integrations",
"Public Views and Pages",
];
+1 -1
View File
@@ -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);
},
};
},
+12 -12
View File
@@ -151,11 +151,11 @@
/* end font size and style */
/* layout config */
#page-header-container {
container-name: page-header-container;
#page-toolbar-container {
container-name: page-toolbar-container;
container-type: inline-size;
.page-header-content {
.page-toolbar-content {
--header-width: var(--normal-content-width);
&.wide-layout {
@@ -186,23 +186,23 @@
}
/* keep a static padding of 96px for wide layouts for container width >912px and <1344px */
@container page-header-container (min-width: 912px) and (max-width: 1344px) {
.page-header-content.wide-layout {
@container page-toolbar-container (min-width: 912px) and (max-width: 1344px) {
.page-toolbar-content.wide-layout {
padding-left: var(--wide-content-margin) !important;
}
}
/* keep a static padding of 96px for wide layouts for container width <912px */
@container page-header-container (max-width: 912) {
.page-header-content.wide-layout {
@container page-toolbar-container (max-width: 912) {
.page-toolbar-content.wide-layout {
padding-left: var(--wide-content-margin) !important;
}
}
/* end layout config */
/* keep a static padding of 20px for wide layouts for container width <760px */
@container page-header-container (max-width: 760px) {
.page-header-content {
@container page-toolbar-container (max-width: 760px) {
.page-toolbar-content {
padding-left: var(--normal-content-margin) !important;
}
}
@@ -211,7 +211,7 @@
/* keep a static padding of 96px for wide layouts for container width >912px and <1344px */
@container page-content-container (min-width: 912px) and (max-width: 1344px) {
.editor-container.wide-layout,
.page-title-container {
.page-header-container {
padding-left: var(--wide-content-margin);
padding-right: var(--wide-content-margin);
}
@@ -220,7 +220,7 @@
/* keep a static padding of 20px for wide layouts for container width <912px */
@container page-content-container (max-width: 912px) {
.editor-container.wide-layout,
.page-title-container {
.page-header-container {
padding-left: var(--normal-content-margin);
padding-right: var(--normal-content-margin);
}
@@ -229,7 +229,7 @@
/* keep a static padding of 20px for normal layouts for container width <760px */
@container page-content-container (max-width: 760px) {
.editor-container:not(.wide-layout),
.page-title-container {
.page-header-container {
padding-left: var(--normal-content-margin);
padding-right: var(--normal-content-margin);
}
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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,
+1
View File
@@ -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";
+26 -3
View File
@@ -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": {
+26 -3
View File
@@ -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": {
+26 -3
View File
@@ -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": {
+26 -3
View File
@@ -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": {
+26 -3
View File
@@ -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": {
+26 -3
View File
@@ -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": {
+26 -3
View File
@@ -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": {
+26 -3
View File
@@ -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": {
+26 -3
View File
@@ -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": {
+26 -3
View File
@@ -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": {
+26 -3
View File
@@ -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": {
+26 -3
View File
@@ -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": {
+26 -3
View File
@@ -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
+26 -3
View File
@@ -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": {
+2
View File
@@ -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}`);
}
+2 -1
View File
@@ -16,7 +16,8 @@ export type TLanguage =
| "pt-BR"
| "id"
| "ro"
| "vi-VN";
| "vi-VN"
| "tr-TR";
export interface ILanguageOption {
label: string;
+1 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/propel",
"version": "0.25.3",
"version": "0.26.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
+4 -7
View File
@@ -14,10 +14,7 @@ export interface IEstimatePoint {
updated_by: string | undefined;
}
export type TEstimateSystemKeys =
| EEstimateSystem.POINTS
| EEstimateSystem.CATEGORIES
| EEstimateSystem.TIME;
export type TEstimateSystemKeys = EEstimateSystem.POINTS | EEstimateSystem.CATEGORIES | EEstimateSystem.TIME;
export interface IEstimate {
id: string | undefined;
@@ -55,12 +52,14 @@ export type TEstimatePointsObject = {
export type TTemplateValues = {
title: string;
i18n_title: string;
values: TEstimatePointsObject[];
hide?: boolean;
};
export type TEstimateSystem = {
name: string;
i18n_name: string;
templates: Record<string, TTemplateValues>;
is_available: boolean;
is_ee: boolean;
@@ -82,6 +81,4 @@ export type TEstimateTypeErrorObject = {
message: string | undefined;
};
export type TEstimateTypeError =
| Record<number, TEstimateTypeErrorObject>
| undefined;
export type TEstimateTypeError = Record<number, TEstimateTypeErrorObject> | undefined;
+1
View File
@@ -35,6 +35,7 @@ export type TIssueEntityData = {
sequence_id: number;
project_id: string;
project_identifier: string;
is_epic: boolean;
};
export type TActivityEntityData = {
+1
View File
@@ -42,3 +42,4 @@ export * from "./charts";
export * from "./home";
export * from "./stickies";
export * from "./utils";
export * from "./payment";
+4 -1
View File
@@ -1,3 +1,6 @@
// plane imports
import { EInboxIssueSource } from "@plane/constants";
// local imports
import {
TIssueActivityWorkspaceDetail,
TIssueActivityProjectDetail,
@@ -31,7 +34,7 @@ export type TIssueActivity = {
epoch: number;
issue_comment: string | null;
source_data: {
source: "IN_APP" | "FORM" | "EMAIL";
source: EInboxIssueSource;
source_email?: string;
extra: {
username?: string;
+1 -1
View File
@@ -40,7 +40,7 @@ export type TIssueComment = {
};
export type TCommentsOperations = {
createComment: (data: Partial<TIssueComment>) => Promise<void>;
createComment: (data: Partial<TIssueComment>) => Promise<Partial<TIssueComment> | undefined>;
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
removeComment: (commentId: string) => Promise<void>;
uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
+36
View File
@@ -0,0 +1,36 @@
import { EProductSubscriptionEnum } from "@plane/constants";
export type TBillingFrequency = "month" | "year";
export type IPaymentProductPrice = {
currency: string;
id: string;
product: string;
recurring: TBillingFrequency;
unit_amount: number;
workspace_amount: number;
};
export type TProductSubscriptionType = "FREE" | "ONE" | "PRO" | "BUSINESS" | "ENTERPRISE";
export type IPaymentProduct = {
description: string;
id: string;
name: string;
type: Omit<TProductSubscriptionType, "FREE">;
payment_quantity: number;
prices: IPaymentProductPrice[];
is_active: boolean;
};
export type TSubscriptionPrice = {
key: string;
id: string | undefined;
currency: string;
price: number;
recurring: TBillingFrequency;
};
export type TProductBillingFrequency = {
[key in EProductSubscriptionEnum]: TBillingFrequency | undefined;
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "0.25.3",
"version": "0.26.0",
"license": "AGPL-3.0",
"private": true,
"files": [
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -42,7 +42,7 @@ export const DropdownOptions: React.FC<IMultiSelectDropdownOptions | ISingleSele
isMobile={isMobile}
/>
)}
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
<div className={cn("max-h-48 space-y-1 overflow-y-scroll", !disableSearch && "mt-2")}>
<>
{options ? (
options.length > 0 ? (
@@ -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;
+4 -4
View File
@@ -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";
+1
View File
@@ -32,3 +32,4 @@ export * from "./tag";
export * from "./tabs";
export * from "./calendar";
export * from "./color-picker";
export * from "./link";
+69
View File
@@ -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>
);
};
+1
View File
@@ -0,0 +1 @@
export * from "./block";
+1 -1
View File
@@ -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,
+64
View File
@@ -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;
};
+5
View File
@@ -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";
+106
View File
@@ -0,0 +1,106 @@
import orderBy from "lodash/orderBy";
// plane imports
import { EProductSubscriptionEnum } from "@plane/constants";
import { IPaymentProduct, TProductSubscriptionType, TSubscriptionPrice } from "@plane/types";
/**
* Calculates the yearly discount percentage when switching from monthly to yearly billing
* @param monthlyPrice - The monthly subscription price
* @param yearlyPricePerMonth - The monthly equivalent price when billed yearly
* @returns The discount percentage as a whole number (floored)
*/
export const calculateYearlyDiscount = (monthlyPrice: number, yearlyPricePerMonth: number): number => {
const monthlyCost = monthlyPrice * 12;
const yearlyCost = yearlyPricePerMonth * 12;
const amountSaved = monthlyCost - yearlyCost;
const discountPercentage = (amountSaved / monthlyCost) * 100;
return Math.floor(discountPercentage);
};
/**
* Gets the display name for a subscription plan variant
* @param planVariant - The subscription plan variant enum
* @returns The human-readable name of the plan
*/
export const getSubscriptionName = (planVariant: EProductSubscriptionEnum): string => {
switch (planVariant) {
case EProductSubscriptionEnum.FREE:
return "Free";
case EProductSubscriptionEnum.ONE:
return "One";
case EProductSubscriptionEnum.PRO:
return "Pro";
case EProductSubscriptionEnum.BUSINESS:
return "Business";
case EProductSubscriptionEnum.ENTERPRISE:
return "Enterprise";
default:
return "--";
}
};
/**
* Gets the base subscription name for upgrade/downgrade paths
* @param planVariant - The current subscription plan variant
* @param isSelfHosted - Whether the instance is self-hosted / community
* @returns The name of the base subscription plan
*
* @remarks
* - For self-hosted / community instances, the upgrade path differs from cloud instances
* - Returns the immediate lower tier subscription name
*/
export const getBaseSubscriptionName = (planVariant: TProductSubscriptionType, isSelfHosted: boolean): string => {
switch (planVariant) {
case EProductSubscriptionEnum.ONE:
return getSubscriptionName(EProductSubscriptionEnum.FREE);
case EProductSubscriptionEnum.PRO:
return isSelfHosted
? getSubscriptionName(EProductSubscriptionEnum.ONE)
: getSubscriptionName(EProductSubscriptionEnum.FREE);
case EProductSubscriptionEnum.BUSINESS:
return getSubscriptionName(EProductSubscriptionEnum.PRO);
case EProductSubscriptionEnum.ENTERPRISE:
return getSubscriptionName(EProductSubscriptionEnum.BUSINESS);
default:
return "--";
}
};
export type TSubscriptionPriceDetail = {
monthlyPriceDetails: TSubscriptionPrice;
yearlyPriceDetails: TSubscriptionPrice;
};
/**
* Gets the price details for a subscription product
* @param product - The payment product to get price details for
* @returns Array of price details for monthly and yearly plans
*/
export const getSubscriptionPriceDetails = (product: IPaymentProduct | undefined): TSubscriptionPriceDetail => {
const productPrices = product?.prices || [];
const monthlyPriceDetails = orderBy(productPrices, ["recurring"], ["desc"])?.find(
(price) => price.recurring === "month"
);
const monthlyPriceAmount = Number(((monthlyPriceDetails?.unit_amount || 0) / 100).toFixed(2));
const yearlyPriceDetails = orderBy(productPrices, ["recurring"], ["desc"])?.find(
(price) => price.recurring === "year"
);
const yearlyPriceAmount = Number(((yearlyPriceDetails?.unit_amount || 0) / 1200).toFixed(2));
return {
monthlyPriceDetails: {
key: "monthly",
id: monthlyPriceDetails?.id,
currency: "$",
price: monthlyPriceAmount,
recurring: "month",
},
yearlyPriceDetails: {
key: "yearly",
id: yearlyPriceDetails?.id,
currency: "$",
price: yearlyPriceAmount,
recurring: "year",
},
};
};
+83 -9
View File
@@ -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
View File
@@ -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