Compare commits

..

1 Commits

Author SHA1 Message Date
sriram veeraghanta 5f5ab2b75d fix: moving nginx to caddy 2025-03-09 16:19:09 +05:30
302 changed files with 2829 additions and 19442 deletions
-6
View File
@@ -38,9 +38,3 @@ USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# Force HTTPS for handling SSL Termination
MINIO_ENDPOINT_SSL=0
# API key rate limit
API_KEY_RATE_LIMIT="60/minute"
+50
View File
@@ -47,6 +47,12 @@ jobs:
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }}
build_apiserver: ${{ steps.changed_files.outputs.apiserver_any_changed }}
build_admin: ${{ steps.changed_files.outputs.admin_any_changed }}
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
build_web: ${{ steps.changed_files.outputs.web_any_changed }}
build_live: ${{ steps.changed_files.outputs.live_any_changed }}
dh_img_web: ${{ steps.set_env_variables.outputs.DH_IMG_WEB }}
dh_img_space: ${{ steps.set_env_variables.outputs.DH_IMG_SPACE }}
@@ -117,7 +123,46 @@ jobs:
name: Checkout Files
uses: actions/checkout@v4
- name: Get changed files
id: changed_files
uses: tj-actions/changed-files@v42
with:
files_yaml: |
apiserver:
- apiserver/**
proxy:
- nginx/**
admin:
- admin/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
space:
- space/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
web:
- web/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
live:
- live/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
branch_build_push_admin:
if: ${{ needs.branch_build_setup.outputs.build_admin == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Admin Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
@@ -140,6 +185,7 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_web:
if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Web Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
@@ -162,6 +208,7 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_space:
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Space Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
@@ -184,6 +231,7 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_live:
if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Live Collaboration Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
@@ -206,6 +254,7 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_apiserver:
if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push API Server Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
@@ -228,6 +277,7 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_proxy:
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Proxy Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
+47 -4
View File
@@ -6,9 +6,49 @@ on:
types: ["opened", "synchronize", "ready_for_review"]
jobs:
lint-apiserver:
get-changed-files:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
outputs:
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
admin_changed: ${{ steps.changed-files.outputs.admin_any_changed }}
space_changed: ${{ steps.changed-files.outputs.space_any_changed }}
web_changed: ${{ steps.changed-files.outputs.web_any_changed }}
steps:
- uses: actions/checkout@v4
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v44
with:
files_yaml: |
apiserver:
- apiserver/**
admin:
- admin/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
space:
- space/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
web:
- web/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
lint-apiserver:
needs: get-changed-files
runs-on: ubuntu-latest
if: needs.get-changed-files.outputs.apiserver_changed == 'true'
steps:
- uses: actions/checkout@v4
- name: Set up Python
@@ -23,7 +63,8 @@ jobs:
run: ruff check --fix apiserver
lint-admin:
if: github.event.pull_request.draft == false
needs: get-changed-files
if: needs.get-changed-files.outputs.admin_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -35,7 +76,8 @@ jobs:
- run: yarn lint --filter=admin
lint-space:
if: github.event.pull_request.draft == false
needs: get-changed-files
if: needs.get-changed-files.outputs.space_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -47,7 +89,8 @@ jobs:
- run: yarn lint --filter=space
lint-web:
if: github.event.pull_request.draft == false
needs: get-changed-files
if: needs.get-changed-files.outputs.web_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+3
View File
@@ -43,6 +43,9 @@ NGINX_PORT=80
# Debug value for api server use it as 0 for production use
DEBUG=0
CORS_ALLOWED_ORIGINS="http://localhost"
# Error logs
SENTRY_DSN=""
SENTRY_ENVIRONMENT="development"
# Database Settings
POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
-15
View File
@@ -43,7 +43,6 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
defaultValues: {
GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"],
GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"],
GITHUB_ORGANIZATION_ID: config["GITHUB_ORGANIZATION_ID"],
},
});
@@ -94,19 +93,6 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
error: Boolean(errors.GITHUB_CLIENT_SECRET),
required: true,
},
{
key: "GITHUB_ORGANIZATION_ID",
type: "text",
label: "Organization ID",
description: (
<>
The organization github ID.
</>
),
placeholder: "123456789",
error: Boolean(errors.GITHUB_ORGANIZATION_ID),
required: false,
},
];
const GITHUB_SERVICE_FIELD: TCopyField[] = [
@@ -164,7 +150,6 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
reset({
GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value,
GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value,
GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value,
});
})
.catch((err) => console.error(err));
-13
View File
@@ -9,19 +9,6 @@ const nextConfig = {
unoptimized: true,
},
basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "",
transpilePackages: [
"@plane/constants",
"@plane/editor",
"@plane/hooks",
"@plane/i18n",
"@plane/logger",
"@plane/propel",
"@plane/services",
"@plane/shared-state",
"@plane/types",
"@plane/ui",
"@plane/utils",
],
};
module.exports = nextConfig;
+4 -5
View File
@@ -1,8 +1,6 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "0.25.3",
"license": "AGPL-3.0",
"version": "0.25.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -21,15 +19,16 @@
"@plane/ui": "*",
"@plane/utils": "*",
"@plane/services": "*",
"@sentry/nextjs": "^8.54.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
"axios": "^1.8.3",
"axios": "^1.7.9",
"lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.25",
"next": "^14.2.20",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
+3
View File
@@ -145,8 +145,11 @@ RUN chmod +x /app/pg-setup.sh
# APPLICATION ENVIRONMENT SETTINGS
# *****************************************************************************
ENV APP_DOMAIN=localhost
ENV WEB_URL=http://${APP_DOMAIN}
ENV DEBUG=0
ENV SENTRY_DSN=
ENV SENTRY_ENVIRONMENT=production
ENV CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN},https://${APP_DOMAIN}
# Secret Key
ENV SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
+5 -7
View File
@@ -3,6 +3,10 @@
DEBUG=0
CORS_ALLOWED_ORIGINS="http://localhost"
# Error logs
SENTRY_DSN=""
SENTRY_ENVIRONMENT="development"
# Database Settings
POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
@@ -55,10 +59,4 @@ APP_BASE_URL=
# Hard delete files after days
HARD_DELETE_AFTER_DAYS=60
# Force HTTPS for handling SSL Termination
MINIO_ENDPOINT_SSL=0
# API key rate limit
API_KEY_RATE_LIMIT="60/minute"
HARD_DELETE_AFTER_DAYS=60
+1 -4
View File
@@ -1,7 +1,4 @@
{
"name": "plane-api",
"version": "0.25.3",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
"version": "0.25.0"
}
+1 -5
View File
@@ -1,13 +1,9 @@
# python imports
import os
# Third party imports
from rest_framework.throttling import SimpleRateThrottle
class ApiKeyRateThrottle(SimpleRateThrottle):
scope = "api_key"
rate = os.environ.get("API_KEY_RATE_LIMIT", "60/minute")
rate = "60/minute"
def get_cache_key(self, request, view):
# Retrieve the API key from the request header
-9
View File
@@ -1,5 +1,4 @@
# Third party imports
import pytz
from rest_framework import serializers
# Module imports
@@ -19,14 +18,6 @@ class CycleSerializer(BaseSerializer):
completed_estimates = serializers.FloatField(read_only=True)
started_estimates = serializers.FloatField(read_only=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
project = self.context.get("project")
if project and project.timezone:
project_timezone = pytz.timezone(project.timezone)
self.fields["start_date"].timezone = project_timezone
self.fields["end_date"].timezone = project_timezone
def validate(self, data):
if (
data.get("start_date", None) is not None
+2 -8
View File
@@ -80,7 +80,6 @@ class IssueSerializer(BaseSerializer):
data["assignees"] = ProjectMember.objects.filter(
project_id=self.context.get("project_id"),
is_active=True,
role__gte=15,
member_id__in=data["assignees"],
).values_list("member_id", flat=True)
@@ -159,13 +158,8 @@ class IssueSerializer(BaseSerializer):
pass
else:
try:
# Then assign it to default assignee, if it is a valid assignee
if default_assignee_id is not None and ProjectMember.objects.filter(
member_id=default_assignee_id,
project_id=project_id,
role__gte=15,
is_active=True
).exists():
# Then assign it to default assignee
if default_assignee_id is not None:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
+7 -15
View File
@@ -137,12 +137,10 @@ class CycleAPIEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id, pk=None):
project = Project.objects.get(workspace__slug=slug, pk=project_id)
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
).data
return Response(data, status=status.HTTP_200_OK)
queryset = self.get_queryset().filter(archived_at__isnull=True)
@@ -154,8 +152,7 @@ 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
).data
return Response(data, status=status.HTTP_200_OK)
@@ -166,8 +163,7 @@ 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
).data,
)
@@ -178,8 +174,7 @@ 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
).data,
)
@@ -190,8 +185,7 @@ 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
).data,
)
@@ -204,16 +198,14 @@ 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
).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
).data,
)
+11 -43
View File
@@ -36,7 +36,6 @@ from plane.db.models import (
State,
IssueVersion,
IssueDescriptionVersion,
ProjectMember,
)
@@ -111,23 +110,14 @@ class IssueCreateSerializer(BaseSerializer):
data["label_ids"] = label_ids if label_ids else []
return data
def validate(self, attrs):
def validate(self, data):
if (
attrs.get("start_date", None) is not None
and attrs.get("target_date", None) is not None
and attrs.get("start_date", None) > attrs.get("target_date", None)
data.get("start_date", None) is not None
and data.get("target_date", None) is not None
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
if attrs.get("assignee_ids", []):
attrs["assignee_ids"] = ProjectMember.objects.filter(
project_id=self.context["project_id"],
role__gte=15,
is_active=True,
member_id__in=attrs["assignee_ids"],
).values_list("member_id", flat=True)
return attrs
return data
def create(self, validated_data):
assignees = validated_data.pop("assignee_ids", None)
@@ -149,30 +139,22 @@ class IssueCreateSerializer(BaseSerializer):
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
assignee=user,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
for user in assignees
],
batch_size=10,
)
except IntegrityError:
pass
else:
# Then assign it to default assignee, if it is a valid assignee
if (
default_assignee_id is not None
and ProjectMember.objects.filter(
member_id=default_assignee_id,
project_id=project_id,
role__gte=15,
is_active=True,
).exists()
):
# Then assign it to default assignee
if default_assignee_id is not None:
try:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
@@ -222,14 +204,14 @@ class IssueCreateSerializer(BaseSerializer):
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
assignee=user,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
for user in assignees
],
batch_size=10,
ignore_conflicts=True,
@@ -268,20 +250,6 @@ class IssueActivitySerializer(BaseSerializer):
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
source_data = serializers.SerializerMethodField()
def get_source_data(self, obj):
if (
hasattr(obj, "issue")
and hasattr(obj.issue, "source_data")
and obj.issue.source_data
):
return {
"source": obj.issue.source_data[0].source,
"source_email": obj.issue.source_data[0].source_email,
"extra": obj.issue.source_data[0].extra,
}
return None
class Meta:
model = IssueActivity
+6 -15
View File
@@ -268,7 +268,7 @@ class CycleViewSet(BaseViewSet):
)
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(
data, datetime_fields, project_timezone
data, datetime_fields, request.user.user_timezone
)
return Response(data, status=status.HTTP_200_OK)
@@ -318,13 +318,9 @@ class CycleViewSet(BaseViewSet):
.first()
)
# Fetch the project timezone
project = Project.objects.get(id=self.kwargs.get("project_id"))
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(
cycle, datetime_fields, project_timezone
cycle, datetime_fields, request.user.user_timezone
)
# Send the model activity
@@ -411,13 +407,9 @@ class CycleViewSet(BaseViewSet):
"created_by",
).first()
# Fetch the project timezone
project = Project.objects.get(id=self.kwargs.get("project_id"))
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(
cycle, datetime_fields, project_timezone
cycle, datetime_fields, request.user.user_timezone
)
# Send the model activity
@@ -488,11 +480,10 @@ class CycleViewSet(BaseViewSet):
)
queryset = queryset.first()
# Fetch the project timezone
project = Project.objects.get(id=self.kwargs.get("project_id"))
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(data, datetime_fields, project_timezone)
data = user_timezone_converter(
data, datetime_fields, request.user.user_timezone
)
recent_visited_task.delay(
slug=slug,
+2 -4
View File
@@ -178,9 +178,7 @@ class IntakeIssueViewSet(BaseViewSet):
workspace__slug=slug, project_id=project_id
).first()
if not intake:
return Response(
{"error": "Intake not found"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Intake not found"}, status=status.HTTP_404_NOT_FOUND)
project = Project.objects.get(pk=project_id)
filters = issue_filters(request.GET, "GET", "issue__")
@@ -387,7 +385,7 @@ class IntakeIssueViewSet(BaseViewSet):
}
issue_serializer = IssueCreateSerializer(
issue, data=issue_data, partial=True, context={"project_id": project_id}
issue, data=issue_data, partial=True
)
if issue_serializer.is_valid():
+3 -12
View File
@@ -14,7 +14,7 @@ from rest_framework import status
from .. import BaseAPIView
from plane.app.serializers import IssueActivitySerializer, IssueCommentSerializer
from plane.app.permissions import ProjectEntityPermission, allow_permission, ROLE
from plane.db.models import IssueActivity, IssueComment, CommentReaction, IntakeIssue
from plane.db.models import IssueActivity, IssueComment, CommentReaction
class IssueActivityEndpoint(BaseAPIView):
@@ -57,22 +57,13 @@ class IssueActivityEndpoint(BaseAPIView):
)
)
)
issue_activities = IssueActivitySerializer(issue_activities, many=True).data
issue_comments = IssueCommentSerializer(issue_comments, many=True).data
if request.GET.get("activity_type", None) == "issue-property":
issue_activities = issue_activities.prefetch_related(
Prefetch(
"issue__issue_intake",
queryset=IntakeIssue.objects.only(
"source_email", "source", "extra"
),
to_attr="source_data",
)
)
issue_activities = IssueActivitySerializer(issue_activities, many=True).data
return Response(issue_activities, status=status.HTTP_200_OK)
if request.GET.get("activity_type", None) == "issue-comment":
issue_comments = IssueCommentSerializer(issue_comments, many=True).data
return Response(issue_comments, status=status.HTTP_200_OK)
result_list = sorted(
+9 -15
View File
@@ -45,7 +45,6 @@ from plane.db.models import (
ProjectMember,
CycleIssue,
UserRecentVisit,
ModuleIssue,
)
from plane.utils.grouper import (
issue_group_values,
@@ -636,9 +635,7 @@ class IssueViewSet(BaseViewSet):
)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
serializer = IssueCreateSerializer(
issue, data=request.data, partial=True, context={"project_id": project_id}
)
serializer = IssueCreateSerializer(issue, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
@@ -739,13 +736,6 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
total_issues = len(issues)
# First, delete all related cycle issues
CycleIssue.objects.filter(issue_id__in=issue_ids).delete()
# Then, delete all related module issues
ModuleIssue.objects.filter(issue_id__in=issue_ids).delete()
# Finally, delete the issues themselves
issues.delete()
return Response(
@@ -1109,6 +1099,7 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView):
class IssueMetaEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")
def get(self, request, slug, project_id, issue_id):
issue = Issue.issue_objects.only("sequence_id", "project__identifier").get(
@@ -1124,12 +1115,14 @@ class IssueMetaEndpoint(BaseAPIView):
class IssueDetailIdentifierEndpoint(BaseAPIView):
def strict_str_to_int(self, s):
if not s.isdigit() and not (s.startswith("-") and s[1:].isdigit()):
if not s.isdigit() and not (s.startswith('-') and s[1:].isdigit()):
raise ValueError("Invalid integer string")
return int(s)
def get(self, request, slug, project_identifier, issue_identifier):
# Check if the issue identifier is a valid integer
try:
issue_identifier = self.strict_str_to_int(issue_identifier)
@@ -1141,7 +1134,8 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
# Fetch the project
project = Project.objects.get(
identifier__iexact=project_identifier, workspace__slug=slug
identifier__iexact=project_identifier,
workspace__slug=slug,
)
# Check if the user is a member of the project
@@ -1243,8 +1237,8 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project.id,
workspace__slug=slug,
project_id=project.id,
issue__sequence_id=issue_identifier,
subscriber=request.user,
)
@@ -177,9 +177,7 @@ class ProjectViewSet(BaseViewSet):
"module_view",
"page_view",
"inbox_view",
"guest_view_all_features",
"project_lead",
"network",
"created_at",
"updated_at",
"created_by",
+7 -17
View File
@@ -16,17 +16,17 @@ from rest_framework.permissions import AllowAny
# Module imports
from .base import BaseViewSet, BaseAPIView
from plane.app.serializers import ProjectMemberInviteSerializer
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import (
ProjectMember,
Workspace,
ProjectMemberInvite,
User,
WorkspaceMember,
Project,
IssueUserProperty,
)
from plane.db.models.project import ProjectNetwork
class ProjectInvitationsViewset(BaseViewSet):
@@ -128,7 +128,6 @@ class UserProjectInvitationsViewset(BaseViewSet):
.select_related("workspace", "workspace__owner", "project")
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def create(self, request, slug):
project_ids = request.data.get("project_ids", [])
@@ -137,20 +136,11 @@ class UserProjectInvitationsViewset(BaseViewSet):
member=request.user, workspace__slug=slug, is_active=True
)
# Get all the projects
projects = Project.objects.filter(
id__in=project_ids, workspace__slug=slug
).only("id", "network")
# Check if user has permission to join each project
for project in projects:
if (
project.network == ProjectNetwork.SECRET.value
and workspace_member.role != ROLE.ADMIN.value
):
return Response(
{"error": "Only workspace admins can join private project"},
status=status.HTTP_403_FORBIDDEN,
)
if workspace_member.role not in [ROLE.ADMIN.value, ROLE.MEMBER.value]:
return Response(
{"error": "You do not have permission to join the project"},
status=status.HTTP_403_FORBIDDEN,
)
workspace_role = workspace_member.role
workspace = workspace_member.workspace
+18 -12
View File
@@ -10,7 +10,11 @@ from plane.app.serializers import (
ProjectMemberRoleSerializer,
)
from plane.app.permissions import WorkspaceUserPermission
from plane.app.permissions import (
ProjectMemberPermission,
ProjectLitePermission,
WorkspaceUserPermission,
)
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
from plane.bgtasks.project_add_user_email_task import project_add_user_email
@@ -22,6 +26,14 @@ class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberAdminSerializer
model = ProjectMember
def get_permissions(self):
if self.action == "leave":
self.permission_classes = [ProjectLitePermission]
else:
self.permission_classes = [ProjectMemberPermission]
return super(ProjectMemberViewSet, self).get_permissions()
search_fields = ["member__display_name", "member__first_name"]
def get_queryset(self):
@@ -175,20 +187,12 @@ class ProjectMemberViewSet(BaseViewSet):
)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN])
def partial_update(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, is_active=True
)
# Fetch the workspace role of the project member
workspace_role = WorkspaceMember.objects.get(
workspace__slug=slug, member=project_member.member, is_active=True
).role
is_workspace_admin = workspace_role == ROLE.ADMIN.value
# Check if the user is not editing their own role if they are not an admin
if request.user.id == project_member.member_id and not is_workspace_admin:
if request.user.id == project_member.member_id:
return Response(
{"error": "You cannot update your own role"},
status=status.HTTP_400_BAD_REQUEST,
@@ -201,6 +205,9 @@ class ProjectMemberViewSet(BaseViewSet):
is_active=True,
)
workspace_role = WorkspaceMember.objects.get(
workspace__slug=slug, member=project_member.member, is_active=True
).role
if workspace_role in [5] and int(
request.data.get("role", project_member.role)
) in [15, 20]:
@@ -215,7 +222,6 @@ class ProjectMemberViewSet(BaseViewSet):
"role" in request.data
and int(request.data.get("role", project_member.role))
> requested_project_member.role
and not is_workspace_admin
):
return Response(
{"error": "You cannot update a role that is higher than your own role"},
+1 -1
View File
@@ -117,7 +117,7 @@ class WorkspaceViewViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN], level="WORKSPACE", creator=True, model=IssueView
allowed_roles=[], level="WORKSPACE", creator=True, model=IssueView
)
def destroy(self, request, slug, pk):
workspace_view = IssueView.objects.get(pk=pk, workspace__slug=slug)
@@ -34,22 +34,6 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
def post(self, request, slug):
try:
workspace = Workspace.objects.get(slug=slug)
# If the favorite exists return
if request.data.get("entity_identifier"):
user_favorites = UserFavorite.objects.filter(
workspace=workspace,
user_id=request.user.id,
entity_type=request.data.get("entity_type"),
entity_identifier=request.data.get("entity_identifier"),
).first()
# If the favorite exists return
if user_favorites:
serializer = UserFavoriteSerializer(user_favorites)
return Response(serializer.data, status=status.HTTP_200_OK)
# else create a new favorite
serializer = UserFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
@@ -68,11 +68,10 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
# If a user is moved to a guest role he can't have any other role in projects
if "role" in request.data and int(request.data.get("role")) == 5:
ProjectMember.objects.filter(
if workspace_member.role > int(request.data.get("role")):
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id
).update(role=5)
).update(role=int(request.data.get("role")))
serializer = WorkSpaceMemberSerializer(
workspace_member, data=request.data, partial=True
@@ -36,12 +36,10 @@ AUTHENTICATION_ERROR_CODES = {
"OAUTH_NOT_CONFIGURED": 5104,
"GOOGLE_NOT_CONFIGURED": 5105,
"GITHUB_NOT_CONFIGURED": 5110,
"GITHUB_USER_NOT_IN_ORG": 5122,
"GITLAB_NOT_CONFIGURED": 5111,
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
"GITLAB_OAUTH_PROVIDER_ERROR": 5121,
# Reset Password
"INVALID_PASSWORD_TOKEN": 5125,
"EXPIRED_PASSWORD_TOKEN": 5130,
@@ -18,16 +18,11 @@ from plane.authentication.adapter.error import (
class GitHubOAuthProvider(OauthAdapter):
token_url = "https://github.com/login/oauth/access_token"
userinfo_url = "https://api.github.com/user"
org_membership_url = f"https://api.github.com/orgs"
provider = "github"
scope = "read:user user:email"
organization_scope = "read:org"
def __init__(self, request, code=None, state=None, callback=None):
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value(
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = get_configuration_value(
[
{
"key": "GITHUB_CLIENT_ID",
@@ -37,10 +32,6 @@ class GitHubOAuthProvider(OauthAdapter):
"key": "GITHUB_CLIENT_SECRET",
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
},
{
"key": "GITHUB_ORGANIZATION_ID",
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
},
]
)
@@ -52,10 +43,6 @@ class GitHubOAuthProvider(OauthAdapter):
client_id = GITHUB_CLIENT_ID
client_secret = GITHUB_CLIENT_SECRET
self.organization_id = GITHUB_ORGANIZATION_ID
if self.organization_id:
self.scope += f" {self.organization_scope}"
redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/github/callback/"""
url_params = {
@@ -126,26 +113,12 @@ class GitHubOAuthProvider(OauthAdapter):
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
def is_user_in_organization(self, github_username):
headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"}
response = requests.get(f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}", headers=headers)
return response.status_code == 200 # 200 means the user is a member
def set_user_data(self):
user_info_response = self.get_user_response()
headers = {
"Authorization": f"Bearer {self.token_data.get('access_token')}",
"Accept": "application/json",
}
if self.organization_id:
if not self.is_user_in_organization(user_info_response.get("login")):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_USER_NOT_IN_ORG"],
error_message="GITHUB_USER_NOT_IN_ORG",
)
email = self.__get_email(headers=headers)
super().set_user_data(
{
+17 -18
View File
@@ -15,35 +15,34 @@ app = Celery("plane")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.conf.beat_schedule = {
# Intra day recurring jobs
"check-every-five-minutes-to-send-email-notifications": {
"task": "plane.bgtasks.email_notification_task.stack_email_notification",
"schedule": crontab(minute="*/5"), # Every 5 minutes
},
"run-every-6-hours-for-instance-trace": {
"task": "plane.license.bgtasks.tracer.instance_traces",
"schedule": crontab(hour="*/6", minute=0), # Every 6 hours
},
# Occurs once every day
"check-every-day-to-delete-hard-delete": {
"task": "plane.bgtasks.deletion_task.hard_delete",
"schedule": crontab(hour=0, minute=0), # UTC 00:00
},
# Executes every day at 12 AM
"check-every-day-to-archive-and-close": {
"task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues",
"schedule": crontab(hour=1, minute=0), # UTC 01:00
"schedule": crontab(hour=0, minute=0),
},
"check-every-day-to-delete_exporter_history": {
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
"schedule": crontab(hour=1, minute=30), # UTC 01:30
"schedule": crontab(hour=0, minute=0),
},
"check-every-day-to-delete-file-asset": {
"task": "plane.bgtasks.file_asset_task.delete_unuploaded_file_asset",
"schedule": crontab(hour=2, minute=0), # UTC 02:00
"schedule": crontab(hour=0, minute=0),
},
"check-every-five-minutes-to-send-email-notifications": {
"task": "plane.bgtasks.email_notification_task.stack_email_notification",
"schedule": crontab(minute="*/5"),
},
"check-every-day-to-delete-hard-delete": {
"task": "plane.bgtasks.deletion_task.hard_delete",
"schedule": crontab(hour=0, minute=0),
},
"check-every-day-to-delete-api-logs": {
"task": "plane.bgtasks.api_logs_task.delete_api_logs",
"schedule": crontab(hour=2, minute=30), # UTC 02:30
"schedule": crontab(hour=0, minute=0),
},
"run-every-6-hours-for-instance-trace": {
"task": "plane.license.bgtasks.tracer.instance_traces",
"schedule": crontab(hour="*/6", minute=0),
},
}
@@ -3,6 +3,7 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
from sentry_sdk import capture_exception
import uuid
@@ -28,6 +29,7 @@ def create_issue_relation(apps, schema_editor):
)
except Exception as e:
print(e)
capture_exception(e)
def update_issue_priority_choice(apps, schema_editor):
-10
View File
@@ -1,7 +1,6 @@
# Python imports
import pytz
from uuid import uuid4
from enum import Enum
# Django imports
from django.conf import settings
@@ -18,15 +17,6 @@ from .base import BaseModel
ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest"))
class ProjectNetwork(Enum):
SECRET = 0
PUBLIC = 2
@classmethod
def choices(cls):
return [(0, "Secret"), (2, "Public")]
def get_default_props():
return {
"filters": {
@@ -71,12 +71,6 @@ class Command(BaseCommand):
"category": "GITHUB",
"is_encrypted": True,
},
{
"key": "GITHUB_ORGANIZATION_ID",
"value": os.environ.get("GITHUB_ORGANIZATION_ID"),
"category": "GITHUB",
"is_encrypted": False,
},
{
"key": "GITLAB_HOST",
"value": os.environ.get("GITLAB_HOST"),
+23
View File
@@ -7,9 +7,13 @@ from urllib.parse import urlparse
# Third party imports
import dj_database_url
import sentry_sdk
# Django imports
from django.core.management.utils import get_random_secret_key
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from corsheaders.defaults import default_headers
@@ -263,6 +267,25 @@ CELERY_IMPORTS = (
"plane.bgtasks.issue_description_version_sync",
)
# Sentry Settings
# Enable Sentry Settings
if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get(
"SENTRY_DSN"
).startswith("https://"):
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN", ""),
integrations=[
DjangoIntegration(),
RedisIntegration(),
CeleryIntegration(monitor_beat_tasks=True),
],
traces_sample_rate=1,
send_default_pii=True,
environment=os.environ.get("SENTRY_ENVIRONMENT", "development"),
profiles_sample_rate=float(os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0)),
)
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# Unsplash Access key
+1 -7
View File
@@ -32,12 +32,6 @@ class S3Storage(S3Boto3Storage):
) or os.environ.get("MINIO_ENDPOINT_URL")
if os.environ.get("USE_MINIO") == "1":
# Determine protocol based on environment variable
if os.environ.get("MINIO_ENDPOINT_SSL") == "1":
endpoint_protocol = "https"
else:
endpoint_protocol = request.scheme if request else "http"
# Create an S3 client for MinIO
self.s3_client = boto3.client(
"s3",
@@ -45,7 +39,7 @@ class S3Storage(S3Boto3Storage):
aws_secret_access_key=self.aws_secret_access_key,
region_name=self.aws_region,
endpoint_url=(
f"{endpoint_protocol}://{request.get_host()}"
f"{request.scheme}://{request.get_host()}"
if request
else self.aws_s3_endpoint_url
),
+2 -7
View File
@@ -12,7 +12,7 @@ from rest_framework.response import Response
# Module imports
from .base import BaseViewSet
from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard
from plane.db.models import IntakeIssue, Issue, State, IssueLink, FileAsset, DeployBoard
from plane.app.serializers import (
IssueSerializer,
IntakeIssueSerializer,
@@ -202,12 +202,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
"description": issue_data.get("description", issue.description),
}
issue_serializer = IssueCreateSerializer(
issue,
data=issue_data,
partial=True,
context={"project_id": project_deploy_board.project_id},
)
issue_serializer = IssueCreateSerializer(issue, data=issue_data, partial=True)
if issue_serializer.is_valid():
current_instance = issue
@@ -5,6 +5,9 @@ import traceback
# Django imports
from django.conf import settings
# Third party imports
from sentry_sdk import capture_exception
def log_exception(e):
# Log the error
@@ -15,4 +18,6 @@ def log_exception(e):
# Print the traceback if in debug mode
print(traceback.format_exc())
# Capture in sentry if configured
capture_exception(e)
return
+3 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.20
Django==4.2.18
# rest framework
djangorestframework==3.15.2
# postgres
@@ -26,6 +26,8 @@ faker==25.0.0
django-filter==24.2
# json model
jsonmodels==2.7.0
# sentry
sentry-sdk==2.8.0
# storage
django-storages==1.14.2
# user management
+1 -1
View File
@@ -1,3 +1,3 @@
-r base.txt
# server
gunicorn==23.0.0
gunicorn==22.0.0
+15 -3
View File
@@ -6,8 +6,16 @@
"website": "https://plane.so/",
"success_url": "/",
"stack": "heroku-22",
"keywords": ["plane", "project management", "django", "next"],
"addons": ["heroku-postgresql:mini", "heroku-redis:mini"],
"keywords": [
"plane",
"project management",
"django",
"next"
],
"addons": [
"heroku-postgresql:mini",
"heroku-redis:mini"
],
"buildpacks": [
{
"url": "https://github.com/heroku/heroku-buildpack-python.git"
@@ -53,6 +61,10 @@
"description": "AWS Bucket Name to use for S3",
"value": ""
},
"SENTRY_DSN": {
"description": "",
"value": ""
},
"WEB_URL": {
"description": "Web URL for Plane this will be used for redirections in the emails",
"value": ""
@@ -70,4 +82,4 @@
"value": ""
}
}
}
}
+2 -2
View File
@@ -42,14 +42,14 @@ x-live-env: &live-env
x-app-env: &app-env
WEB_URL: ${WEB_URL:-http://localhost}
DEBUG: ${DEBUG:-0}
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
GUNICORN_WORKERS: 1
USE_MINIO: ${USE_MINIO:-1}
DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
AMQP_URL: ${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute}
MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0}
services:
web:
+2 -5
View File
@@ -12,6 +12,8 @@ LIVE_REPLICAS=1
NGINX_PORT=80
WEB_URL=http://${APP_DOMAIN}
DEBUG=0
SENTRY_DSN=
SENTRY_ENVIRONMENT=production
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN}
API_BASE_URL=http://api:8000
@@ -56,8 +58,3 @@ GUNICORN_WORKERS=1
# UNCOMMENT `DOCKER_PLATFORM` IF YOU ARE ON `ARM64` AND DOCKER IMAGE IS NOT AVAILABLE FOR RESPECTIVE `APP_RELEASE`
# DOCKER_PLATFORM=linux/amd64
# Force HTTPS for handling SSL Termination
MINIO_ENDPOINT_SSL=0
# API key rate limit
API_KEY_RATE_LIMIT="60/minute"
+8 -6
View File
@@ -1,13 +1,12 @@
{
"name": "live",
"version": "0.25.3",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"version": "0.25.0",
"description": "",
"main": "./src/server.ts",
"private": true,
"type": "module",
"scripts": {
"dev": "PORT=3100 concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js",
"lint": "eslint src --ext .ts,.tsx",
@@ -15,6 +14,7 @@
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@hocuspocus/extension-database": "^2.15.0",
"@hocuspocus/extension-logger": "^2.15.0",
@@ -23,9 +23,11 @@
"@plane/constants": "*",
"@plane/editor": "*",
"@plane/types": "*",
"@sentry/node": "^9.0.1",
"@sentry/profiling-node": "^8.28.0",
"@tiptap/core": "2.10.4",
"@tiptap/html": "2.11.0",
"axios": "^1.8.3",
"axios": "^1.7.9",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
@@ -57,7 +59,7 @@
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"tsup": "^8.4.0",
"tsup": "^7.2.0",
"typescript": "5.3.3"
}
}
+19
View File
@@ -0,0 +1,19 @@
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
// Ensure to call this before importing any other modules!
Sentry.init({
dsn: process.env.LIVE_SENTRY_DSN,
environment: process.env.LIVE_SENTRY_ENVIRONMENT || "development",
integrations: [
// Add our Profiling integration
nodeProfilingIntegration(),
],
// Add Tracing by setting tracesSampleRate
// We recommend adjusting this value in production
tracesSampleRate: Number(process.env.LIVE_SENTRY_TRACES_SAMPLE_RATE) || 0.5,
// Set sampling rate for profiling
// This is relative to tracesSampleRate
profilesSampleRate: 1.0,
});
+6 -1
View File
@@ -1,8 +1,11 @@
import * as Sentry from "@sentry/node";
import compression from "compression";
import cors from "cors";
import expressWs from "express-ws";
import express from "express";
import helmet from "helmet";
// config
import "@/core/config/sentry-config.js";
// hocuspocus server
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers
@@ -12,7 +15,7 @@ import { errorHandler } from "@/core/helpers/error-handler.js";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common.js";
const app: any = express();
const app = express();
expressWs(app);
app.set("port", process.env.PORT || 3000);
@@ -89,6 +92,8 @@ app.use((_req, res) => {
res.status(404).send("Not Found");
});
Sentry.setupExpressErrorHandler(app);
app.use(errorHandler);
const liveServer = app.listen(app.get("port"), () => {
+3
View File
@@ -16,6 +16,9 @@
"skipLibCheck": true,
"sourceMap": true,
"inlineSources": true,
// Set `sourceRoot` to "/" to strip the build path prefix
// from generated source code references.
// This improves issue grouping in Sentry.
"sourceRoot": "/"
},
"include": ["src/**/*.ts", "tsup.config.ts"],
+4 -7
View File
@@ -1,8 +1,6 @@
{
"name": "plane",
"description": "Open-source project management that unlocks customer value",
"repository": "https://github.com/makeplane/plane.git",
"version": "0.25.3",
"version": "0.25.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -28,9 +26,8 @@
},
"resolutions": {
"nanoid": "3.3.8",
"esbuild": "0.25.0",
"@babel/helpers": "7.26.10",
"@babel/runtime": "7.26.10"
"esbuild": "0.25.0"
},
"packageManager": "yarn@1.22.22"
"packageManager": "yarn@1.22.22",
"name": "plane"
}
+2 -3
View File
@@ -1,7 +1,6 @@
{
"name": "@plane/constants",
"version": "0.25.3",
"version": "0.25.0",
"private": true,
"main": "./src/index.ts",
"license": "AGPL-3.0"
"main": "./src/index.ts"
}
+24 -160
View File
@@ -1,4 +1,8 @@
import { TIssueGroupByOptions, TIssueOrderByOptions, IIssueDisplayProperties } from "@plane/types";
import {
TIssueGroupByOptions,
TIssueOrderByOptions,
IIssueDisplayProperties,
} from "@plane/types";
export const ALL_ISSUES = "All Issues";
@@ -145,24 +149,25 @@ export const ISSUE_ORDER_BY_OPTIONS: {
{ key: "-priority", titleTranslationKey: "common.priority" },
];
export const ISSUE_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] = [
"assignee",
"start_date",
"due_date",
"labels",
"key",
"priority",
"state",
"sub_issue_count",
"link",
"attachment_count",
"estimate",
"created_on",
"updated_on",
"modules",
"cycle",
"issue_type",
];
export const ISSUE_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] =
[
"assignee",
"start_date",
"due_date",
"labels",
"key",
"priority",
"state",
"sub_issue_count",
"link",
"attachment_count",
"estimate",
"created_on",
"updated_on",
"modules",
"cycle",
"issue_type",
];
export const ISSUE_DISPLAY_PROPERTIES: {
key: keyof IIssueDisplayProperties;
@@ -210,144 +215,3 @@ export const ISSUE_DISPLAY_PROPERTIES: {
{ key: "modules", titleTranslationKey: "common.module" },
{ key: "cycle", titleTranslationKey: "common.cycle" },
];
export const SPREADSHEET_PROPERTY_LIST: (keyof IIssueDisplayProperties)[] = [
"state",
"priority",
"assignee",
"labels",
"modules",
"cycle",
"start_date",
"due_date",
"estimate",
"created_on",
"updated_on",
"link",
"attachment_count",
"sub_issue_count",
];
export const SPREADSHEET_PROPERTY_DETAILS: {
[key in keyof IIssueDisplayProperties]: {
i18n_title: string;
ascendingOrderKey: TIssueOrderByOptions;
ascendingOrderTitle: string;
descendingOrderKey: TIssueOrderByOptions;
descendingOrderTitle: string;
icon: string;
};
} = {
assignee: {
i18n_title: "common.assignees",
ascendingOrderKey: "assignees__first_name",
ascendingOrderTitle: "A",
descendingOrderKey: "-assignees__first_name",
descendingOrderTitle: "Z",
icon: "Users",
},
created_on: {
i18n_title: "common.sort.created_on",
ascendingOrderKey: "-created_at",
ascendingOrderTitle: "New",
descendingOrderKey: "created_at",
descendingOrderTitle: "Old",
icon: "CalendarDays",
},
due_date: {
i18n_title: "common.order_by.due_date",
ascendingOrderKey: "-target_date",
ascendingOrderTitle: "New",
descendingOrderKey: "target_date",
descendingOrderTitle: "Old",
icon: "CalendarCheck2",
},
estimate: {
i18n_title: "common.estimate",
ascendingOrderKey: "estimate_point__key",
ascendingOrderTitle: "Low",
descendingOrderKey: "-estimate_point__key",
descendingOrderTitle: "High",
icon: "Triangle",
},
labels: {
i18n_title: "common.labels",
ascendingOrderKey: "labels__name",
ascendingOrderTitle: "A",
descendingOrderKey: "-labels__name",
descendingOrderTitle: "Z",
icon: "Tag",
},
modules: {
i18n_title: "common.modules",
ascendingOrderKey: "issue_module__module__name",
ascendingOrderTitle: "A",
descendingOrderKey: "-issue_module__module__name",
descendingOrderTitle: "Z",
icon: "DiceIcon",
},
cycle: {
i18n_title: "common.cycle",
ascendingOrderKey: "issue_cycle__cycle__name",
ascendingOrderTitle: "A",
descendingOrderKey: "-issue_cycle__cycle__name",
descendingOrderTitle: "Z",
icon: "ContrastIcon",
},
priority: {
i18n_title: "common.priority",
ascendingOrderKey: "priority",
ascendingOrderTitle: "None",
descendingOrderKey: "-priority",
descendingOrderTitle: "Urgent",
icon: "Signal",
},
start_date: {
i18n_title: "common.order_by.start_date",
ascendingOrderKey: "-start_date",
ascendingOrderTitle: "New",
descendingOrderKey: "start_date",
descendingOrderTitle: "Old",
icon: "CalendarClock",
},
state: {
i18n_title: "common.state",
ascendingOrderKey: "state__name",
ascendingOrderTitle: "A",
descendingOrderKey: "-state__name",
descendingOrderTitle: "Z",
icon: "DoubleCircleIcon",
},
updated_on: {
i18n_title: "common.sort.updated_on",
ascendingOrderKey: "-updated_at",
ascendingOrderTitle: "New",
descendingOrderKey: "updated_at",
descendingOrderTitle: "Old",
icon: "CalendarDays",
},
link: {
i18n_title: "common.link",
ascendingOrderKey: "-link_count",
ascendingOrderTitle: "Most",
descendingOrderKey: "link_count",
descendingOrderTitle: "Least",
icon: "Link2",
},
attachment_count: {
i18n_title: "common.attachment",
ascendingOrderKey: "-attachment_count",
ascendingOrderTitle: "Most",
descendingOrderKey: "attachment_count",
descendingOrderTitle: "Least",
icon: "Paperclip",
},
sub_issue_count: {
i18n_title: "issue.display.properties.sub_issue",
ascendingOrderKey: "-sub_issues_count",
ascendingOrderTitle: "Most",
descendingOrderKey: "sub_issues_count",
descendingOrderTitle: "Least",
icon: "LayersIcon",
},
};
-1
View File
@@ -339,7 +339,6 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
"-updated_at",
"start_date",
"-priority",
"target_date",
],
type: [null, "active", "backlog"],
},
-1
View File
@@ -1,4 +1,3 @@
export * from "./common";
export * from "./filter";
export * from "./layout";
export * from "./modal";
-19
View File
@@ -1,19 +0,0 @@
// plane imports
import { TIssue } from "@plane/types";
export const DEFAULT_WORK_ITEM_FORM_VALUES: Partial<TIssue> = {
project_id: "",
type_id: null,
name: "",
description_html: "",
estimate_point: null,
state_id: "",
parent_id: null,
priority: "none",
assignee_ids: [],
label_ids: [],
cycle_id: null,
module_ids: null,
start_date: null,
target_date: null,
};
+9 -6
View File
@@ -1,5 +1,8 @@
// icons
import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
import {
TProjectAppliedDisplayFilterKeys,
TProjectOrderByOptions,
} from "@plane/types";
export type TNetworkChoiceIconKey = "Lock" | "Globe2";
@@ -52,11 +55,11 @@ export const GROUP_CHOICES = {
};
export const PROJECT_AUTOMATION_MONTHS = [
{ i18n_label: "workspace_projects.common.months_count", value: 1 },
{ i18n_label: "workspace_projects.common.months_count", value: 3 },
{ i18n_label: "workspace_projects.common.months_count", value: 6 },
{ i18n_label: "workspace_projects.common.months_count", value: 9 },
{ i18n_label: "workspace_projects.common.months_count", value: 12 },
{ i18n_label: "common.months_count", value: 1 },
{ i18n_label: "common.months_count", value: 3 },
{ i18n_label: "common.months_count", value: 6 },
{ i18n_label: "common.months_count", value: 9 },
{ i18n_label: "common.months_count", value: 12 },
];
export const PROJECT_UNSPLASH_COVERS = [
+3 -20
View File
@@ -1,4 +1,4 @@
import { TStaticViewTypes, IWorkspaceSearchResults } from "@plane/types";
import { TStaticViewTypes } from "@plane/types";
import { EUserWorkspaceRoles } from "./user";
export const ORGANIZATION_SIZE = [
@@ -83,14 +83,14 @@ export const WORKSPACE_SETTINGS = {
key: "general",
i18n_label: "workspace_settings.settings.general.title",
href: `/settings`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`,
},
members: {
key: "members",
i18n_label: "workspace_settings.settings.members.title",
href: `/settings/members`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
},
"billing-and-plans": {
@@ -123,10 +123,6 @@ export const WORKSPACE_SETTINGS = {
},
};
export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries(
Object.entries(WORKSPACE_SETTINGS).map(([_, { href, access }]) => [href, access])
);
export const WORKSPACE_SETTINGS_LINKS: {
key: string;
i18n_label: string;
@@ -328,16 +324,3 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarN
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["inbox"],
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["projects"],
];
export const IS_FAVORITE_MENU_OPEN = "is_favorite_menu_open";
export const WORKSPACE_DEFAULT_SEARCH_RESULT: IWorkspaceSearchResults = {
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
};
+2 -3
View File
@@ -1,8 +1,7 @@
{
"name": "@plane/editor",
"version": "0.25.3",
"version": "0.25.0",
"description": "Core Editor that powers Plane",
"license": "AGPL-3.0",
"private": true,
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
@@ -81,7 +80,7 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"postcss": "^8.4.38",
"tsup": "^8.4.0",
"tsup": "^7.2.0",
"typescript": "5.3.3"
},
"keywords": [
@@ -1,89 +0,0 @@
import { Extension } from "@tiptap/core";
import { Fragment, Node } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export const MarkdownClipboard = Extension.create({
name: "markdownClipboard",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("markdownClipboard"),
props: {
clipboardTextSerializer: (slice) => {
const markdownSerializer = this.editor.storage.markdown.serializer;
const isTableRow = slice.content.firstChild?.type?.name === "tableRow";
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
if (nodeSelect) {
return markdownSerializer.serialize(slice.content);
}
const processTableContent = (tableNode: Node | Fragment) => {
let result = "";
tableNode.content?.forEach?.((tableRowNode: Node | Fragment) => {
tableRowNode.content?.forEach?.((cell: Node) => {
const cellContent = cell.content ? markdownSerializer.serialize(cell.content) : "";
result += cellContent + "\n";
});
});
return result;
};
if (isTableRow) {
const rowsCount = slice.content?.childCount || 0;
const cellsCount = slice.content?.firstChild?.content?.childCount || 0;
if (rowsCount === 1 || cellsCount === 1) {
return processTableContent(slice.content);
} else {
return markdownSerializer.serialize(slice.content);
}
}
const traverseToParentOfLeaf = (
node: Node | null,
parent: Fragment | Node,
depth: number
): Node | Fragment => {
let currentNode = node;
let currentParent = parent;
let currentDepth = depth;
while (currentNode && currentDepth > 1 && currentNode.content?.firstChild) {
if (currentNode.content?.childCount > 1) {
if (currentNode.content.firstChild?.type?.name === "listItem") {
return currentParent;
} else {
return currentNode.content;
}
}
currentParent = currentNode;
currentNode = currentNode.content?.firstChild || null;
currentDepth--;
}
return currentParent;
};
if (slice.content.childCount > 1) {
return markdownSerializer.serialize(slice.content);
} else {
const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart);
let currentNode = targetNode;
while (currentNode && currentNode.content && currentNode.childCount === 1 && currentNode.firstChild) {
currentNode = currentNode.firstChild;
}
if (currentNode instanceof Node && currentNode.isText) {
return currentNode.text;
}
return markdownSerializer.serialize(targetNode);
}
},
},
}),
];
},
});
@@ -76,7 +76,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={editor.storage.imageComponent?.maxFileSize}
maxFileSize={editor.storage.imageComponent.maxFileSize}
node={node}
setIsUploaded={setIsUploaded}
selected={selected}
@@ -16,7 +16,7 @@ export const ImageUploadStatus: React.FC<Props> = (props) => {
// subscribe to image upload status
const uploadStatus: number | undefined = useEditorState({
editor,
selector: ({ editor }) => editor.storage.imageComponent?.assetsUploadStatus[nodeId],
selector: ({ editor }) => editor.storage.imageComponent.assetsUploadStatus[nodeId],
});
useEffect(() => {
@@ -22,7 +22,7 @@ declare module "@tiptap/core" {
imageComponent: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
uploadImage: (blockId: string, file: File) => () => Promise<string> | undefined;
updateAssetsUploadStatus?: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void;
updateAssetsUploadStatus: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void;
getImageSource?: (path: string) => () => Promise<string>;
restoreImage: (src: string) => () => Promise<void>;
};
@@ -29,7 +29,6 @@ import {
TableCell,
TableHeader,
TableRow,
MarkdownClipboard,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
@@ -51,7 +50,8 @@ type TArguments = {
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex } = args;
const extensions = [
return [
// @ts-expect-error tiptap types are incorrect
StarterKit.configure({
bulletList: {
HTMLAttributes: {
@@ -109,6 +109,12 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
},
}),
CustomTypographyExtension,
ImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageExtension(fileHandler),
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -131,11 +137,10 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomCodeInlineExtension,
Markdown.configure({
html: true,
transformCopiedText: false,
transformCopiedText: true,
transformPastedText: true,
breaks: true,
}),
MarkdownClipboard,
Table,
TableHeader,
TableCell,
@@ -147,7 +152,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
if (editor.storage.imageComponent?.uploadInProgress) return "";
if (editor.storage.imageComponent.uploadInProgress) return "";
const shouldHidePlaceholder =
editor.isActive("table") ||
@@ -174,18 +179,4 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
disabledExtensions,
}),
];
if (!disabledExtensions.includes("image")) {
extensions.push(
ImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageExtension(fileHandler)
);
}
// @ts-expect-error tiptap types are incorrect
return extensions;
};
@@ -48,7 +48,6 @@ export const CustomImageComponentWithoutProps = () =>
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
assetsUploadStatus: {},
};
},
});
@@ -23,4 +23,3 @@ export * from "./quote";
export * from "./read-only-extensions";
export * from "./side-menu";
export * from "./text-align";
export * from "./clipboard";
@@ -1,15 +1,12 @@
import { mergeAttributes } from "@tiptap/core";
import Mention, { MentionOptions } from "@tiptap/extension-mention";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
import { Node as NodeType } from "@tiptap/pm/model";
// types
import { TMentionHandler } from "@/types";
// local types
import { EMentionComponentAttributeNames, TMentionComponentAttributes } from "./types";
import { EMentionComponentAttributeNames } from "./types";
export type TMentionExtensionOptions = MentionOptions & {
renderComponent: TMentionHandler["renderComponent"];
getMentionedEntityDetails: TMentionHandler["getMentionedEntityDetails"];
};
export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOptions>({
@@ -43,26 +40,9 @@ export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOpti
class: "mention",
},
renderText({ node }) {
return getMentionDisplayText(this.options, node);
},
addStorage() {
const options = this.options;
addStorage(this) {
return {
mentionsOpen: false,
markdown: {
serialize(state: MarkdownSerializerState, node: NodeType) {
state.write(getMentionDisplayText(options, node));
},
},
};
},
});
function getMentionDisplayText(options: TMentionExtensionOptions, node: NodeType): string {
const attrs = node.attrs as TMentionComponentAttributes;
const mentionEntityId = attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER];
const mentionEntityDetails = options.getMentionedEntityDetails?.(mentionEntityId ?? "");
return `@${mentionEntityDetails?.display_name ?? attrs[EMentionComponentAttributeNames.ID] ?? mentionEntityId}`;
}
@@ -9,13 +9,12 @@ import { MentionNodeView } from "./mention-node-view";
import { renderMentionsDropdown } from "./utils";
export const CustomMentionExtension = (props: TMentionHandler) => {
const { searchCallback, renderComponent, getMentionedEntityDetails } = props;
const { searchCallback, renderComponent } = props;
return CustomMentionExtensionConfig.extend({
addOptions(this) {
return {
...this.parent?.(),
renderComponent,
getMentionedEntityDetails,
};
},
@@ -24,7 +24,6 @@ import {
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
CustomColorExtension,
MarkdownClipboard,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
@@ -42,7 +41,8 @@ type Props = {
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
const { disabledExtensions, fileHandler, mentionHandler } = props;
const extensions = [
return [
// @ts-expect-error tiptap types are incorrect
StarterKit.configure({
bulletList: {
HTMLAttributes: {
@@ -94,6 +94,12 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
},
}),
CustomTypographyExtension,
ReadOnlyImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomReadOnlyImageExtension(fileHandler),
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -115,9 +121,8 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
CustomCodeInlineExtension,
Markdown.configure({
html: true,
transformCopiedText: false,
transformCopiedText: true,
}),
MarkdownClipboard,
Table,
TableHeader,
TableCell,
@@ -131,18 +136,4 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
disabledExtensions,
}),
];
if (!disabledExtensions.includes("image")) {
extensions.push(
ReadOnlyImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomReadOnlyImageExtension(fileHandler)
);
}
// @ts-expect-error tiptap types are incorrect
return extensions;
};
@@ -43,7 +43,7 @@ import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/typ
// plane editor extensions
import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions";
// local types
import { TExtensionProps, TSlashCommandAdditionalOption } from "./root";
import { TExtensionProps } from "./root";
export type TSlashCommandSection = {
key: TSlashCommandSectionKeys;
@@ -54,7 +54,7 @@ export type TSlashCommandSection = {
export const getSlashCommandFilteredSections =
(args: TExtensionProps) =>
({ query }: { query: string }): TSlashCommandSection[] => {
const { additionalOptions: externalAdditionalOptions, disabledExtensions } = args;
const { additionalOptions, disabledExtensions } = args;
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
{
key: "general",
@@ -176,6 +176,15 @@ export const getSlashCommandFilteredSections =
icon: <Code2 className="size-3.5" />,
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
commandKey: "image",
key: "image",
title: "Image",
icon: <ImageIcon className="size-3.5" />,
description: "Insert an image",
searchTerms: ["img", "photo", "picture", "media", "upload"],
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
},
{
commandKey: "callout",
key: "callout",
@@ -275,24 +284,8 @@ export const getSlashCommandFilteredSections =
},
];
const internalAdditionalOptions: TSlashCommandAdditionalOption[] = [];
if (!disabledExtensions?.includes("image")) {
internalAdditionalOptions.push({
commandKey: "image",
key: "image",
title: "Image",
icon: <ImageIcon className="size-3.5" />,
description: "Insert an image",
searchTerms: ["img", "photo", "picture", "media", "upload"],
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
section: "general",
pushAfter: "code",
});
}
[
...internalAdditionalOptions,
...(externalAdditionalOptions ?? []),
...(additionalOptions ?? []),
...coreEditorAdditionalSlashCommandOptions({
disabledExtensions,
}),
@@ -16,22 +16,6 @@ export function tableControls() {
},
},
props: {
handleTripleClickOn(view, pos, node, nodePos, event, direct) {
if (node.type.name === 'tableCell') {
event.preventDefault();
const $pos = view.state.doc.resolve(pos);
const line = $pos.parent;
const linePos = $pos.start();
const start = linePos;
const end = linePos + line.nodeSize - 1;
const tr = view.state.tr.setSelection(
TextSelection.create(view.state.doc, start, end)
);
view.dispatch(tr);
return true;
}
return false;
},
handleDOMEvents: {
mousemove: (view, event) => {
const pluginState = key.getState(view.state);
+3 -3
View File
@@ -111,7 +111,7 @@ export const useEditor = (props: CustomEditorProps) => {
// value is null when intentionally passed where syncing is not yet
// supported and value is undefined when the data from swr is not populated
if (value == null) return;
if (editor && !editor.isDestroyed && !editor.storage.imageComponent?.uploadInProgress) {
if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) {
try {
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
if (editor.state.selection) {
@@ -129,7 +129,7 @@ export const useEditor = (props: CustomEditorProps) => {
useEffect(() => {
if (!editor) return;
const assetsUploadStatus = fileHandler.assetsUploadStatus;
editor.commands.updateAssetsUploadStatus?.(assetsUploadStatus);
editor.commands.updateAssetsUploadStatus(assetsUploadStatus);
}, [editor, fileHandler.assetsUploadStatus]);
useImperativeHandle(
@@ -221,7 +221,7 @@ export const useEditor = (props: CustomEditorProps) => {
if (!editor) return;
scrollSummary(editor, marking);
},
isEditorReadyToDiscard: () => editor?.storage.imageComponent?.uploadInProgress === false,
isEditorReadyToDiscard: () => editor?.storage.imageComponent.uploadInProgress === false,
setFocusAtPosition: (position: number) => {
if (!editor || editor.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
@@ -21,9 +21,7 @@ export const useUploader = (args: TUploaderArgs) => {
const uploadFile = useCallback(
async (file: File) => {
const setImageUploadInProgress = (isUploading: boolean) => {
if (editor.storage.imageComponent) {
editor.storage.imageComponent.uploadInProgress = isUploading;
}
editor.storage.imageComponent.uploadInProgress = isUploading;
};
setImageUploadInProgress(true);
setUploading(true);
+1 -1
View File
@@ -1 +1 @@
export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed" | "slash-commands" | "enter-key" | "image";
export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed" | "slash-commands" | "enter-key";
+1 -2
View File
@@ -1,5 +1,5 @@
// plane types
import { IUserLite, TSearchEntities } from "@plane/types";
import { TSearchEntities } from "@plane/types";
export type TMentionSuggestion = {
entity_identifier: string;
@@ -20,7 +20,6 @@ export type TMentionComponentProps = Pick<TMentionSuggestion, "entity_identifier
export type TReadOnlyMentionHandler = {
renderComponent: (props: TMentionComponentProps) => React.ReactNode;
getMentionedEntityDetails?: (entity_identifier: string) => { display_name: string } | undefined;
};
export type TMentionHandler = TReadOnlyMentionHandler & {
+1 -2
View File
@@ -1,8 +1,7 @@
{
"name": "@plane/eslint-config",
"private": true,
"version": "0.25.3",
"license": "AGPL-3.0",
"version": "0.25.0",
"files": [
"library.js",
"next.js",
+2 -3
View File
@@ -1,7 +1,6 @@
{
"name": "@plane/hooks",
"version": "0.25.3",
"license": "AGPL-3.0",
"version": "0.25.0",
"description": "React hooks that are shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
@@ -22,7 +21,7 @@
"@plane/eslint-config": "*",
"@types/node": "^22.5.4",
"@types/react": "^18.3.11",
"tsup": "^8.4.0",
"tsup": "^7.2.0",
"typescript": "^5.3.3"
}
}
+1 -2
View File
@@ -1,7 +1,6 @@
{
"name": "@plane/i18n",
"version": "0.25.3",
"license": "AGPL-3.0",
"version": "0.25.0",
"description": "I18n shared across multiple apps internally",
"private": true,
"main": "./src/index.ts",
+2 -9
View File
@@ -7,16 +7,9 @@ export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "Français", value: "fr" },
{ label: "Español", value: "es" },
{ label: "日本語", value: "ja" },
{ label: "简体中文", value: "zh-CN" },
{ label: "繁體中文", value: "zh-TW" },
{ label: "中文", value: "zh-CN" },
{ label: "Русский", value: "ru" },
{ label: "Italian", value: "it" },
{ label: "Čeština", value: "cs" },
{ label: "Slovenčina", value: "sk" },
{ label: "Deutsch", value: "de" },
{ label: "Українська", value: "ua" },
{ label: "Polski", value: "pl" },
{ label: "한국어", value: "ko" },
];
export const LANGUAGE_STORAGE_KEY = "userLanguage";
export const STORAGE_KEY = "userLanguage";
+4 -4
View File
@@ -1,8 +1,8 @@
import { useContext } from "react";
import { useContext } from 'react';
// context
import { TranslationContext } from "../context";
import { TranslationContext } from '../context';
// types
import { ILanguageOption, TLanguage } from "../types";
import { ILanguageOption, TLanguage } from '../types';
export type TTranslationStore = {
t: (key: string, params?: Record<string, any>) => string;
@@ -23,7 +23,7 @@ export type TTranslationStore = {
export function useTranslation(): TTranslationStore {
const store = useContext(TranslationContext);
if (!store) {
throw new Error("useTranslation must be used within a TranslationProvider");
throw new Error('useTranslation must be used within a TranslationProvider');
}
return {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,7 +9,7 @@
"workspace": "Workspace",
"views": "Views",
"analytics": "Analytics",
"work_items": "Work items",
"work_items": "Work Items",
"cycles": "Cycles",
"modules": "Modules",
"intake": "Intake",
@@ -529,7 +529,6 @@
"property": "Property",
"properties": "Properties",
"parent": "Parent",
"page": "Page",
"remove": "Remove",
"archiving": "Archiving",
"archive": "Archive",
@@ -688,7 +687,7 @@
"you": "You",
"upgrade_cta": {
"higher_subscription": "Upgrade to higher subscription",
"talk_to_sales": "Talk to Sales"
"talk_to_sales": "Talk to sales"
},
"category": "Category",
"categories": "Categories",
@@ -697,8 +696,7 @@
"delete": "Delete",
"deleting": "Deleting",
"pending": "Pending",
"invite": "Invite",
"view": "View"
"invite": "Invite"
},
"chart": {
@@ -816,8 +814,7 @@
"sub_issue_count": "Sub-work item count",
"attachment_count": "Attachment count",
"created_on": "Created on",
"sub_issue": "Sub-work item",
"work_item_count": "Work item count"
"sub_issue": "Sub-work item"
},
"extra": {
"show_sub_issues": "Show sub-work items",
@@ -1305,8 +1302,7 @@
"max_length": "Workspace name should not exceed 80 characters"
},
"company_size": {
"required": "Company size is required",
"select_a_range": "Select organization size"
"required": "Company size is required"
}
}
},
@@ -701,7 +701,6 @@
"property": "Propiedad",
"properties": "Propiedades",
"parent": "Padre",
"page": "página",
"remove": "Eliminar",
"archiving": "Archivando",
"archive": "Archivar",
@@ -868,8 +867,7 @@
"delete": "Eliminar",
"deleting": "Eliminando",
"pending": "Pendiente",
"invite": "Invitar",
"view": "Ver"
"invite": "Invitar"
},
"chart": {
@@ -987,8 +985,7 @@
"sub_issue_count": "Cantidad de sub-elementos",
"attachment_count": "Cantidad de archivos adjuntos",
"created_on": "Creado el",
"sub_issue": "Sub-elemento de trabajo",
"work_item_count": "Recuento de elementos de trabajo"
"sub_issue": "Sub-elemento de trabajo"
},
"extra": {
"show_sub_issues": "Mostrar sub-elementos",
@@ -1475,8 +1472,7 @@
"max_length": "El nombre del espacio de trabajo no debe exceder los 80 caracteres"
},
"company_size": {
"required": "El tamaño de la empresa es obligatorio",
"select_a_range": "Seleccionar tamaño de la organización"
"required": "El tamaño de la empresa es obligatorio"
}
}
},
@@ -699,7 +699,6 @@
"property": "Propriété",
"properties": "Propriétés",
"parent": "Parent",
"page": "Pâge",
"remove": "Supprimer",
"archiving": "Archivage",
"archive": "Archiver",
@@ -857,7 +856,7 @@
"you": "Vous",
"upgrade_cta": {
"higher_subscription": "Passer à une abonnement plus élevé",
"talk_to_sales": "Parler aux ventes"
"talk_to_sales": "Parler à la vente"
},
"category": "Catégorie",
"categories": "Catégories",
@@ -866,8 +865,7 @@
"delete": "Supprimer",
"deleting": "Suppression",
"pending": "En attente",
"invite": "Inviter",
"view": "Afficher"
"invite": "Inviter"
},
"chart": {
@@ -985,8 +983,7 @@
"sub_issue_count": "Nombre de sous-éléments",
"attachment_count": "Nombre de pièces jointes",
"created_on": "Créé le",
"sub_issue": "Sous-élément de travail",
"work_item_count": "Nombre d'éléments de travail"
"sub_issue": "Sous-élément de travail"
},
"extra": {
"show_sub_issues": "Afficher les sous-éléments",
@@ -1473,8 +1470,7 @@
"max_length": "Le nom de l'espace de travail ne doit pas dépasser 80 caractères"
},
"company_size": {
"required": "La taille de l'entreprise est requise",
"select_a_range": "Sélectionner la taille de l'organisation"
"required": "La taille de l'entreprise est requise"
}
}
},
@@ -693,7 +693,6 @@
"property": "Proprietà",
"properties": "Proprietà",
"parent": "Principale",
"page": "Pagina",
"remove": "Rimuovi",
"archiving": "Archiviazione in corso",
"archive": "Archivia",
@@ -863,8 +862,7 @@
"delete": "Elimina",
"deleting": "Eliminazione in corso",
"pending": "In sospeso",
"invite": "Invita",
"view": "Visualizza"
"invite": "Invita"
},
"chart": {
@@ -982,8 +980,7 @@
"sub_issue_count": "Numero di sotto-elementi di lavoro",
"attachment_count": "Numero di allegati",
"created_on": "Creato il",
"sub_issue": "Sotto-elemento di lavoro",
"work_item_count": "Conteggio degli elementi di lavoro"
"sub_issue": "Sotto-elemento di lavoro"
},
"extra": {
"show_sub_issues": "Mostra sotto-elementi di lavoro",
@@ -1471,8 +1468,7 @@
"max_length": "Il nome dello spazio di lavoro non deve superare gli 80 caratteri"
},
"company_size": {
"required": "La dimensione aziendale è obbligatoria",
"select_a_range": "Seleziona la dimensione dell'organizzazione"
"required": "La dimensione aziendale è obbligatoria"
}
}
},
@@ -699,7 +699,6 @@
"property": "プロパティ",
"properties": "プロパティ",
"parent": "親",
"page": "ページ",
"remove": "削除",
"archiving": "アーカイブ中",
"archive": "アーカイブ",
@@ -857,7 +856,7 @@
"you": "あなた",
"upgrade_cta": {
"higher_subscription": "高いサブスクリプションにアップグレード",
"talk_to_sales": "トーク トゥ セールス"
"talk_to_sales": "セールスに連絡"
},
"category": "カテゴリー",
"categories": "カテゴリーズ",
@@ -866,8 +865,7 @@
"delete": "デリート",
"deleting": "デリーティング",
"pending": "保留中",
"invite": "招待",
"view": "ビュー"
"invite": "招待"
},
"chart": {
@@ -985,8 +983,7 @@
"sub_issue_count": "サブ作業項目数",
"attachment_count": "添付ファイル数",
"created_on": "作成日",
"sub_issue": "サブ作業項目",
"work_item_count": "作業項目数"
"sub_issue": "サブ作業項目"
},
"extra": {
"show_sub_issues": "サブ作業項目を表示",
@@ -1473,8 +1470,7 @@
"max_length": "ワークスペース名は80文字を超えることはできません"
},
"company_size": {
"required": "会社の規模は必須です",
"select_a_range": "組織の規模を選択"
"required": "会社の規模は必須です"
}
}
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -11
View File
@@ -697,7 +697,6 @@
"property": "Свойство",
"properties": "Свойства",
"parent": "Родительский",
"page": "Пейдж",
"remove": "Удалить",
"archiving": "Архивация",
"archive": "Архивировать",
@@ -865,8 +864,7 @@
"delete": "Удалить",
"deleting": "Удаление",
"pending": "Ожидание",
"invite": "Пригласить",
"view": "Просмотр"
"invite": "Пригласить"
},
"chart": {
@@ -984,8 +982,7 @@
"sub_issue_count": "Количество подэлементов",
"attachment_count": "Количество вложений",
"created_on": "Дата создания",
"sub_issue": "Подэлемент",
"work_item_count": "Количество рабочих элементов"
"sub_issue": "Подэлемент"
},
"extra": {
"show_sub_issues": "Показывать подэлементы",
@@ -1473,8 +1470,7 @@
"max_length": "Максимум 80 символов"
},
"company_size": {
"required": "Размер компании обязателен",
"select_a_range": "Выберите размер организации"
"required": "Размер компании обязателен"
}
}
},
@@ -1870,7 +1866,7 @@
}
},
"completed_no_issues": {
"title": "Нет рабочих элементов в цикле",
"title": "Нет рабочих элементов в цикле",
"description": "Нет рабочих элементов. Рабочие элементы были перенесены или скрыты. Для просмотра измените настройки отображения."
},
"active": {
@@ -2289,7 +2285,7 @@
"short_description": "Экспорт в csv"
},
"excel": {
"title": "Excel",
"title": "Excel",
"description": "Экспорт рабочих элементов в файл Excel.",
"short_description": "Экспорт в excel"
},
@@ -2307,7 +2303,7 @@
"default_global_view": {
"all_issues": "Все рабочие элементы",
"assigned": "Назначенные",
"created": "Созданные",
"created": "Созданные",
"subscribed": "Подписанные"
},
@@ -2336,7 +2332,7 @@
"project_modules": {
"status": {
"backlog": "Бэклог",
"planned": "Запланировано",
"planned": "Запланировано",
"in_progress": "В процессе",
"paused": "Приостановлено",
"completed": "Завершено",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -699,7 +699,6 @@
"property": "属性",
"properties": "属性",
"parent": "父项",
"page": "页面",
"remove": "移除",
"archiving": "归档中",
"archive": "归档",
@@ -866,8 +865,7 @@
"delete": "删除",
"deleting": "删除中",
"pending": "待处理",
"invite": "邀请",
"view": "查看"
"invite": "邀请"
},
"chart": {
@@ -985,8 +983,7 @@
"sub_issue_count": "子工作项数量",
"attachment_count": "附件数量",
"created_on": "创建于",
"sub_issue": "子工作项",
"work_item_count": "工作项数量"
"sub_issue": "子工作项"
},
"extra": {
"show_sub_issues": "显示子工作项",
@@ -1473,8 +1470,7 @@
"max_length": "工作区名称不应超过80个字符"
},
"company_size": {
"required": "公司规模为必填项",
"select_a_range": "选择组织规模"
"required": "公司规模为必填项"
}
}
},
File diff suppressed because it is too large Load Diff
+39 -19
View File
@@ -3,7 +3,7 @@ import get from "lodash/get";
import merge from "lodash/merge";
import { makeAutoObservable, runInAction } from "mobx";
// constants
import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, LANGUAGE_STORAGE_KEY } from "../constants";
import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, STORAGE_KEY } from "../constants";
// core translations imports
import coreEn from "../locales/en/core.json";
// types
@@ -48,14 +48,14 @@ export class TranslationStore {
private initializeLanguage() {
if (typeof window === "undefined") return;
const savedLocale = localStorage.getItem(LANGUAGE_STORAGE_KEY) as TLanguage;
const savedLocale = localStorage.getItem(STORAGE_KEY) as TLanguage;
if (this.isValidLanguage(savedLocale)) {
this.setLanguage(savedLocale);
return;
}
// Fallback to default language
this.setLanguage(FALLBACK_LANGUAGE);
const browserLang = this.getBrowserLanguage();
this.setLanguage(browserLang);
}
/** Loads the translations for the current language */
@@ -147,24 +147,10 @@ export class TranslationStore {
return import("../locales/ja/translations.json");
case "zh-CN":
return import("../locales/zh-CN/translations.json");
case "zh-TW":
return import("../locales/zh-TW/translations.json");
case "ru":
return import("../locales/ru/translations.json");
case "it":
return import("../locales/it/translations.json");
case "cs":
return import("../locales/cs/translations.json");
case "sk":
return import("../locales/sk/translations.json");
case "de":
return import("../locales/de/translations.json");
case "ua":
return import("../locales/ua/translations.json");
case "pl":
return import("../locales/pl/translations.json");
case "ko":
return import("../locales/ko/translations.json");
default:
throw new Error(`Unsupported language: ${language}`);
}
@@ -175,6 +161,40 @@ export class TranslationStore {
return lang !== null && this.availableLanguages.some((l) => l.value === lang);
}
/** Checks if a language code is similar to any supported language */
private findSimilarLanguage(lang: string): TLanguage | null {
// Convert to lowercase for case-insensitive comparison
const normalizedLang = lang.toLowerCase();
// Find a supported language that includes or is included in the browser language
const similarLang = this.availableLanguages.find(
(l) => normalizedLang.includes(l.value.toLowerCase()) || l.value.toLowerCase().includes(normalizedLang)
);
return similarLang ? similarLang.value : null;
}
/** Gets the browser language based on the navigator.language */
private getBrowserLanguage(): TLanguage {
const browserLang = navigator.language;
// Check exact match first
if (this.isValidLanguage(browserLang)) {
return browserLang;
}
// Check base language without region code
const baseLang = browserLang.split("-")[0];
if (this.isValidLanguage(baseLang)) {
return baseLang as TLanguage;
}
// Try to find a similar language
const similarLang = this.findSimilarLanguage(browserLang) || this.findSimilarLanguage(baseLang);
return similarLang || FALLBACK_LANGUAGE;
}
/**
* Gets the cache key for the given key and locale
* @param key - the key to get the cache key for
@@ -259,7 +279,7 @@ export class TranslationStore {
}
if (typeof window !== "undefined") {
localStorage.setItem(LANGUAGE_STORAGE_KEY, lng);
localStorage.setItem(STORAGE_KEY, lng);
document.documentElement.lang = lng;
}
+1 -1
View File
@@ -1,4 +1,4 @@
export type TLanguage = "en" | "fr" | "es" | "ja" | "zh-CN" | "zh-TW" | "ru" | "it" | "cs" | "sk" | "de" | "ua" | "pl" | "ko";
export type TLanguage = "en" | "fr" | "es" | "ja" | "zh-CN" | "ru" | "it";
export interface ILanguageOption {
label: string;
+1 -2
View File
@@ -1,7 +1,6 @@
{
"name": "@plane/logger",
"version": "0.25.3",
"license": "AGPL-3.0",
"version": "0.25.0",
"description": "Logger shared across multiple apps internally",
"private": true,
"main": "./src/index.ts",
+1 -2
View File
@@ -1,8 +1,7 @@
{
"name": "@plane/propel",
"version": "0.25.3",
"version": "0.25.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
+2 -3
View File
@@ -1,7 +1,6 @@
{
"name": "@plane/services",
"version": "0.25.3",
"license": "AGPL-3.0",
"version": "0.25.0",
"private": true,
"main": "./src/index.ts",
"scripts": {
@@ -10,6 +9,6 @@
},
"dependencies": {
"@plane/constants": "*",
"axios": "^1.8.3"
"axios": "^1.7.9"
}
}
+1 -2
View File
@@ -1,7 +1,6 @@
{
"name": "@plane/shared-state",
"version": "0.25.3",
"license": "AGPL-3.0",
"version": "0.25.0",
"description": "Shared state shared across multiple apps internally",
"private": true,
"main": "./src/index.ts",
+1 -2
View File
@@ -1,7 +1,6 @@
{
"name": "@plane/tailwind-config",
"version": "0.25.3",
"license": "AGPL-3.0",
"version": "0.25.0",
"description": "common tailwind configuration across monorepo",
"main": "tailwind.config.js",
"private": true,
+1 -2
View File
@@ -1,7 +1,6 @@
{
"name": "@plane/types",
"version": "0.25.3",
"license": "AGPL-3.0",
"version": "0.25.0",
"private": true,
"types": "./src/index.d.ts",
"main": "./src/index.d.ts"
-6
View File
@@ -6,12 +6,6 @@ export enum EUserPermissions {
export type TUserPermissions = EUserPermissions.ADMIN | EUserPermissions.MEMBER | EUserPermissions.GUEST;
// project network
export enum EProjectNetwork {
PRIVATE = 0,
PUBLIC = 2,
}
// project pages
export enum EPageAccess {
PUBLIC = 0,
+1 -3
View File
@@ -66,10 +66,8 @@ export type TInboxIssueWithPagination = TInboxIssuePaginationInfo & {
results: TInboxIssue[];
};
export type TAnchors = { [key: string]: string };
export type TInboxForm = {
anchors: TAnchors;
anchor: string;
id: string;
is_in_app_enabled: boolean;
is_form_enabled: boolean;
-1
View File
@@ -40,4 +40,3 @@ export * from "./epics";
export * from "./charts";
export * from "./home";
export * from "./stickies";
export * from "./utils";

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