Compare commits

..

2 Commits

Author SHA1 Message Date
Aaryan Khandelwal 015f8cbfe5 chore: split editor ref 2025-08-26 16:56:29 +05:30
Aaryan Khandelwal 42b38db131 chore: implement code splitting for editor 2025-08-26 14:37:17 +05:30
154 changed files with 8546 additions and 10289 deletions
+12 -21
View File
@@ -35,10 +35,6 @@ on:
- preview
- canary
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
TARGET_BRANCH: ${{ github.ref_name }}
ARM64_BUILD: ${{ github.event.inputs.arm64 }}
@@ -272,14 +268,15 @@ jobs:
if: ${{ needs.branch_build_setup.outputs.aio_build == 'true' }}
name: Build-Push AIO Docker Image
runs-on: ubuntu-22.04
needs:
- branch_build_setup
- branch_build_push_admin
- branch_build_push_web
- branch_build_push_space
- branch_build_push_live
- branch_build_push_api
- branch_build_push_proxy
needs: [
branch_build_setup,
branch_build_push_admin,
branch_build_push_web,
branch_build_push_space,
branch_build_push_live,
branch_build_push_api,
branch_build_push_proxy
]
steps:
- name: Checkout Files
uses: actions/checkout@v4
@@ -288,7 +285,7 @@ jobs:
id: prepare_aio_assets
run: |
cd deployments/aio/community
if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then
aio_version=${{ needs.branch_build_setup.outputs.release_version }}
else
@@ -327,14 +324,7 @@ jobs:
upload_build_assets:
name: Upload Build Assets
runs-on: ubuntu-22.04
needs:
- branch_build_setup
- branch_build_push_admin
- branch_build_push_web
- branch_build_push_space
- branch_build_push_live
- branch_build_push_api
- branch_build_push_proxy
needs: [branch_build_setup, branch_build_push_admin, branch_build_push_web, branch_build_push_space, branch_build_push_live, branch_build_push_api, branch_build_push_proxy]
steps:
- name: Checkout Files
uses: actions/checkout@v4
@@ -407,3 +397,4 @@ jobs:
${{ github.workspace }}/deployments/cli/community/docker-compose.yml
${{ github.workspace }}/deployments/cli/community/variables.env
${{ github.workspace }}/deployments/swarm/community/swarm.sh
+2
View File
@@ -17,6 +17,8 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Get PR Branch version
run: echo "PR_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
@@ -3,21 +3,11 @@ name: Build and lint API
on:
workflow_dispatch:
pull_request:
branches:
- "preview"
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"
branches: ["preview"]
types: ["opened", "synchronize", "ready_for_review", "review_requested", "reopened"]
paths:
- "apps/api/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-api:
name: Lint API
@@ -3,18 +3,21 @@ name: Build and lint web apps
on:
workflow_dispatch:
pull_request:
branches:
- "preview"
branches: ["preview"]
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
[
"opened",
"synchronize",
"ready_for_review",
"review_requested",
"reopened",
]
paths:
- "**.tsx?"
- "**.jsx?"
- "**.css"
- "**.json"
- "!apps/api/**"
jobs:
build-and-lint:
@@ -24,18 +27,16 @@ jobs:
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
TURBO_SCM_HEAD: ${{ github.sha }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 50
filter: blob:none
fetch-depth: 2
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- name: Enable Corepack and pnpm
run: corepack enable pnpm
@@ -43,11 +44,11 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint Affected
run: pnpm turbo run check:lint --affected
- name: Lint web apps
run: pnpm run check:lint
- name: Check Affected format
run: pnpm turbo run check:format --affected
- name: Check format
run: pnpm run check:format
- name: Build Affected
run: pnpm turbo run build --affected
- name: Build apps
run: pnpm run build
+1 -1
View File
@@ -14,7 +14,7 @@ strict-peer-dependencies=false
# Turbo occasionally performs postinstall tasks for optimal performance
# moved to pnpm-workspace.yaml: onlyBuiltDependencies (e.g., allow turbo)
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=eslint
public-hoist-pattern[]=prettier
public-hoist-pattern[]=typescript
+1
View File
@@ -0,0 +1 @@
lts/jod
-12
View File
@@ -1,12 +0,0 @@
.next/*
out/*
public/*
dist/*
node_modules/*
.turbo/*
.env*
.env
.env.local
.env.development
.env.production
.env.test
+1
View File
@@ -1,4 +1,5 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
parser: "@typescript-eslint/parser",
};
@@ -9,7 +9,7 @@ import { useInstance } from "@/hooks/store";
// components
import { InstanceEmailForm } from "./email-config-form";
const InstanceEmailPage: React.FC = observer(() => {
const InstanceEmailPage = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance();
@@ -29,7 +29,7 @@ const InstanceEmailPage: React.FC = observer(() => {
message: "Email feature has been disabled",
type: TOAST_TYPE.SUCCESS,
});
} catch (_error) {
} catch (error) {
setToast({
title: "Error disabling email",
message: "Failed to disable email feature. Please try again.",
@@ -25,8 +25,9 @@ export const EmailCodesConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch
value={Boolean(parseInt(enableMagicLogin))}
onChange={() => {
const newEnableMagicLogin = Boolean(parseInt(enableMagicLogin)) === true ? "0" : "1";
updateConfig("ENABLE_MAGIC_LINK_LOGIN", newEnableMagicLogin);
Boolean(parseInt(enableMagicLogin)) === true
? updateConfig("ENABLE_MAGIC_LINK_LOGIN", "0")
: updateConfig("ENABLE_MAGIC_LINK_LOGIN", "1");
}}
size="sm"
disabled={disabled}
@@ -35,8 +35,9 @@ export const GithubConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch
value={Boolean(parseInt(enableGithubConfig))}
onChange={() => {
const newEnableGithubConfig = Boolean(parseInt(enableGithubConfig)) === true ? "0" : "1";
updateConfig("IS_GITHUB_ENABLED", newEnableGithubConfig);
Boolean(parseInt(enableGithubConfig)) === true
? updateConfig("IS_GITHUB_ENABLED", "0")
: updateConfig("IS_GITHUB_ENABLED", "1");
}}
size="sm"
disabled={disabled}
@@ -35,8 +35,9 @@ export const GitlabConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch
value={Boolean(parseInt(enableGitlabConfig))}
onChange={() => {
const newEnableGitlabConfig = Boolean(parseInt(enableGitlabConfig)) === true ? "0" : "1";
updateConfig("IS_GITLAB_ENABLED", newEnableGitlabConfig);
Boolean(parseInt(enableGitlabConfig)) === true
? updateConfig("IS_GITLAB_ENABLED", "0")
: updateConfig("IS_GITLAB_ENABLED", "1");
}}
size="sm"
disabled={disabled}
@@ -35,8 +35,9 @@ export const GoogleConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch
value={Boolean(parseInt(enableGoogleConfig))}
onChange={() => {
const newEnableGoogleConfig = Boolean(parseInt(enableGoogleConfig)) === true ? "0" : "1";
updateConfig("IS_GOOGLE_ENABLED", newEnableGoogleConfig);
Boolean(parseInt(enableGoogleConfig)) === true
? updateConfig("IS_GOOGLE_ENABLED", "0")
: updateConfig("IS_GOOGLE_ENABLED", "1");
}}
size="sm"
disabled={disabled}
@@ -25,8 +25,9 @@ export const PasswordLoginConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch
value={Boolean(parseInt(enableEmailPassword))}
onChange={() => {
const newEnableEmailPassword = Boolean(parseInt(enableEmailPassword)) === true ? "0" : "1";
updateConfig("ENABLE_EMAIL_PASSWORD", newEnableEmailPassword);
Boolean(parseInt(enableEmailPassword)) === true
? updateConfig("ENABLE_EMAIL_PASSWORD", "0")
: updateConfig("ENABLE_EMAIL_PASSWORD", "1");
}}
size="sm"
disabled={disabled}
+1 -1
View File
@@ -209,7 +209,7 @@ export class InstanceStore implements IInstanceStore {
});
});
await this.instanceService.disableEmail();
} catch (_error) {
} catch (error) {
console.error("Error disabling the email");
this.instanceConfigurations = instanceConfigurations;
}
+2 -2
View File
@@ -31,7 +31,7 @@
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "14.2.32",
"next": "14.2.30",
"next-themes": "^0.2.1",
"postcss": "^8.4.49",
"react": "^18.3.1",
@@ -45,7 +45,7 @@
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/lodash": "4.17.20",
"@types/lodash": "^4.17.6",
"@types/node": "18.16.1",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
-1
View File
@@ -91,7 +91,6 @@ class BaseSerializer(serializers.ModelSerializer):
"project_lead": UserLiteSerializer,
"state": StateLiteSerializer,
"created_by": UserLiteSerializer,
"updated_by": UserLiteSerializer,
"issue": IssueSerializer,
"actor": UserLiteSerializer,
"owned_by": UserLiteSerializer,
+10 -13
View File
@@ -24,6 +24,7 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -88,24 +89,20 @@ class IssueSerializer(BaseSerializer):
raise serializers.ValidationError("Invalid HTML passed")
# Validate description content for security
if data.get("description_html"):
is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
if data.get("description"):
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
raise serializers.ValidationError({"description": error_msg})
if data.get("description_html"):
is_valid, error_msg = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if data.get("description_binary"):
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": error_msg})
# Validate assignees are from project
if data.get("assignees", []):
+17 -7
View File
@@ -12,6 +12,7 @@ from plane.db.models import (
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
)
from .base import BaseSerializer
@@ -199,18 +200,27 @@ class ProjectSerializer(BaseSerializer):
)
# Validate description content for security
if "description" in data and data["description"]:
# For Project, description might be text field, not JSON
if isinstance(data["description"], dict):
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if "description_text" in data and data["description_text"]:
is_valid, error_msg = validate_json_content(data["description_text"])
if not is_valid:
raise serializers.ValidationError({"description_text": error_msg})
if "description_html" in data and data["description_html"]:
if isinstance(data["description_html"], dict):
is_valid, error_msg, sanitized_html = validate_html_content(
is_valid, error_msg = validate_json_content(data["description_html"])
else:
is_valid, error_msg = validate_html_content(
str(data["description_html"])
)
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
raise serializers.ValidationError({"description_html": error_msg})
return data
+10 -13
View File
@@ -23,6 +23,7 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
from plane.app.permissions import ROLE
@@ -75,24 +76,20 @@ class DraftIssueCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
attrs["description_html"]
)
if "description" in attrs and attrs["description"]:
is_valid, error_msg = validate_json_content(attrs["description"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the attrs with sanitized HTML if available
if sanitized_html is not None:
attrs["description_html"] = sanitized_html
raise serializers.ValidationError({"description": error_msg})
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg = validate_html_content(attrs["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if "description_binary" in attrs and attrs["description_binary"]:
is_valid, error_msg = validate_binary_data(attrs["description_binary"])
if not is_valid:
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": error_msg})
# Validate assignees are from project
if attrs.get("assignee_ids", []):
+10 -13
View File
@@ -43,6 +43,7 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -127,24 +128,20 @@ class IssueCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
attrs["description_html"]
)
if "description" in attrs and attrs["description"]:
is_valid, error_msg = validate_json_content(attrs["description"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the attrs with sanitized HTML if available
if sanitized_html is not None:
attrs["description_html"] = sanitized_html
raise serializers.ValidationError({"description": error_msg})
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg = validate_html_content(attrs["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if "description_binary" in attrs and attrs["description_binary"]:
is_valid, error_msg = validate_binary_data(attrs["description_binary"])
if not is_valid:
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": error_msg})
# Validate assignees are from project
if attrs.get("assignee_ids", []):
+14 -3
View File
@@ -7,6 +7,7 @@ from .base import BaseSerializer
from plane.utils.content_validator import (
validate_binary_data,
validate_html_content,
validate_json_content,
)
from plane.db.models import (
Page,
@@ -228,13 +229,23 @@ class PageBinaryUpdateSerializer(serializers.Serializer):
return value
# Use the validation function from utils
is_valid, error_message, sanitized_html = validate_html_content(value)
is_valid, error_message = validate_html_content(value)
if not is_valid:
raise serializers.ValidationError(error_message)
# Return sanitized HTML if available, otherwise return original
return sanitized_html if sanitized_html is not None else value
return value
def validate_description(self, value):
"""Validate the JSON description"""
if not value:
return value
# Use the validation function from utils
is_valid, error_message = validate_json_content(value)
if not is_valid:
raise serializers.ValidationError(error_message)
return value
def update(self, instance, validated_data):
"""Update the page instance with validated data"""
+19 -9
View File
@@ -15,6 +15,7 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -64,18 +65,27 @@ class ProjectSerializer(BaseSerializer):
def validate(self, data):
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
str(data["description_html"])
)
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
if "description" in data and data["description"]:
# For Project, description might be text field, not JSON
if isinstance(data["description"], dict):
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if "description_text" in data and data["description_text"]:
is_valid, error_msg = validate_json_content(data["description_text"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
raise serializers.ValidationError({"description_text": error_msg})
if "description_html" in data and data["description_html"]:
if isinstance(data["description_html"], dict):
is_valid, error_msg = validate_json_content(data["description_html"])
else:
is_valid, error_msg = validate_html_content(
str(data["description_html"])
)
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
return data
+10 -13
View File
@@ -26,6 +26,7 @@ from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.utils.url import contains_url
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -318,24 +319,20 @@ class StickySerializer(BaseSerializer):
def validate(self, data):
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
if "description" in data and data["description"]:
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
raise serializers.ValidationError({"description": error_msg})
if "description_html" in data and data["description_html"]:
is_valid, error_msg = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if "description_binary" in data and data["description_binary"]:
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
raise serializers.ValidationError({"description_binary": error_msg})
return data
+2 -10
View File
@@ -441,11 +441,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition="attachment",
filename=asset.attributes.get("name"),
)
signed_url = storage.generate_presigned_url(object_name=asset.asset.name)
# Redirect to the signed URL
return HttpResponseRedirect(signed_url)
@@ -645,11 +641,7 @@ class ProjectAssetEndpoint(BaseAPIView):
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition="attachment",
filename=asset.attributes.get("name"),
)
signed_url = storage.generate_presigned_url(object_name=asset.asset.name)
# Redirect to the signed URL
return HttpResponseRedirect(signed_url)
+7 -9
View File
@@ -198,7 +198,6 @@ class PageViewSet(BaseViewSet):
def retrieve(self, request, slug, project_id, pk=None):
page = self.get_queryset().filter(pk=pk).first()
project = Project.objects.get(pk=project_id)
track_visit = request.query_params.get("track_visit", "true").lower() == "true"
"""
if the role is guest and guest_view_all_features is false and owned by is not
@@ -231,14 +230,13 @@ class PageViewSet(BaseViewSet):
).values_list("entity_identifier", flat=True)
data = PageDetailSerializer(page).data
data["issue_ids"] = issue_ids
if track_visit:
recent_visited_task.delay(
slug=slug,
entity_name="page",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
recent_visited_task.delay(
slug=slug,
entity_name="page",
entity_identifier=pk,
user_id=request.user.id,
project_id=project_id,
)
return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
+1 -3
View File
@@ -172,14 +172,12 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
{"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND
)
project_id = request.data.get("project_id", issue.project_id)
serializer = DraftIssueCreateSerializer(
issue,
data=request.data,
partial=True,
context={
"project_id": project_id,
"project_id": request.data.get("project_id", None),
"cycle_id": request.data.get("cycle_id", "not_provided"),
},
)
+24 -24
View File
@@ -67,7 +67,7 @@ def flush_to_mongo_and_delete(
mongo_archival_failed = False
# Try to insert into MongoDB if available
if mongo_collection is not None and mongo_available:
if mongo_collection and mongo_available:
try:
mongo_collection.bulk_write([InsertOne(doc) for doc in buffer])
except BulkWriteError as bwe:
@@ -166,9 +166,9 @@ def process_cleanup_task(
def transform_api_log(record: Dict) -> Dict:
"""Transform API activity log record."""
return {
"id": str(record["id"]),
"id": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"token_identifier": str(record["token_identifier"]),
"token_identifier": record["token_identifier"],
"path": record["path"],
"method": record["method"],
"query_params": record.get("query_params"),
@@ -178,18 +178,18 @@ def transform_api_log(record: Dict) -> Dict:
"response_body": record["response_body"],
"ip_address": record["ip_address"],
"user_agent": record["user_agent"],
"created_by_id": str(record["created_by_id"]),
"created_by_id": record["created_by_id"],
}
def transform_email_log(record: Dict) -> Dict:
"""Transform email notification log record."""
return {
"id": str(record["id"]),
"id": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"receiver_id": str(record["receiver_id"]),
"triggered_by_id": str(record["triggered_by_id"]),
"entity_identifier": str(record["entity_identifier"]),
"receiver_id": record["receiver_id"],
"triggered_by_id": record["triggered_by_id"],
"entity_identifier": record["entity_identifier"],
"entity_name": record["entity_name"],
"data": record["data"],
"processed_at": (
@@ -197,27 +197,27 @@ def transform_email_log(record: Dict) -> Dict:
),
"sent_at": str(record["sent_at"]) if record.get("sent_at") else None,
"entity": record["entity"],
"old_value": str(record["old_value"]),
"new_value": str(record["new_value"]),
"created_by_id": str(record["created_by_id"]),
"old_value": record["old_value"],
"new_value": record["new_value"],
"created_by_id": record["created_by_id"],
}
def transform_page_version(record: Dict) -> Dict:
"""Transform page version record."""
return {
"id": str(record["id"]),
"id": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"page_id": str(record["page_id"]),
"workspace_id": str(record["workspace_id"]),
"owned_by_id": str(record["owned_by_id"]),
"page_id": record["page_id"],
"workspace_id": record["workspace_id"],
"owned_by_id": record["owned_by_id"],
"description_html": record["description_html"],
"description_binary": record["description_binary"],
"description_stripped": record["description_stripped"],
"description_json": record["description_json"],
"sub_pages_data": record["sub_pages_data"],
"created_by_id": str(record["created_by_id"]),
"updated_by_id": str(record["updated_by_id"]),
"created_by_id": record["created_by_id"],
"updated_by_id": record["updated_by_id"],
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
"last_saved_at": (
str(record["last_saved_at"]) if record.get("last_saved_at") else None
@@ -228,14 +228,14 @@ def transform_page_version(record: Dict) -> Dict:
def transform_issue_description_version(record: Dict) -> Dict:
"""Transform issue description version record."""
return {
"id": str(record["id"]),
"id": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"issue_id": str(record["issue_id"]),
"workspace_id": str(record["workspace_id"]),
"project_id": str(record["project_id"]),
"created_by_id": str(record["created_by_id"]),
"updated_by_id": str(record["updated_by_id"]),
"owned_by_id": str(record["owned_by_id"]),
"issue_id": record["issue_id"],
"workspace_id": record["workspace_id"],
"project_id": record["project_id"],
"created_by_id": record["created_by_id"],
"updated_by_id": record["updated_by_id"],
"owned_by_id": record["owned_by_id"],
"last_saved_at": (
str(record["last_saved_at"]) if record.get("last_saved_at") else None
),
@@ -1,30 +0,0 @@
# Generated by Django 4.2.22 on 2025-08-29 11:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0101_description_descriptionversion"),
]
operations = [
migrations.AddField(
model_name="page",
name="sort_order",
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name="pagelog",
name="entity_type",
field=models.CharField(
blank=True, max_length=30, null=True, verbose_name="Entity Type"
),
),
migrations.AlterField(
model_name="pagelog",
name="entity_identifier",
field=models.UUIDField(blank=True, null=True),
),
]
+1 -3
View File
@@ -57,7 +57,6 @@ class Page(BaseModel):
)
moved_to_page = models.UUIDField(null=True, blank=True)
moved_to_project = models.UUIDField(null=True, blank=True)
sort_order = models.FloatField(default=65535)
external_id = models.CharField(max_length=255, null=True, blank=True)
external_source = models.CharField(max_length=255, null=True, blank=True)
@@ -99,9 +98,8 @@ class PageLog(BaseModel):
)
transaction = models.UUIDField(default=uuid.uuid4)
page = models.ForeignKey(Page, related_name="page_log", on_delete=models.CASCADE)
entity_identifier = models.UUIDField(null=True, blank=True)
entity_identifier = models.UUIDField(null=True)
entity_name = models.CharField(max_length=30, verbose_name="Transaction Type")
entity_type = models.CharField(max_length=30, verbose_name="Entity Type", null=True, blank=True)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log"
)
-4
View File
@@ -465,7 +465,3 @@ if ENABLE_DRF_SPECTACULAR:
REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema"
INSTALLED_APPS.append("drf_spectacular")
from .openapi import SPECTACULAR_SETTINGS # noqa: F401
# MongoDB Settings
MONGO_DB_URL = os.environ.get("MONGO_DB_URL", False)
MONGO_DB_DATABASE = os.environ.get("MONGO_DB_DATABASE", False)
-3
View File
@@ -118,7 +118,4 @@ class MongoConnection:
Returns:
bool: True if MongoDB is configured and connected, False otherwise
"""
if cls._client is None:
cls._instance = cls()
return cls._client is not None and cls._db is not None
+10 -11
View File
@@ -30,6 +30,7 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -289,22 +290,20 @@ class IssueCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
if "description" in data and data["description"]:
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
raise serializers.ValidationError({"description": error_msg})
if "description_html" in data and data["description_html"]:
is_valid, error_msg = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if "description_binary" in data and data["description_binary"]:
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
raise serializers.ValidationError({"description_binary": error_msg})
return data
+270 -11
View File
@@ -1,11 +1,36 @@
# Python imports
import base64
import nh3
from plane.utils.exception_logger import log_exception
import json
import re
# Maximum allowed size for binary data (10MB)
MAX_SIZE = 10 * 1024 * 1024
# Maximum recursion depth to prevent stack overflow
MAX_RECURSION_DEPTH = 20
# Dangerous text patterns that could indicate XSS or script injection
DANGEROUS_TEXT_PATTERNS = [
r"<script[^>]*>.*?</script>",
r"javascript\s*:",
r"data\s*:\s*text/html",
r"eval\s*\(",
r"document\s*\.",
r"window\s*\.",
r"location\s*\.",
]
# Dangerous attribute patterns for HTML attributes
DANGEROUS_ATTR_PATTERNS = [
r"javascript\s*:",
r"data\s*:\s*text/html",
r"eval\s*\(",
r"alert\s*\(",
r"document\s*\.",
r"window\s*\.",
]
# Suspicious patterns for binary data content
SUSPICIOUS_BINARY_PATTERNS = [
"<html",
@@ -16,6 +41,70 @@ SUSPICIOUS_BINARY_PATTERNS = [
"<iframe",
]
# Malicious HTML patterns for content validation
MALICIOUS_HTML_PATTERNS = [
# Script tags with any content
r"<script[^>]*>",
r"</script>",
# JavaScript URLs in various attributes
r'(?:href|src|action)\s*=\s*["\']?\s*javascript:',
# Data URLs with text/html (potential XSS)
r'(?:href|src|action)\s*=\s*["\']?\s*data:text/html',
# Dangerous event handlers with JavaScript-like content
r'on(?:load|error|click|focus|blur|change|submit|reset|select|resize|scroll|unload|beforeunload|hashchange|popstate|storage|message|offline|online)\s*=\s*["\']?[^"\']*(?:javascript|alert|eval|document\.|window\.|location\.|history\.)[^"\']*["\']?',
# Object and embed tags that could load external content
r"<(?:object|embed)[^>]*(?:data|src)\s*=",
# Base tag that could change relative URL resolution
r"<base[^>]*href\s*=",
# Dangerous iframe sources
r'<iframe[^>]*src\s*=\s*["\']?(?:javascript:|data:text/html)',
# Meta refresh redirects
r'<meta[^>]*http-equiv\s*=\s*["\']?refresh["\']?',
# Link tags - simplified patterns
r'<link[^>]*rel\s*=\s*["\']?stylesheet["\']?',
r'<link[^>]*href\s*=\s*["\']?https?://',
r'<link[^>]*href\s*=\s*["\']?//',
r'<link[^>]*href\s*=\s*["\']?(?:data:|javascript:)',
# Style tags with external imports
r"<style[^>]*>.*?@import.*?(?:https?://|//)",
# Link tags with dangerous rel types
r'<link[^>]*rel\s*=\s*["\']?(?:import|preload|prefetch|dns-prefetch|preconnect)["\']?',
# Forms with action attributes
r"<form[^>]*action\s*=",
]
# Dangerous JavaScript patterns for event handlers
DANGEROUS_JS_PATTERNS = [
r"alert\s*\(",
r"eval\s*\(",
r"document\s*\.",
r"window\s*\.",
r"location\s*\.",
r"fetch\s*\(",
r"XMLHttpRequest",
r"innerHTML\s*=",
r"outerHTML\s*=",
r"document\.write",
r"script\s*>",
]
# HTML self-closing tags that don't need closing tags
SELF_CLOSING_TAGS = {
"img",
"br",
"hr",
"input",
"meta",
"link",
"area",
"base",
"col",
"embed",
"source",
"track",
"wbr",
}
def validate_binary_data(data):
"""
@@ -60,21 +149,191 @@ def validate_binary_data(data):
return True, None
def validate_html_content(html_content: str):
def validate_html_content(html_content):
"""
Sanitize HTML content using nh3.
Returns a tuple: (is_valid, error_message, clean_html)
Validate that HTML content is safe and doesn't contain malicious patterns.
Args:
html_content (str): The HTML content to validate
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
if not html_content:
return True, None, None
return True, None # Empty is OK
# Size check - 10MB limit (consistent with binary validation)
if len(html_content.encode("utf-8")) > MAX_SIZE:
return False, "HTML content exceeds maximum size limit (10MB)", None
return False, "HTML content exceeds maximum size limit (10MB)"
# Check for specific malicious patterns (simplified and more reliable)
for pattern in MALICIOUS_HTML_PATTERNS:
if re.search(pattern, html_content, re.IGNORECASE | re.DOTALL):
return (
False,
f"HTML content contains potentially malicious patterns: {pattern}",
)
# Additional check for inline event handlers that contain suspicious content
# This is more permissive - only blocks if the event handler contains actual dangerous code
event_handler_pattern = r'on\w+\s*=\s*["\']([^"\']*)["\']'
event_matches = re.findall(event_handler_pattern, html_content, re.IGNORECASE)
for handler_content in event_matches:
for js_pattern in DANGEROUS_JS_PATTERNS:
if re.search(js_pattern, handler_content, re.IGNORECASE):
return (
False,
f"HTML content contains dangerous JavaScript in event handler: {handler_content[:100]}",
)
return True, None
def validate_json_content(json_content):
"""
Validate that JSON content is safe and doesn't contain malicious patterns.
Args:
json_content (dict): The JSON content to validate
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
if not json_content:
return True, None # Empty is OK
try:
clean_html = nh3.clean(html_content)
return True, None, clean_html
# Size check - 10MB limit (consistent with other validations)
json_str = json.dumps(json_content)
if len(json_str.encode("utf-8")) > MAX_SIZE:
return False, "JSON content exceeds maximum size limit (10MB)"
# Basic structure validation for page description JSON
if isinstance(json_content, dict):
# Check for expected page description structure
# This is based on ProseMirror/Tiptap JSON structure
if "type" in json_content and json_content.get("type") == "doc":
# Valid document structure
if "content" in json_content and isinstance(
json_content["content"], list
):
# Recursively check content for suspicious patterns
is_valid, error_msg = _validate_json_content_array(
json_content["content"]
)
if not is_valid:
return False, error_msg
elif "type" not in json_content and "content" not in json_content:
# Allow other JSON structures but validate for suspicious content
is_valid, error_msg = _validate_json_content_recursive(json_content)
if not is_valid:
return False, error_msg
else:
return False, "JSON description must be a valid object"
except (TypeError, ValueError) as e:
return False, "Invalid JSON structure"
except Exception as e:
log_exception(e)
return False, "Failed to sanitize HTML", None
return False, "Failed to validate JSON content"
return True, None
def _validate_json_content_array(content, depth=0):
"""
Validate JSON content array for suspicious patterns.
Args:
content (list): Array of content nodes to validate
depth (int): Current recursion depth (default: 0)
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
# Check recursion depth to prevent stack overflow
if depth > MAX_RECURSION_DEPTH:
return False, f"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded"
if not isinstance(content, list):
return True, None
for node in content:
if isinstance(node, dict):
# Check text content for suspicious patterns (more targeted)
if node.get("type") == "text" and "text" in node:
text_content = node["text"]
for pattern in DANGEROUS_TEXT_PATTERNS:
if re.search(pattern, text_content, re.IGNORECASE):
return (
False,
"JSON content contains suspicious script patterns in text",
)
# Check attributes for suspicious content (more targeted)
if "attrs" in node and isinstance(node["attrs"], dict):
for attr_name, attr_value in node["attrs"].items():
if isinstance(attr_value, str):
# Only check specific attributes that could be dangerous
if attr_name.lower() in [
"href",
"src",
"action",
"onclick",
"onload",
"onerror",
]:
for pattern in DANGEROUS_ATTR_PATTERNS:
if re.search(pattern, attr_value, re.IGNORECASE):
return (
False,
f"JSON content contains dangerous pattern in {attr_name} attribute",
)
# Recursively check nested content
if "content" in node and isinstance(node["content"], list):
is_valid, error_msg = _validate_json_content_array(
node["content"], depth + 1
)
if not is_valid:
return False, error_msg
return True, None
def _validate_json_content_recursive(obj, depth=0):
"""
Recursively validate JSON object for suspicious content.
Args:
obj: JSON object (dict, list, or primitive) to validate
depth (int): Current recursion depth (default: 0)
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
# Check recursion depth to prevent stack overflow
if depth > MAX_RECURSION_DEPTH:
return False, f"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded"
if isinstance(obj, dict):
for key, value in obj.items():
if isinstance(value, str):
# Check for dangerous patterns using module constants
for pattern in DANGEROUS_TEXT_PATTERNS:
if re.search(pattern, value, re.IGNORECASE):
return (
False,
"JSON content contains suspicious script patterns",
)
elif isinstance(value, (dict, list)):
is_valid, error_msg = _validate_json_content_recursive(value, depth + 1)
if not is_valid:
return False, error_msg
elif isinstance(obj, list):
for item in obj:
is_valid, error_msg = _validate_json_content_recursive(item, depth + 1)
if not is_valid:
return False, error_msg
return True, None
-2
View File
@@ -69,5 +69,3 @@ opentelemetry-instrumentation-django==0.49b1
opentelemetry-exporter-otlp==1.28.1
# OpenAPI Specification
drf-spectacular==0.28.0
# html sanitizer
nh3==0.2.18
-4
View File
@@ -1,4 +0,0 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/server.js"],
};
+5
View File
@@ -0,0 +1,5 @@
{
"root": true,
"extends": ["@plane/eslint-config/server.js"],
"parser": "@typescript-eslint/parser"
}
+7 -4
View File
@@ -1,17 +1,20 @@
// Third-party libraries
import { Redis } from "ioredis";
// Hocuspocus extensions and core
import { Database } from "@hocuspocus/extension-database";
import { Extension } from "@hocuspocus/server";
import { Logger } from "@hocuspocus/extension-logger";
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
import { Extension } from "@hocuspocus/server";
import { Redis } from "ioredis";
// core helpers and utilities
import { manualLogger } from "@/core/helpers/logger.js";
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
// core libraries
import { fetchPageDescriptionBinary, updatePageDescription } from "@/core/lib/page.js";
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common.js";
// plane live libraries
import { fetchDocument } from "@/plane-live/lib/fetch-document.js";
import { updateDocument } from "@/plane-live/lib/update-document.js";
// types
import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common.js";
export const getExtensions: () => Promise<Extension[]> = async () => {
const extensions: Extension[] = [
+5 -5
View File
@@ -1,12 +1,12 @@
import { Server } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
// editor types
import { TUserDetails } from "@plane/editor";
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
// extensions
import { getExtensions } from "@/core/extensions/index.js";
// lib
import { handleAuthentication } from "@/core/lib/authentication.js";
// extensions
import { getExtensions } from "@/core/extensions/index.js";
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
// editor types
import { TUserDetails } from "@plane/editor";
// types
import { type HocusPocusServerContext } from "@/core/types/common.js";
+2 -2
View File
@@ -1,7 +1,7 @@
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
// services
import { UserService } from "@/core/services/user.service.js";
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
const userService = new UserService();
+2 -2
View File
@@ -1,13 +1,13 @@
import compression from "compression";
import cors from "cors";
import express, { Request, Response } from "express";
import expressWs from "express-ws";
import express, { Request, Response } from "express";
import helmet from "helmet";
// hocuspocus server
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers
import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document.js";
import { logger, manualLogger } from "@/core/helpers/logger.js";
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common.js";
-12
View File
@@ -1,12 +0,0 @@
.next/*
out/*
public/*
dist/*
node_modules/*
.turbo/*
.env*
.env
.env.local
.env.development
.env.production
.env.test
+2
View File
@@ -1,4 +1,6 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
parser: "@typescript-eslint/parser",
};
+3 -2
View File
@@ -41,7 +41,7 @@
"mobx": "^6.10.0",
"mobx-react": "^9.1.1",
"mobx-utils": "^6.0.8",
"next": "14.2.32",
"next": "14.2.30",
"next-themes": "^0.2.1",
"nprogress": "^0.2.0",
"react": "^18.3.1",
@@ -58,12 +58,13 @@
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/lodash": "4.17.20",
"@types/lodash": "^4.17.6",
"@types/node": "18.14.1",
"@types/nprogress": "^0.2.0",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^8.36.0",
"typescript": "5.8.3"
}
}
+1 -10
View File
@@ -1,13 +1,4 @@
.next/*
out/*
public/*
core/local-db/worker/wa-sqlite/src/*
dist/*
node_modules/*
.turbo/*
.env*
.env
.env.local
.env.development
.env.production
.env.test
core/local-db/worker/wa-sqlite/src/*
+2
View File
@@ -1,4 +1,6 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
parser: "@typescript-eslint/parser",
};
@@ -1,24 +1,36 @@
import { FC } from "react";
import { FC, useEffect, useRef } from "react";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
// plane helpers
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useOutsideClickDetector } from "@plane/hooks";
// components
import { SidebarWrapper } from "@/components/sidebar/sidebar-wrapper";
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
import { SidebarDropdown } from "@/components/workspace/sidebar/dropdown";
import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu";
import { HelpMenu } from "@/components/workspace/sidebar/help-menu";
import { SidebarProjectsList } from "@/components/workspace/sidebar/projects-list";
import { SidebarQuickActions } from "@/components/workspace/sidebar/quick-actions";
import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useFavorite } from "@/hooks/store/use-favorite";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRail } from "@/hooks/use-app-rail";
import useSize from "@/hooks/use-window-size";
// plane web components
import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge";
import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list";
export const AppSidebar: FC = observer(() => {
// store hooks
const { allowPermissions } = useUserPermissions();
const { toggleSidebar, sidebarCollapsed } = useAppTheme();
const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail();
const { groupedFavorites } = useFavorite();
const windowSize = useSize();
// refs
const ref = useRef<HTMLDivElement>(null);
// derived values
const canPerformWorkspaceMemberActions = allowPermissions(
@@ -26,17 +38,55 @@ export const AppSidebar: FC = observer(() => {
EUserPermissionsLevel.WORKSPACE
);
useOutsideClickDetector(ref, () => {
if (sidebarCollapsed === false) {
if (window.innerWidth < 768) {
toggleSidebar();
}
}
});
useEffect(() => {
if (windowSize[0] < 768 && !sidebarCollapsed) toggleSidebar();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [windowSize]);
const isFavoriteEmpty = isEmpty(groupedFavorites);
return (
<SidebarWrapper title="Projects" quickActions={<SidebarQuickActions />}>
<SidebarMenuItems />
{/* Favorites Menu */}
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
{/* Teams List */}
<SidebarTeamsList />
{/* Projects List */}
<SidebarProjectsList />
</SidebarWrapper>
<>
<div className="flex flex-col gap-3 px-3">
{/* Workspace switcher and settings */}
{!shouldRenderAppRail && <SidebarDropdown />}
{isAppRailEnabled && (
<div className="flex items-center justify-between gap-2">
<span className="text-md text-custom-text-200 font-medium pt-1">Projects</span>
<div className="flex items-center gap-2">
<AppSidebarToggleButton />
</div>
</div>
)}
{/* Quick actions */}
<SidebarQuickActions />
</div>
<div className="flex flex-col gap-3 overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto vertical-scrollbar px-3 pt-3 pb-0.5">
<SidebarMenuItems />
{/* Favorites Menu */}
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
{/* Teams List */}
<SidebarTeamsList />
{/* Projects List */}
<SidebarProjectsList />
</div>
{/* Help Section */}
<div className="flex items-center justify-between p-3 border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12">
<WorkspaceEditionBadge />
<div className="flex items-center gap-2">
{!shouldRenderAppRail && <HelpMenu />}
{!isAppRailEnabled && <AppSidebarToggleButton />}
</div>
</div>
</>
);
});
@@ -232,6 +232,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
...getChangedIssuefields(formData, dirtyFields as { [key: string]: boolean | undefined }),
project_id: getValues<"project_id">("project_id"),
id: data.id,
description_html: formData.description_html ?? "<p></p>",
type_id: getValues<"type_id">("type_id"),
};
@@ -19,6 +19,7 @@ export type TBasePaidPlanCardProps = {
extraFeatures?: string | React.ReactNode;
renderPriceContent: (price: TSubscriptionPrice) => React.ReactNode;
renderActionButton: (price: TSubscriptionPrice) => React.ReactNode;
isSelfHosted: boolean;
};
export const BasePaidPlanCard: FC<TBasePaidPlanCardProps> = observer((props) => {
@@ -30,10 +31,11 @@ export const BasePaidPlanCard: FC<TBasePaidPlanCardProps> = observer((props) =>
extraFeatures,
renderPriceContent,
renderActionButton,
isSelfHosted,
} = props;
// states
const [selectedPlan, setSelectedPlan] = useState<TBillingFrequency>("month");
const basePlan = getBaseSubscriptionName(planVariant);
const basePlan = getBaseSubscriptionName(planVariant, isSelfHosted);
const upgradeCardVariantStyle = getUpgradeCardVariantStyle(planVariant);
// Plane details
const planeName = getSubscriptionName(planVariant);
@@ -107,6 +107,7 @@ export const PlanUpgradeCard: FC<PlanUpgradeCardProps> = observer((props) => {
isTrialAllowed={isTrialAllowed}
/>
)}
isSelfHosted={isSelfHosted}
/>
);
});
@@ -107,6 +107,7 @@ export const TalkToSalesCard: FC<TalkToSalesCardProps> = observer((props) => {
extraFeatures={extraFeatures}
renderPriceContent={renderPriceContent}
renderActionButton={renderActionButton}
isSelfHosted={isSelfHosted}
/>
);
});
@@ -1,72 +0,0 @@
import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
// components
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
import { SidebarDropdown } from "@/components/workspace/sidebar/dropdown";
import { HelpMenu } from "@/components/workspace/sidebar/help-menu";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useAppRail } from "@/hooks/use-app-rail";
import useSize from "@/hooks/use-window-size";
// plane web components
import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge";
type TSidebarWrapperProps = {
title: string;
children: React.ReactNode;
quickActions?: React.ReactNode;
};
export const SidebarWrapper: FC<TSidebarWrapperProps> = observer((props) => {
const { children, title, quickActions } = props;
// store hooks
const { toggleSidebar, sidebarCollapsed } = useAppTheme();
const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail();
const windowSize = useSize();
// refs
const ref = useRef<HTMLDivElement>(null);
useOutsideClickDetector(ref, () => {
if (sidebarCollapsed === false && window.innerWidth < 768) {
toggleSidebar();
}
});
useEffect(() => {
if (windowSize[0] < 768 && !sidebarCollapsed) toggleSidebar();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [windowSize]);
return (
<div ref={ref} className="flex flex-col h-full w-full">
<div className="flex flex-col gap-3 px-3">
{/* Workspace switcher and settings */}
{!shouldRenderAppRail && <SidebarDropdown />}
{isAppRailEnabled && (
<div className="flex items-center justify-between gap-2">
<span className="text-md text-custom-text-200 font-medium pt-1">{title}</span>
<div className="flex items-center gap-2">
<AppSidebarToggleButton />
</div>
</div>
)}
{/* Quick actions */}
{quickActions}
</div>
<div className="flex flex-col gap-3 overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto vertical-scrollbar px-3 pt-3 pb-0.5">
{children}
</div>
{/* Help Section */}
<div className="flex items-center justify-between p-3 border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12">
<WorkspaceEditionBadge />
<div className="flex items-center gap-2">
{!shouldRenderAppRail && <HelpMenu />}
{!isAppRailEnabled && <AppSidebarToggleButton />}
</div>
</div>
</div>
);
});
@@ -7,7 +7,6 @@ import { useParams, usePathname } from "next/navigation";
// plane imports
import { EUserPermissionsLevel, IWorkspaceSidebarNavigationItem } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { joinUrlPath } from "@plane/utils";
// components
import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation";
import { NotificationAppSidebarOption } from "@/components/workspace-notifications/notification-app-sidebar-option";
@@ -48,13 +47,13 @@ export const SidebarItemBase: FC<Props> = observer(({ item, additionalRender, ad
const isPinned = sidebarPreference?.[item.key]?.is_pinned;
if (!isPinned && !staticItems.includes(item.key)) return null;
const itemHref =
item.key === "your_work" && data?.id ? joinUrlPath(slug, item.href, data?.id) : joinUrlPath(slug, item.href);
const itemHref = item.key === "your_work" ? `/${slug}${item.href}${data?.id}/` : `/${slug}${item.href}`;
const isActive = itemHref === pathname;
const icon = getSidebarNavigationItemIcon(item.key);
return (
<Link href={itemHref} onClick={handleLinkClick}>
<SidebarNavItem isActive={item.highlight(pathname, itemHref)}>
<SidebarNavItem isActive={isActive}>
<div className="flex items-center gap-1.5 py-[1px]">
{icon}
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
+12 -15
View File
@@ -1,4 +1,4 @@
// plane imports
// types
import { API_BASE_URL } from "@plane/constants";
import {
EIssueServiceType,
@@ -12,8 +12,12 @@ import {
type TIssuesResponse,
type TIssueSubIssues,
} from "@plane/types";
// helpers
import { getIssuesShouldFallbackToServer } from "@plane/utils";
import { persistence } from "@/local-db/storage.sqlite";
// services
import { addIssuesBulk, deleteIssueFromLocal, updateIssue } from "@/local-db/utils/load-issues";
import { APIService } from "@/services/api.service";
export class IssueService extends APIService {
@@ -81,7 +85,7 @@ export class IssueService extends APIService {
if (getIssuesShouldFallbackToServer(queries) || this.serviceType !== EIssueServiceType.ISSUES) {
return await this.getIssuesFromServer(workspaceSlug, projectId, queries, config);
}
const { persistence } = await import("@/local-db/storage.sqlite");
const response = await persistence.getIssues(workspaceSlug, projectId, queries, config);
return response as TIssuesResponse;
}
@@ -114,10 +118,9 @@ export class IssueService extends APIService {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/`, {
params: queries,
})
.then(async (response) => {
.then((response) => {
// skip issue update when the service type is epic
if (response.data && this.serviceType === EIssueServiceType.ISSUES) {
const { updateIssue } = await import("@/local-db/utils/load-issues");
updateIssue({ ...response.data, is_local_update: 1 });
}
// add is_epic flag when the service type is epic
@@ -135,9 +138,8 @@ export class IssueService extends APIService {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/list/`, {
params: { issues: issueIds.join(",") },
})
.then(async (response) => {
.then((response) => {
if (response?.data && Array.isArray(response?.data) && this.serviceType === EIssueServiceType.ISSUES) {
const { addIssuesBulk } = await import("@/local-db/utils/load-issues");
addIssuesBulk(response.data);
}
return response?.data;
@@ -244,7 +246,6 @@ export class IssueService extends APIService {
async deleteIssue(workspaceSlug: string, projectId: string, issuesId: string): Promise<any> {
if (this.serviceType === EIssueServiceType.ISSUES) {
const { deleteIssueFromLocal } = await import("@/local-db/utils/load-issues");
deleteIssueFromLocal(issuesId);
}
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issuesId}/`)
@@ -353,9 +354,8 @@ export class IssueService extends APIService {
async bulkOperations(workspaceSlug: string, projectId: string, data: TBulkOperationsPayload): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-operation-issues/`, data)
.then(async (response) => {
.then((response) => {
if (this.serviceType === EIssueServiceType.ISSUES) {
const { persistence } = await import("@/local-db/storage.sqlite");
persistence.syncIssues(projectId);
}
return response?.data;
@@ -373,9 +373,8 @@ export class IssueService extends APIService {
}
): Promise<any> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-delete-issues/`, data)
.then(async (response) => {
.then((response) => {
if (this.serviceType === EIssueServiceType.ISSUES) {
const { persistence } = await import("@/local-db/storage.sqlite");
persistence.syncIssues(projectId);
}
return response?.data;
@@ -395,9 +394,8 @@ export class IssueService extends APIService {
archived_at: string;
}> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-archive-issues/`, data)
.then(async (response) => {
.then((response) => {
if (this.serviceType === EIssueServiceType.ISSUES) {
const { persistence } = await import("@/local-db/storage.sqlite");
persistence.syncIssues(projectId);
}
return response?.data;
@@ -478,10 +476,9 @@ export class IssueService extends APIService {
return this.get(`/api/workspaces/${workspaceSlug}/work-items/${project_identifier}-${issue_sequence}/`, {
params: queries,
})
.then(async (response) => {
.then((response) => {
// skip issue update when the service type is epic
if (response.data && this.serviceType === EIssueServiceType.ISSUES) {
const { updateIssue } = await import("@/local-db/utils/load-issues");
updateIssue({ ...response.data, is_local_update: 1 });
}
// add is_epic flag when the service type is epic
@@ -23,12 +23,8 @@ export class ProjectPageService extends APIService {
});
}
async fetchById(workspaceSlug: string, projectId: string, pageId: string, trackVisit: boolean): Promise<TPage> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, {
params: {
track_visit: trackVisit,
},
})
async fetchById(workspaceSlug: string, projectId: string, pageId: string): Promise<TPage> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
@@ -95,7 +95,6 @@ export interface IIssueDetail
attachmentDeleteModalId: string | null;
// computed
isAnyModalOpen: boolean;
isPeekOpen: boolean;
// helper actions
getIsIssuePeeked: (issueId: string) => boolean;
// actions
@@ -189,7 +188,6 @@ export abstract class IssueDetail implements IIssueDetail {
lastWidgetAction: observable.ref,
// computed
isAnyModalOpen: computed,
isPeekOpen: computed,
// action
setPeekIssue: action,
setIssueLinkData: action,
@@ -237,10 +235,6 @@ export abstract class IssueDetail implements IIssueDetail {
);
}
get isPeekOpen() {
return !!this.peekIssue;
}
// helper actions
getIsIssuePeeked = (issueId: string) => this.peekIssue?.issueId === issueId;
@@ -49,12 +49,7 @@ export interface IProjectPageStore {
projectId: string,
pageType?: TPageNavigationTabs
) => Promise<TPage[] | undefined>;
fetchPageDetails: (
workspaceSlug: string,
projectId: string,
pageId: string,
options?: { trackVisit?: boolean }
) => Promise<TPage | undefined>;
fetchPageDetails: (workspaceSlug: string, projectId: string, pageId: string) => Promise<TPage | undefined>;
createPage: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
removePage: (pageId: string) => Promise<void>;
movePage: (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => Promise<void>;
@@ -244,9 +239,7 @@ export class ProjectPageStore implements IProjectPageStore {
* @description fetch the details of a page
* @param {string} pageId
*/
fetchPageDetails = async (...args: Parameters<IProjectPageStore["fetchPageDetails"]>) => {
const [workspaceSlug, projectId, pageId, options] = args;
const { trackVisit } = options || {};
fetchPageDetails = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
if (!workspaceSlug || !projectId || !pageId) return undefined;
@@ -256,7 +249,7 @@ export class ProjectPageStore implements IProjectPageStore {
this.error = undefined;
});
const page = await this.service.fetchById(workspaceSlug, projectId, pageId, trackVisit ?? true);
const page = await this.service.fetchById(workspaceSlug, projectId, pageId);
runInAction(() => {
if (page?.id) {
+2 -2
View File
@@ -47,7 +47,7 @@
"mobx": "^6.10.0",
"mobx-react": "^9.1.1",
"mobx-utils": "^6.0.8",
"next": "14.2.32",
"next": "14.2.30",
"next-themes": "^0.2.1",
"posthog-js": "^1.131.3",
"react": "^18.3.1",
@@ -72,7 +72,7 @@
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/lodash": "4.17.20",
"@types/lodash": "^4.17.6",
"@types/node": "18.16.1",
"@types/react": "^18.3.11",
"@types/react-color": "^3.0.6",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+4
View File
@@ -5,6 +5,10 @@
"version": "0.28.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --concurrency=18",
-4
View File
@@ -1,4 +0,0 @@
node_modules
build/*
dist/*
out/*
@@ -1,4 +1,5 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
};
-9
View File
@@ -244,7 +244,6 @@ export interface IWorkspaceSidebarNavigationItem {
labelTranslationKey: string;
href: string;
access: EUserWorkspaceRoles[];
highlight: (pathname: string, url: string) => boolean;
}
export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspaceSidebarNavigationItem> = {
@@ -253,28 +252,24 @@ export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspa
labelTranslationKey: "views",
href: `/workspace-views/all-issues/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
highlight: (pathname: string, url: string) => pathname === url,
},
analytics: {
key: "analytics",
labelTranslationKey: "analytics",
href: `/analytics/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, url: string) => pathname.includes(url),
},
drafts: {
key: "drafts",
labelTranslationKey: "drafts",
href: `/drafts/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, url: string) => pathname.includes(url),
},
archives: {
key: "archives",
labelTranslationKey: "archives",
href: `/projects/archives/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, url: string) => pathname.includes(url),
},
};
@@ -291,28 +286,24 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspac
labelTranslationKey: "home.title",
href: `/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
highlight: (pathname: string, url: string) => pathname === url,
},
inbox: {
key: "inbox",
labelTranslationKey: "notification.label",
href: `/notifications/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
highlight: (pathname: string, url: string) => pathname.includes(url),
},
"your-work": {
key: "your_work",
labelTranslationKey: "your_work",
href: `/profile/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, url: string) => pathname.includes(url),
},
projects: {
key: "projects",
labelTranslationKey: "projects",
href: `/projects/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
highlight: (pathname: string, url: string) => pathname === url,
},
};
+2
View File
@@ -1,4 +1,6 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
};
-5
View File
@@ -1,5 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}
+5 -1
View File
@@ -52,7 +52,11 @@ userController.registerRoutes(router);
### WebSocket Controller
```typescript
import { Controller, WebSocket, BaseWebSocketController } from "@plane/decorators";
import {
Controller,
WebSocket,
BaseWebSocketController,
} from "@plane/decorators";
import { Request } from "express";
import { WebSocket as WS } from "ws";
+16 -4
View File
@@ -11,23 +11,35 @@
"dist/**"
],
"scripts": {
"build": "tsc --noEmit && tsup --minify",
"dev": "tsup --watch",
"check:lint": "eslint . --max-warnings 1",
"build": "tsup src/index.ts --format esm,cjs --dts --external express,ws",
"dev": "tsup src/index.ts --format esm,cjs --watch --dts --external express,ws",
"check:lint": "eslint . --max-warnings 0",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"dependencies": {
"express": "^4.21.2",
"reflect-metadata": "^0.2.2"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/express": "^4.17.21",
"@types/node": "^20.14.9",
"@types/ws": "^8.5.10",
"reflect-metadata": "^0.2.2",
"tsup": "8.4.0",
"typescript": "5.8.3"
},
"peerDependencies": {
"express": ">=4.21.2",
"ws": ">=8.0.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
}
}
}
+28 -67
View File
@@ -1,9 +1,15 @@
import type { RequestHandler, Router, Request } from "express";
import type { WebSocket } from "ws";
import { RequestHandler, Router } from "express";
import "reflect-metadata";
type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "options" | "head" | "ws";
type HttpMethod =
| "get"
| "post"
| "put"
| "delete"
| "patch"
| "options"
| "head"
| "ws";
interface ControllerInstance {
[key: string]: unknown;
@@ -16,85 +22,40 @@ interface ControllerConstructor {
export function registerControllers(
router: Router,
controllers: ControllerConstructor[],
dependencies: any[] = []
Controller: ControllerConstructor,
): void {
controllers.forEach((Controller) => {
// Create the controller instance with dependencies
const instance = new Controller(...dependencies);
// Determine if it's a WebSocket controller or REST controller by checking
// if it has any methods with the "ws" method metadata
const isWebsocket = Object.getOwnPropertyNames(Controller.prototype).some((methodName) => {
if (methodName === "constructor") return false;
return Reflect.getMetadata("method", instance, methodName) === "ws";
});
if (isWebsocket) {
// Register as WebSocket controller
// Pass the existing instance with dependencies to avoid creating a new instance without them
registerWebSocketController(router, Controller, instance);
} else {
// Register as REST controller - doesn't accept an instance parameter
registerRestController(router, Controller);
}
});
}
function registerRestController(router: Router, Controller: ControllerConstructor): void {
const instance = new Controller();
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
if (methodName === "constructor") return; // Skip the constructor
const method = Reflect.getMetadata("method", instance, methodName) as HttpMethod;
const method = Reflect.getMetadata(
"method",
instance,
methodName,
) as HttpMethod;
const route = Reflect.getMetadata("route", instance, methodName) as string;
const middlewares = (Reflect.getMetadata("middlewares", instance, methodName) as RequestHandler[]) || [];
const middlewares =
(Reflect.getMetadata(
"middlewares",
instance,
methodName,
) as RequestHandler[]) || [];
if (method && route) {
const handler = instance[methodName] as unknown;
if (typeof handler === "function") {
if (method !== "ws") {
(router[method] as (path: string, ...handlers: RequestHandler[]) => void)(
`${baseRoute}${route}`,
...middlewares,
handler.bind(instance)
);
(
router[method] as (
path: string,
...handlers: RequestHandler[]
) => void
)(`${baseRoute}${route}`, ...middlewares, handler.bind(instance));
}
}
}
});
}
function registerWebSocketController(
router: Router,
Controller: ControllerConstructor,
existingInstance?: ControllerInstance
): void {
const instance = existingInstance || new Controller();
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
if (methodName === "constructor") return; // Skip the constructor
const method = Reflect.getMetadata("method", instance, methodName) as string;
const route = Reflect.getMetadata("route", instance, methodName) as string;
if (method === "ws" && route) {
const handler = instance[methodName] as unknown;
if (typeof handler === "function" && "ws" in router && typeof router.ws === "function") {
router.ws(`${baseRoute}${route}`, (ws: WebSocket, req: Request) => {
try {
handler.call(instance, ws, req);
} catch (error) {
console.error(`WebSocket error in ${Controller.name}.${methodName}`, error);
ws.close(1011, error instanceof Error ? error.message : "Internal server error");
}
});
}
}
});
}
+1
View File
@@ -3,6 +3,7 @@ export { Controller, Middleware } from "./rest";
export { Get, Post, Put, Patch, Delete } from "./rest";
export { WebSocket } from "./websocket";
export { registerControllers } from "./controller";
export { registerWebSocketControllers } from "./websocket-controller";
// Also provide namespaced exports for better organization
import * as RestDecorators from "./rest";
+5 -2
View File
@@ -21,7 +21,9 @@ export function Controller(baseRoute: string = ""): ClassDecorator {
* @param method HTTP method to handle
* @returns Method decorator
*/
function createHttpMethodDecorator(method: RestMethod): (route: string) => MethodDecorator {
function createHttpMethodDecorator(
method: RestMethod,
): (route: string) => MethodDecorator {
return function (route: string): MethodDecorator {
return function (target: object, propertyKey: string | symbol) {
Reflect.defineMetadata("method", method, target, propertyKey);
@@ -44,7 +46,8 @@ export const Delete = createHttpMethodDecorator("delete");
*/
export function Middleware(middleware: RequestHandler): MethodDecorator {
return function (target: object, propertyKey: string | symbol) {
const middlewares = Reflect.getMetadata("middlewares", target, propertyKey) || [];
const middlewares =
Reflect.getMetadata("middlewares", target, propertyKey) || [];
middlewares.push(middleware);
Reflect.defineMetadata("middlewares", middlewares, target, propertyKey);
};
@@ -0,0 +1,81 @@
import { Router, Request } from "express";
import type { WebSocket } from "ws";
import "reflect-metadata";
interface ControllerInstance {
[key: string]: unknown;
}
interface ControllerConstructor {
new (...args: unknown[]): ControllerInstance;
prototype: ControllerInstance;
}
export function registerWebSocketControllers(
router: Router,
Controller: ControllerConstructor,
existingInstance?: ControllerInstance,
): void {
const instance = existingInstance || new Controller();
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
if (methodName === "constructor") return; // Skip the constructor
const method = Reflect.getMetadata(
"method",
instance,
methodName,
) as string;
const route = Reflect.getMetadata("route", instance, methodName) as string;
if (method === "ws" && route) {
const handler = instance[methodName] as unknown;
if (
typeof handler === "function" &&
"ws" in router &&
typeof router.ws === "function"
) {
router.ws(`${baseRoute}${route}`, (ws: WebSocket, req: Request) => {
try {
handler.call(instance, ws, req);
} catch (error) {
console.error(
`WebSocket error in ${Controller.name}.${methodName}`,
error,
);
ws.close(
1011,
error instanceof Error ? error.message : "Internal server error",
);
}
});
}
}
});
}
/**
* Base controller class for WebSocket endpoints
*/
export abstract class BaseWebSocketController {
protected router: Router;
constructor() {
this.router = Router();
}
/**
* Get the base route for this controller
*/
protected getBaseRoute(): string {
return Reflect.getMetadata("baseRoute", this.constructor) || "";
}
/**
* Abstract method to handle WebSocket connections
* Implement this in your derived class
*/
abstract handleConnection(ws: WebSocket, req: Request): void;
}
+1
View File
@@ -6,6 +6,7 @@
"lib": ["ES2020"],
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
-4
View File
@@ -1,4 +0,0 @@
node_modules
build/*
dist/*
out/*
+1
View File
@@ -1,4 +1,5 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
};
@@ -0,0 +1 @@
export const DRAG_HANDLE_ADDITIONAL_SELECTORS = [];
@@ -4,7 +4,7 @@ import type { IEditorProps } from "@/types";
export type TCoreAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "embedHandler"
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => {
@@ -1,8 +1,6 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { AnyExtension } from "@tiptap/core";
import { SlashCommands } from "@/extensions";
// plane editor types
import type { TEmbedConfig } from "@/plane-editor/types";
// types
import type { IEditorProps, TExtensions, TUserDetails } from "@/types";
@@ -10,7 +8,6 @@ export type TDocumentEditorAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
> & {
embedConfig: TEmbedConfig | undefined;
isEditable: boolean;
provider?: HocuspocusProvider;
userDetails: TUserDetails;
@@ -0,0 +1,6 @@
// helpers
import type { TEditorRefHelperArgs } from "@/helpers/editor-ref";
// local imports
import type { TAdditionalEditorRefApiMethods } from "../types/editor";
export const getAdditionalEditorRefHelpers = (_args: TEditorRefHelperArgs): TAdditionalEditorRefApiMethods => ({});
+7
View File
@@ -0,0 +1,7 @@
export type TAdditionalEditorCommands = never;
export type TAdditionalCommandExtraProps = {};
export type TAdditionalEditorRefApiMethods = {};
export type IEditorPropsExtended = {};
-1
View File
@@ -1 +0,0 @@
export * from "./issue-embed";
@@ -1,17 +0,0 @@
export type TEmbedConfig = {
issue?: TIssueEmbedConfig;
};
export type TReadOnlyEmbedConfig = TEmbedConfig;
export type TIssueEmbedConfig = {
widgetCallback: ({
issueId,
projectId,
workspaceSlug,
}: {
issueId: string;
projectId: string | undefined;
workspaceSlug: string | undefined;
}) => React.ReactNode;
};
@@ -6,8 +6,6 @@ import { cn } from "@plane/utils";
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
import { WorkItemEmbedExtension } from "@/extensions";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
@@ -21,13 +19,12 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
bubbleMenuEnabled = true,
containerClassName,
documentLoaderClassName,
extensions: externalExtensions = [],
extensions,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
editorProps,
embedHandler,
fileHandler,
flaggedExtensions,
forwardedRef,
@@ -47,27 +44,12 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
user,
} = props;
const extensions: Extensions = useMemo(() => {
const allExtensions = [...externalExtensions];
if (embedHandler?.issue) {
allExtensions.push(
WorkItemEmbedExtension({
widgetCallback: embedHandler.issue.widgetCallback,
})
);
}
return allExtensions;
}, [externalExtensions, embedHandler.issue]);
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
disabledExtensions,
editable,
editorClassName,
editorProps,
embedHandler,
extensions,
fileHandler,
flaggedExtensions,
@@ -7,7 +7,7 @@ import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
import { HeadingListExtension, WorkItemEmbedExtension, SideMenuExtension } from "@/extensions";
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
@@ -25,7 +25,6 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
embedHandler,
fileHandler,
flaggedExtensions,
forwardedRef,
@@ -39,13 +38,6 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
} = props;
const extensions: Extensions = useMemo(() => {
const additionalExtensions: Extensions = [];
if (embedHandler?.issue) {
additionalExtensions.push(
WorkItemEmbedExtension({
widgetCallback: embedHandler.issue.widgetCallback,
})
);
}
additionalExtensions.push(
SideMenuExtension({
aiEnabled: !disabledExtensions?.includes("ai"),
@@ -54,7 +46,6 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
HeadingListExtension,
...DocumentEditorAdditionalExtensions({
disabledExtensions,
embedConfig: embedHandler,
flaggedExtensions,
isEditable: editable,
fileHandler,
@@ -82,7 +73,6 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
initialValue: value,
mentionHandler,
onChange,
embedHandler,
});
const editorContainerClassName = getEditorClassNames({
@@ -98,9 +88,9 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
editor={editor}
editorContainerClassName={cn(editorContainerClassName, "document-editor")}
id={id}
isTouchDevice={!!isTouchDevice}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
isTouchDevice={!!isTouchDevice}
/>
);
};
@@ -18,8 +18,8 @@ type Props = {
isLoading?: boolean;
isTouchDevice: boolean;
tabIndex?: number;
flaggedExtensions: IEditorProps["flaggedExtensions"];
disabledExtensions: IEditorProps["disabledExtensions"];
flaggedExtensions?: IEditorProps["flaggedExtensions"];
disabledExtensions?: IEditorProps["disabledExtensions"];
};
export const PageRenderer = (props: Props) => {
@@ -41,7 +41,6 @@ export const EditorWrapper: React.FC<Props> = (props) => {
placeholder,
tabIndex,
value,
embedHandler,
} = props;
const editor = useEditor({
@@ -66,7 +65,6 @@ export const EditorWrapper: React.FC<Props> = (props) => {
placeholder,
tabIndex,
value,
embedHandler,
});
const editorContainerClassName = getEditorClassNames({
@@ -55,7 +55,6 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
isActive: () => editorState.right,
},
];
if (editorState.code) return null;
return (
<div className="flex gap-0.5 px-2">
@@ -21,7 +21,6 @@ import { CustomMentionExtensionConfig } from "./mentions/extension-config";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
import { CustomTextAlignExtension } from "./text-align";
import { WorkItemEmbedExtensionConfig } from "./work-item-embed/extension-config";
export const CoreEditorExtensionsWithoutProps = [
StarterKit.configure({
@@ -101,5 +100,3 @@ export const CoreEditorExtensionsWithoutProps = [
CustomColorExtension,
...CoreEditorAdditionalExtensionsWithoutProps,
];
export const DocumentEditorExtensionsWithoutProps = [WorkItemEmbedExtensionConfig];
@@ -16,9 +16,7 @@ import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
import emojiRegex from "emoji-regex";
import { isEmojiSupported } from "is-emoji-supported";
// helpers
import { CORE_EXTENSIONS } from "@/constants/extension";
import { customFindSuggestionMatch } from "@/helpers/find-suggestion-match";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@@ -344,10 +342,6 @@ export const Emoji = Node.create<EmojiOptions, EmojiStorage>({
},
addProseMirrorPlugins() {
const isTouchDevice = !!getExtensionStorage(this.editor, CORE_EXTENSIONS.UTILITY).isTouchDevice;
if (isTouchDevice) {
return [];
}
return [
Suggestion({
editor: this.editor,
@@ -3,7 +3,7 @@ import { gitHubEmojis, shortcodeToEmoji } from "@tiptap/extension-emoji";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { Emoji } from "./emoji";
import { emojiSuggestion } from "./suggestion";
import suggestion from "./suggestion";
export const EmojiExtension = Emoji.extend({
addStorage() {
@@ -25,6 +25,6 @@ export const EmojiExtension = Emoji.extend({
},
}).configure({
emojis: gitHubEmojis,
suggestion: emojiSuggestion,
suggestion: suggestion,
enableEmoticons: true,
});
@@ -10,7 +10,7 @@ import { EmojiItem, EmojiList, EmojiListRef } from "./components/emojis-list";
const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"];
export const emojiSuggestion: EmojiOptions["suggestion"] = {
const emojiSuggestion: EmojiOptions["suggestion"] = {
items: ({ editor, query }: { editor: Editor; query: string }): EmojiItem[] => {
const { emojis } = getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI);
const { isSupported } = getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI);
@@ -116,3 +116,5 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = {
};
},
};
export default emojiSuggestion;
@@ -29,7 +29,7 @@ import {
// plane editor extensions
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import type { IEditorProps, TEmbedConfig } from "@/types";
import type { IEditorProps } from "@/types";
// local imports
import { CustomImageExtension } from "./custom-image/extension";
import { EmojiExtension } from "./emoji/extension";
@@ -45,11 +45,9 @@ type TArguments = Pick<
| "mentionHandler"
| "placeholder"
| "tabIndex"
| "embedHandler"
> & {
enableHistory: boolean;
editable: boolean;
embedHandler?: TEmbedConfig;
};
export const CoreEditorExtensions = (args: TArguments): Extensions => {
@@ -62,7 +60,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
mentionHandler,
placeholder,
tabIndex,
embedHandler,
editable,
} = args;
@@ -118,7 +115,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
disabledExtensions,
flaggedExtensions,
fileHandler,
embedHandler,
}),
];
@@ -8,7 +8,6 @@ export * from "./mentions";
export * from "./slash-commands";
export * from "./table";
export * from "./typography";
export * from "./work-item-embed";
export * from "./core-without-props";
export * from "./custom-color";
export * from "./enter-key";
@@ -1,43 +0,0 @@
import { mergeAttributes, Node } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
export const WorkItemEmbedExtensionConfig = Node.create({
name: CORE_EXTENSIONS.WORK_ITEM_EMBED,
group: "block",
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
entity_identifier: {
default: undefined,
},
project_identifier: {
default: undefined,
},
workspace_identifier: {
default: undefined,
},
id: {
default: undefined,
},
entity_name: {
default: undefined,
},
};
},
parseHTML() {
return [
{
tag: "issue-embed-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
},
});
@@ -1,30 +0,0 @@
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
// local imports
import { WorkItemEmbedExtensionConfig } from "./extension-config";
type Props = {
widgetCallback: ({
issueId,
projectId,
workspaceSlug,
}: {
issueId: string;
projectId: string | undefined;
workspaceSlug: string | undefined;
}) => React.ReactNode;
};
export const WorkItemEmbedExtension = (props: Props) =>
WorkItemEmbedExtensionConfig.extend({
addNodeView() {
return ReactNodeViewRenderer((issueProps: NodeViewProps) => (
<NodeViewWrapper>
{props.widgetCallback({
issueId: issueProps.node.attrs.entity_identifier,
projectId: issueProps.node.attrs.project_identifier,
workspaceSlug: issueProps.node.attrs.workspace_identifier,
})}
</NodeViewWrapper>
));
},
});
@@ -1 +0,0 @@
export * from "./extension";
@@ -9,18 +9,20 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
import { CORE_EDITOR_META } from "@/constants/meta";
// types
import type { EditorRefApi, TEditorCommands } from "@/types";
// plane editor imports
import { getAdditionalEditorRefHelpers } from "@/plane-editor/helpers/editor-ref";
// local imports
import { getParagraphCount } from "./common";
import { getExtensionStorage } from "./get-extension-storage";
import { insertContentAtSavedSelection } from "./insert-content-at-cursor-position";
import { scrollSummary, scrollToNodeViaDOMCoordinates } from "./scroll-to-node";
type TArgs = {
export type TEditorRefHelperArgs = {
editor: Editor | null;
provider: HocuspocusProvider | undefined;
};
export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
export const getEditorRefHelpers = (args: TEditorRefHelperArgs): EditorRefApi => {
const { editor, provider } = args;
return {
@@ -238,5 +240,6 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
Y.applyUpdate(document, value);
},
undo: () => editor?.commands.undo(),
...getAdditionalEditorRefHelpers(args),
};
};

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