Compare commits

...
Author SHA1 Message Date
sriram veeraghanta 14601d0f36 fix: base url standardization 2025-08-30 22:50:49 +05:30
sriram veeraghanta 866eb5ac17 chore: service structure that scales 2025-08-28 02:27:11 +05:30
sriram veeraghantaandGitHub 0e6fbaee3a [WEB-4790] fix: moved typescript parser to the base eslint config (#7658)
* fix: moved typescript parser to the base eslint config

* fix: eslint warning

* fix: type config setting

* fix: convert live eslint to cjs
2025-08-27 21:03:20 +05:30
Anmol Singh BhatiaandGitHub cfe710d492 [WEB-4779] chore: app sidebar wrapper component for consistency and reusability (#7655)
* chore: app sidebar ui enhancements and code refactor

* chore: code refactor

* chore: code refactor
2025-08-27 20:46:43 +05:30
AaronandGitHub 4a3c172992 feat(repo): add diff checks in CI flows (#7657) 2025-08-27 16:31:23 +05:30
Vipin ChaudharyandGitHub 5d1ad8a183 [WIKI-550] fix: emoji modal for touch device (#7651)
* fix: emoji modal for touch device

* refactor: editor from props

* fix : update is touch device plugin
2025-08-27 00:39:47 +05:30
Vipin ChaudharyandGitHub f95a07d8c8 [WIKI-436] fix: check for code in alignment #7649 2025-08-27 00:39:26 +05:30
Bavisetti NarayanandGitHub 0af75897f5 [WEB-4780] chore: changed the html validation (#7648)
* chore: changed the html validation

* chore: added requirements for nh3

* chore: removed the json validations
2025-08-27 00:38:25 +05:30
Vamsi KrishnaandGitHub 3602ff6930 [WEB-4781] fix: add peek view get to store #7654 2025-08-26 18:24:08 +05:30
Prateek ShouryaandGitHub 6d1275d58c [WEB-4761] fix: resolve circular import between IssueService and local db (#7653)
- Move local db import to dynamic import to break circular dependency
2025-08-26 16:48:32 +05:30
Vipin ChaudharyandGitHub cf7b288f93 [WIKI-345] regression: update block menu options (#7647)
* chore: update flagged and disabled extension

* chore :build fix
2025-08-26 14:18:26 +05:30
Vipin ChaudharyandGitHub 0fe7da6265 [WIKI-345] chore: pass disabled and flagged extensions to block menu (#7152)
* chore: refactor editor

* sync changes

* feat: api service update

* refactor : update sync

* fix : package sync

* fix: requested changes

* fix : embedhandler type

* fix : remove commands

* refactor : space

* refactor : rich lite editors

* refactor : minor ce changes

* chore : minor ui fix

* package: tldjs

* refactor : remove tldjs

* refactor: flagged

* refactor: flagged

* chore : remove disbaled check in menu

* refactor: fix space

* refactor: NodeViewProps

* refactor: type

* refactor : update community types

* refactor : remove external embed CE

* remove : external embed config from ce

* refactor : update disabled

* chore: pass disabled

* chore : update utils
2025-08-26 02:23:50 +05:30
Jayash TripathyandGitHub 8801ab0081 [WEB-4727] feat: propel cards (#7630)
* feat: add card component to propel package and update tooltip imports

* refactor: remove @plane/ui dependency and update tooltip imports to use local card component

* fix: lint

* refactor: update import from @plane/ui to @plane/utils in command component

* refactor: extend CardProps interface to include HTML attributes for better flexibility
2025-08-26 02:14:24 +05:30
Anmol Singh BhatiaandGitHub c2464939fc [WEB-4725] chore: storybook setup & tailwind config package improvements (#7614)
* chore: global css file added to tailwind config package

* chore: tailwind config updated

* chore: cn utility function added to propel package

* chore: storybook init

* fix: format error

* chore: code refactor

* chore: code refactor

* fix: format error

* fix: build error
2025-08-26 02:14:00 +05:30
Aaryan KhandelwalandGitHub 34e231230f [WIKI-498] regression: table bugs #7631 2025-08-26 02:13:27 +05:30
Lakhan BahetiandGitHub eb5ac2fc2d [WIKI-602] chore: disable image alignment tooltip for touch devices (#7642)
* chore: disable image alignment tooltip for touch devices

* chore: added touch-select-none style
2025-08-26 02:12:04 +05:30
Jayash TripathyandGitHub b6cf3a5a8b [WEB-4438] fix: epics label in y axis of epics(analytics-modal) #7644 2025-08-25 19:25:42 +05:30
Anmol Singh BhatiaandGitHub bbc465a3b2 [WEB-4761] fix: old url redirection method #7638 2025-08-25 15:28:58 +05:30
Jayash TripathyandGitHub 9a77e383cd [WEB-4751] chore: enhance URL utility functions with IP address validation and cleaned up url extraction (#7636)
* feat: enhance URL utility functions with IP address validation and cleaned up the extraction utilities

* fix: remove unnecessary type assertion in isLocalhost function
2025-08-25 13:38:09 +05:30
sriram veeraghanta a2d9e70a83 fix: requirments.txt 2025-08-25 02:40:06 +05:30
c7763dd431 refactor: remove few barrel exports (#7633)
* refactor: remove few barrel exports

* fix: pnpm lock file update

* fix: build errors

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2025-08-25 02:13:20 +05:30
Prateek ShouryaandGitHub 599ff2eec4 [WEB-4746] fix: position peek view relative to app layout (#7635) 2025-08-25 01:32:15 +05:30
Prateek ShouryaandGitHub 568a1bb228 [WEB-4757] fix: remove project view from workspace level group by options (#7634) 2025-08-24 15:16:08 +05:30
NikhilandGitHub 935e4b5c33 [WEB-4720] chore: refactor and extend cleanup tasks for logs and versions (#7604)
* Refactor and extend cleanup tasks for logs and versions

- Consolidate API log deletion into cleanup_task.py - Add tasks to
delete old email logs, page versions, and issue description versions -
Update Celery schedule and imports for new tasks

* chore: update cleanup task with mongo changes

* fix: update log deletion task name for clarity

* fix: enhance MongoDB archival error handling in cleanup task

- Added a parameter to check MongoDB availability in the flush_to_mongo_and_delete function.
- Implemented error logging for MongoDB archival failures.
- Updated calls to flush_to_mongo_and_delete to include the new parameter.

* fix: correct parameter name in cleanup task function call

- Updated the parameter name from 'mode' to 'model' in the process_cleanup_task function to ensure consistency and clarity in the code.

* fix: improve MongoDB connection parameter handling in MongoConnection class

- Replaced direct access to settings with getattr for MONGO_DB_URL and MONGO_DB_DATABASE to enhance robustness.
- Added warning logging for missing MongoDB connection parameters.
2025-08-24 15:13:49 +05:30
841388e437 [WEB-4751] refactor: added tld validation for urls (#7622)
* refactor: added tld validation for urls

* refactor: improve TLD validation and update parameter naming in URL utility functions

* refactor: enhance URL component extraction and validation logic

* fix: lint

* chore: remove unused lodash filter import in existing issues list modal

---------

Co-authored-by: Sriram Veeraghanta <veeraghanta.sriram@gmail.com>
2025-08-23 01:07:35 +05:30
9ecea15d74 [WIKI-498] [WIKI-567] feat: ability to rearrange columns and rows in table (#7624)
* feat: ability to rearrange columns and rows

* chore: update delete icon

* refactor: table utilities and plugins

* chore: handle edge cases

* chore: safe pseudo element inserts

---------

Co-authored-by: Sriram Veeraghanta <veeraghanta.sriram@gmail.com>
2025-08-23 00:54:03 +05:30
4ad88c969c [WEB-4747]chore: rendering cycle progress from snapshot (#7626)
* chore: rendering progress from snaposhot

* chore: removed unncessary memoization

---------

Co-authored-by: Sriram Veeraghanta <veeraghanta.sriram@gmail.com>
2025-08-23 00:46:06 +05:30
706085395e [WEB-4748] chore: placement helper fn added and code refactor #7627
Co-authored-by: Sriram Veeraghanta <veeraghanta.sriram@gmail.com>
2025-08-23 00:42:44 +05:30
Sriram Veeraghanta a0f7acae42 chore: format files 2025-08-23 00:33:38 +05:30
SangeethaandGitHub 6e5549c439 [WEB-4187] fix: related search issues #7628 2025-08-23 00:28:08 +05:30
Jayash TripathyandGitHub cf8eeee03a [WEB-4687] feat: propel switch (#7629)
* feat: added switch

* fix: lint

* fix: lock file

* fix: improve accessibility and refactor switch component styles

* feat: add switch component to propel package

* fix: update imports in command component for consistency

* refactor: styles
2025-08-23 00:27:31 +05:30
sriram veeraghantaandGitHub d3b26996dd fix: replace .npmrc node version with engines in package.json (#7623) 2025-08-22 14:13:08 +05:30
d0f26f8734 [WEB-4726] fix: intake work item redirection (#7619)
* chore: added is intake for email notifications

* fix: intake work item redirection

* chore: code refactor

* chore: code refactor

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-08-22 12:54:46 +05:30
e86b40ac82 [WEB-4682 | WEB-4685] feat: propel comobobox and command component (#7615)
* feat: comobobox and command component added to propel package

* fix: format error

* chore: code refactor

* chore: code refactor

* fix: format error

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2025-08-21 15:32:29 +05:30
Saurabh KumarandGitHub c209a713d8 [SILO-449] fix: add missing methods in external APIs (#7601)
* add missing fields and methods in endpoints

* add POST method for project members

* make project_id as uuid in url pattern

* remove post method

* fix method reordering
2025-08-21 13:15:15 +05:30
Anmol Singh BhatiaandGitHub f10cd92610 [WEB-4724] feat: tooltip component added to propel package (#7613)
* feat: tooltip component added to propel package

* chore: code refactor

* chore: code refactor

* fix: format error
2025-08-21 13:03:18 +05:30
197 changed files with 5469 additions and 1102 deletions
+21 -12
View File
@@ -35,6 +35,10 @@ 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 }}
@@ -268,15 +272,14 @@ 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
@@ -285,7 +288,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
@@ -324,7 +327,14 @@ 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
@@ -397,4 +407,3 @@ jobs:
${{ github.workspace }}/deployments/cli/community/docker-compose.yml
${{ github.workspace }}/deployments/cli/community/variables.env
${{ github.workspace }}/deployments/swarm/community/swarm.sh
@@ -3,11 +3,21 @@ 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,21 +3,18 @@ name: Build and lint web apps
on:
workflow_dispatch:
pull_request:
branches: ["preview"]
branches:
- "preview"
types:
[
"opened",
"synchronize",
"ready_for_review",
"review_requested",
"reopened",
]
paths:
- "**.tsx?"
- "**.jsx?"
- "**.css"
- "**.json"
- "!apps/api/**"
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-lint:
@@ -27,11 +24,15 @@ 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: 2
fetch-depth: 50
filter: blob:none
- name: Set up Node.js
uses: actions/setup-node@v4
@@ -44,11 +45,11 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint web apps
run: pnpm run check:lint
- name: Lint Affected
run: pnpm turbo run check:lint --affected
- name: Check format
run: pnpm run check:format
- name: Check Affected format
run: pnpm turbo run check:format --affected
- name: Build apps
run: pnpm run build
- name: Build Affected
run: pnpm turbo run build --affected
+2
View File
@@ -97,3 +97,5 @@ dev-editor
# Redis
*.rdb
*.rdb.gz
storybook-static
-3
View File
@@ -18,9 +18,6 @@ public-hoist-pattern[]=eslint
public-hoist-pattern[]=prettier
public-hoist-pattern[]=typescript
# Enforce Node version for consistent installs
use-node-version=22.18.0
# Reproducible installs across CI and dev
prefer-frozen-lockfile=true
+12
View File
@@ -0,0 +1,12 @@
.next/*
out/*
public/*
dist/*
node_modules/*
.turbo/*
.env*
.env
.env.local
.env.development
.env.production
.env.test
-1
View File
@@ -1,5 +1,4 @@
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 = observer(() => {
const InstanceEmailPage: React.FC = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance();
@@ -29,7 +29,7 @@ const InstanceEmailPage = 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,9 +25,8 @@ export const EmailCodesConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch
value={Boolean(parseInt(enableMagicLogin))}
onChange={() => {
Boolean(parseInt(enableMagicLogin)) === true
? updateConfig("ENABLE_MAGIC_LINK_LOGIN", "0")
: updateConfig("ENABLE_MAGIC_LINK_LOGIN", "1");
const newEnableMagicLogin = Boolean(parseInt(enableMagicLogin)) === true ? "0" : "1";
updateConfig("ENABLE_MAGIC_LINK_LOGIN", newEnableMagicLogin);
}}
size="sm"
disabled={disabled}
@@ -35,9 +35,8 @@ export const GithubConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch
value={Boolean(parseInt(enableGithubConfig))}
onChange={() => {
Boolean(parseInt(enableGithubConfig)) === true
? updateConfig("IS_GITHUB_ENABLED", "0")
: updateConfig("IS_GITHUB_ENABLED", "1");
const newEnableGithubConfig = Boolean(parseInt(enableGithubConfig)) === true ? "0" : "1";
updateConfig("IS_GITHUB_ENABLED", newEnableGithubConfig);
}}
size="sm"
disabled={disabled}
@@ -35,9 +35,8 @@ export const GitlabConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch
value={Boolean(parseInt(enableGitlabConfig))}
onChange={() => {
Boolean(parseInt(enableGitlabConfig)) === true
? updateConfig("IS_GITLAB_ENABLED", "0")
: updateConfig("IS_GITLAB_ENABLED", "1");
const newEnableGitlabConfig = Boolean(parseInt(enableGitlabConfig)) === true ? "0" : "1";
updateConfig("IS_GITLAB_ENABLED", newEnableGitlabConfig);
}}
size="sm"
disabled={disabled}
@@ -35,9 +35,8 @@ export const GoogleConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch
value={Boolean(parseInt(enableGoogleConfig))}
onChange={() => {
Boolean(parseInt(enableGoogleConfig)) === true
? updateConfig("IS_GOOGLE_ENABLED", "0")
: updateConfig("IS_GOOGLE_ENABLED", "1");
const newEnableGoogleConfig = Boolean(parseInt(enableGoogleConfig)) === true ? "0" : "1";
updateConfig("IS_GOOGLE_ENABLED", newEnableGoogleConfig);
}}
size="sm"
disabled={disabled}
@@ -25,9 +25,8 @@ export const PasswordLoginConfiguration: React.FC<Props> = observer((props) => {
<ToggleSwitch
value={Boolean(parseInt(enableEmailPassword))}
onChange={() => {
Boolean(parseInt(enableEmailPassword)) === true
? updateConfig("ENABLE_EMAIL_PASSWORD", "0")
: updateConfig("ENABLE_EMAIL_PASSWORD", "1");
const newEnableEmailPassword = Boolean(parseInt(enableEmailPassword)) === true ? "0" : "1";
updateConfig("ENABLE_EMAIL_PASSWORD", newEnableEmailPassword);
}}
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;
}
+12 -9
View File
@@ -24,7 +24,6 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -89,20 +88,24 @@ class IssueSerializer(BaseSerializer):
raise serializers.ValidationError("Invalid HTML passed")
# Validate description content for security
if data.get("description"):
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
raise serializers.ValidationError({"description": error_msg})
if data.get("description_html"):
is_valid, error_msg = validate_html_content(data["description_html"])
is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
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
if data.get("description_binary"):
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": error_msg})
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
# Validate assignees are from project
if data.get("assignees", []):
+11 -17
View File
@@ -12,7 +12,6 @@ from plane.db.models import (
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
)
from .base import BaseSerializer
@@ -45,6 +44,10 @@ class ProjectCreateSerializer(BaseSerializer):
"archive_in",
"close_in",
"timezone",
"logo_props",
"external_source",
"external_id",
"is_issue_type_enabled",
]
read_only_fields = [
@@ -196,27 +199,18 @@ 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 = validate_json_content(data["description_html"])
else:
is_valid, error_msg = validate_html_content(
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 not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
return data
+1 -1
View File
@@ -4,7 +4,7 @@ from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<str:project_id>/members/",
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]),
name="project-members",
),
+12 -9
View File
@@ -23,7 +23,6 @@ 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
@@ -76,20 +75,24 @@ class DraftIssueCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description" in attrs and attrs["description"]:
is_valid, error_msg = validate_json_content(attrs["description"])
if not is_valid:
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"])
is_valid, error_msg, sanitized_html = validate_html_content(
attrs["description_html"]
)
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
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
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": error_msg})
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
# Validate assignees are from project
if attrs.get("assignee_ids", []):
+18 -10
View File
@@ -43,7 +43,6 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -128,20 +127,24 @@ class IssueCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description" in attrs and attrs["description"]:
is_valid, error_msg = validate_json_content(attrs["description"])
if not is_valid:
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"])
is_valid, error_msg, sanitized_html = validate_html_content(
attrs["description_html"]
)
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
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
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": error_msg})
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
# Validate assignees are from project
if attrs.get("assignee_ids", []):
@@ -908,9 +911,14 @@ class IssueLiteSerializer(DynamicBaseSerializer):
class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField()
is_subscribed = serializers.BooleanField(read_only=True)
is_intake = serializers.BooleanField(read_only=True)
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + ["description_html", "is_subscribed"]
fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
"is_intake",
]
read_only_fields = fields
+3 -14
View File
@@ -7,7 +7,6 @@ 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,
@@ -229,23 +228,13 @@ class PageBinaryUpdateSerializer(serializers.Serializer):
return value
# Use the validation function from utils
is_valid, error_message = validate_html_content(value)
is_valid, error_message, sanitized_html = validate_html_content(value)
if not is_valid:
raise serializers.ValidationError(error_message)
return value
# Return sanitized HTML if available, otherwise return original
return sanitized_html if sanitized_html is not None else 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"""
+10 -20
View File
@@ -15,7 +15,6 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -65,27 +64,18 @@ class ProjectSerializer(BaseSerializer):
def validate(self, data):
# 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 = validate_json_content(data["description_html"])
else:
is_valid, error_msg = validate_html_content(
str(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 not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
raise serializers.ValidationError(
{"error": "html content is not valid"}
)
return data
+12 -9
View File
@@ -26,7 +26,6 @@ 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,
)
@@ -319,20 +318,24 @@ class StickySerializer(BaseSerializer):
def validate(self, data):
# Validate description content for security
if "description" in data and data["description"]:
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
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"])
is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
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
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": error_msg})
raise serializers.ValidationError(
{"description_binary": "Invalid binary data"}
)
return data
+12 -1
View File
@@ -51,6 +51,7 @@ from plane.db.models import (
IssueRelation,
IssueAssignee,
IssueLabel,
IntakeIssue,
)
from plane.utils.grouper import (
issue_group_values,
@@ -1223,7 +1224,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
# Fetch the issue
issue = (
Issue.issue_objects.filter(project_id=project.id)
Issue.objects.filter(project_id=project.id)
.filter(workspace__slug=slug)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
@@ -1315,6 +1316,16 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
)
)
)
.annotate(
is_intake=Exists(
IntakeIssue.objects.filter(
issue=OuterRef("id"),
status__in=[-2, 0],
workspace__slug=slug,
project_id=project.id,
)
)
)
).first()
# Check if the issue exists
+2 -1
View File
@@ -59,9 +59,10 @@ class IssueSearchEndpoint(BaseAPIView):
)
related_issue_ids = [item for sublist in related_issue_ids for item in sublist]
related_issue_ids.append(issue_id)
if issue:
issues = issues.filter(~Q(pk=issue_id), ~Q(pk__in=related_issue_ids))
issues = issues.exclude(pk__in=related_issue_ids)
return issues
-15
View File
@@ -1,15 +0,0 @@
from django.utils import timezone
from datetime import timedelta
from plane.db.models import APIActivityLog
from celery import shared_task
@shared_task
def delete_api_logs():
# Get the logs older than 30 days to delete
logs_to_delete = APIActivityLog.objects.filter(
created_at__lte=timezone.now() - timedelta(days=30)
)
# Delete the logs
logs_to_delete._raw_delete(logs_to_delete.db)
+423
View File
@@ -0,0 +1,423 @@
# Python imports
from datetime import timedelta
import logging
from typing import List, Dict, Any, Callable, Optional
import os
# Django imports
from django.utils import timezone
from django.db.models import F, Window, Subquery
from django.db.models.functions import RowNumber
# Third party imports
from celery import shared_task
from pymongo.errors import BulkWriteError
from pymongo.collection import Collection
from pymongo.operations import InsertOne
# Module imports
from plane.db.models import (
EmailNotificationLog,
PageVersion,
APIActivityLog,
IssueDescriptionVersion,
)
from plane.settings.mongo import MongoConnection
from plane.utils.exception_logger import log_exception
logger = logging.getLogger("plane.worker")
BATCH_SIZE = 1000
def get_mongo_collection(collection_name: str) -> Optional[Collection]:
"""Get MongoDB collection if available, otherwise return None."""
if not MongoConnection.is_configured():
logger.info("MongoDB not configured")
return None
try:
mongo_collection = MongoConnection.get_collection(collection_name)
logger.info(f"MongoDB collection '{collection_name}' connected successfully")
return mongo_collection
except Exception as e:
logger.error(f"Failed to get MongoDB collection: {str(e)}")
log_exception(e)
return None
def flush_to_mongo_and_delete(
mongo_collection: Optional[Collection],
buffer: List[Dict[str, Any]],
ids_to_delete: List[int],
model,
mongo_available: bool,
) -> None:
"""
Inserts a batch of records into MongoDB and deletes the corresponding rows from PostgreSQL.
"""
if not buffer:
logger.debug("No records to flush - buffer is empty")
return
logger.info(
f"Starting batch flush: {len(buffer)} records, {len(ids_to_delete)} IDs to delete"
)
mongo_archival_failed = False
# Try to insert into MongoDB if available
if mongo_collection and mongo_available:
try:
mongo_collection.bulk_write([InsertOne(doc) for doc in buffer])
except BulkWriteError as bwe:
logger.error(f"MongoDB bulk write error: {str(bwe)}")
log_exception(bwe)
mongo_archival_failed = True
# If MongoDB is available and archival failed, log the error and return
if mongo_available and mongo_archival_failed:
logger.error(f"MongoDB archival failed for {len(buffer)} records")
return
# Delete from PostgreSQL - delete() returns (count, {model: count})
delete_result = model.all_objects.filter(id__in=ids_to_delete).delete()
deleted_count = (
delete_result[0] if delete_result and isinstance(delete_result, tuple) else 0
)
logger.info(f"Batch flush completed: {deleted_count} records deleted")
def process_cleanup_task(
queryset_func: Callable,
transform_func: Callable[[Dict], Dict],
model,
task_name: str,
collection_name: str,
):
"""
Generic function to process cleanup tasks.
Args:
queryset_func: Function that returns the queryset to process
transform_func: Function to transform each record for MongoDB
model: Django model class
task_name: Name of the task for logging
collection_name: MongoDB collection name
"""
logger.info(f"Starting {task_name} cleanup task")
# Get MongoDB collection
mongo_collection = get_mongo_collection(collection_name)
mongo_available = mongo_collection is not None
# Get queryset
queryset = queryset_func()
# Process records in batches
buffer: List[Dict[str, Any]] = []
ids_to_delete: List[int] = []
total_processed = 0
total_batches = 0
for record in queryset:
# Transform record for MongoDB
buffer.append(transform_func(record))
ids_to_delete.append(record["id"])
# Flush batch when it reaches BATCH_SIZE
if len(buffer) >= BATCH_SIZE:
total_batches += 1
flush_to_mongo_and_delete(
mongo_collection=mongo_collection,
buffer=buffer,
ids_to_delete=ids_to_delete,
model=model,
mongo_available=mongo_available,
)
total_processed += len(buffer)
buffer.clear()
ids_to_delete.clear()
# Process final batch if any records remain
if buffer:
total_batches += 1
flush_to_mongo_and_delete(
mongo_collection=mongo_collection,
buffer=buffer,
ids_to_delete=ids_to_delete,
model=model,
mongo_available=mongo_available,
)
total_processed += len(buffer)
logger.info(
f"{task_name} cleanup task completed",
extra={
"total_records_processed": total_processed,
"total_batches": total_batches,
"mongo_available": mongo_available,
"collection_name": collection_name,
},
)
# Transform functions for each model
def transform_api_log(record: Dict) -> Dict:
"""Transform API activity log record."""
return {
"id": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"token_identifier": record["token_identifier"],
"path": record["path"],
"method": record["method"],
"query_params": record.get("query_params"),
"headers": record.get("headers"),
"body": record.get("body"),
"response_code": record["response_code"],
"response_body": record["response_body"],
"ip_address": record["ip_address"],
"user_agent": record["user_agent"],
"created_by_id": record["created_by_id"],
}
def transform_email_log(record: Dict) -> Dict:
"""Transform email notification log record."""
return {
"id": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"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": (
str(record["processed_at"]) if record.get("processed_at") else None
),
"sent_at": str(record["sent_at"]) if record.get("sent_at") else None,
"entity": record["entity"],
"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": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"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": 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
),
}
def transform_issue_description_version(record: Dict) -> Dict:
"""Transform issue description version record."""
return {
"id": record["id"],
"created_at": str(record["created_at"]) if record.get("created_at") else None,
"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
),
"description_binary": record["description_binary"],
"description_html": record["description_html"],
"description_stripped": record["description_stripped"],
"description_json": record["description_json"],
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
}
# Queryset functions for each cleanup task
def get_api_logs_queryset():
"""Get API logs older than cutoff days."""
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
logger.info(f"API logs cutoff time: {cutoff_time}")
return (
APIActivityLog.all_objects.filter(created_at__lte=cutoff_time)
.values(
"id",
"created_at",
"token_identifier",
"path",
"method",
"query_params",
"headers",
"body",
"response_code",
"response_body",
"ip_address",
"user_agent",
"created_by_id",
)
.iterator(chunk_size=BATCH_SIZE)
)
def get_email_logs_queryset():
"""Get email logs older than cutoff days."""
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
logger.info(f"Email logs cutoff time: {cutoff_time}")
return (
EmailNotificationLog.all_objects.filter(sent_at__lte=cutoff_time)
.values(
"id",
"created_at",
"receiver_id",
"triggered_by_id",
"entity_identifier",
"entity_name",
"data",
"processed_at",
"sent_at",
"entity",
"old_value",
"new_value",
"created_by_id",
)
.iterator(chunk_size=BATCH_SIZE)
)
def get_page_versions_queryset():
"""Get page versions beyond the maximum allowed (20 per page)."""
subq = (
PageVersion.all_objects.annotate(
row_num=Window(
expression=RowNumber(),
partition_by=[F("page_id")],
order_by=F("created_at").desc(),
)
)
.filter(row_num__gt=20)
.values("id")
)
return (
PageVersion.all_objects.filter(id__in=Subquery(subq))
.values(
"id",
"created_at",
"page_id",
"workspace_id",
"owned_by_id",
"description_html",
"description_binary",
"description_stripped",
"description_json",
"sub_pages_data",
"created_by_id",
"updated_by_id",
"deleted_at",
"last_saved_at",
)
.iterator(chunk_size=BATCH_SIZE)
)
def get_issue_description_versions_queryset():
"""Get issue description versions beyond the maximum allowed (20 per issue)."""
subq = (
IssueDescriptionVersion.all_objects.annotate(
row_num=Window(
expression=RowNumber(),
partition_by=[F("issue_id")],
order_by=F("created_at").desc(),
)
)
.filter(row_num__gt=20)
.values("id")
)
return (
IssueDescriptionVersion.all_objects.filter(id__in=Subquery(subq))
.values(
"id",
"created_at",
"issue_id",
"workspace_id",
"project_id",
"created_by_id",
"updated_by_id",
"owned_by_id",
"last_saved_at",
"description_binary",
"description_html",
"description_stripped",
"description_json",
"deleted_at",
)
.iterator(chunk_size=BATCH_SIZE)
)
# Celery tasks - now much simpler!
@shared_task
def delete_api_logs():
"""Delete old API activity logs."""
process_cleanup_task(
queryset_func=get_api_logs_queryset,
transform_func=transform_api_log,
model=APIActivityLog,
task_name="API Activity Log",
collection_name="api_activity_logs",
)
@shared_task
def delete_email_notification_logs():
"""Delete old email notification logs."""
process_cleanup_task(
queryset_func=get_email_logs_queryset,
transform_func=transform_email_log,
model=EmailNotificationLog,
task_name="Email Notification Log",
collection_name="email_notification_logs",
)
@shared_task
def delete_page_versions():
"""Delete excess page versions."""
process_cleanup_task(
queryset_func=get_page_versions_queryset,
transform_func=transform_page_version,
model=PageVersion,
task_name="Page Version",
collection_name="page_versions",
)
@shared_task
def delete_issue_description_versions():
"""Delete excess issue description versions."""
process_cleanup_task(
queryset_func=get_issue_description_versions_queryset,
transform_func=transform_issue_description_version,
model=IssueDescriptionVersion,
task_name="Issue Description Version",
collection_name="issue_description_versions",
)
+13 -1
View File
@@ -50,9 +50,21 @@ app.conf.beat_schedule = {
"schedule": crontab(hour=2, minute=0), # UTC 02:00
},
"check-every-day-to-delete-api-logs": {
"task": "plane.bgtasks.api_logs_task.delete_api_logs",
"task": "plane.bgtasks.cleanup_task.delete_api_logs",
"schedule": crontab(hour=2, minute=30), # UTC 02:30
},
"check-every-day-to-delete-email-notification-logs": {
"task": "plane.bgtasks.cleanup_task.delete_email_notification_logs",
"schedule": crontab(hour=3, minute=0), # UTC 03:00
},
"check-every-day-to-delete-page-versions": {
"task": "plane.bgtasks.cleanup_task.delete_page_versions",
"schedule": crontab(hour=3, minute=30), # UTC 03:30
},
"check-every-day-to-delete-issue-description-versions": {
"task": "plane.bgtasks.cleanup_task.delete_issue_description_versions",
"schedule": crontab(hour=4, minute=0), # UTC 04:00
},
}
+1 -1
View File
@@ -284,7 +284,7 @@ CELERY_IMPORTS = (
"plane.bgtasks.exporter_expired_task",
"plane.bgtasks.file_asset_task",
"plane.bgtasks.email_notification_task",
"plane.bgtasks.api_logs_task",
"plane.bgtasks.cleanup_task",
"plane.license.bgtasks.tracer",
# management tasks
"plane.bgtasks.dummy_data_task",
+5
View File
@@ -73,5 +73,10 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
"plane.mongo": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
},
}
+121
View File
@@ -0,0 +1,121 @@
# Django imports
from django.conf import settings
import logging
# Third party imports
from pymongo import MongoClient
from pymongo.database import Database
from pymongo.collection import Collection
from typing import Optional, TypeVar, Type
T = TypeVar("T", bound="MongoConnection")
# Set up logger
logger = logging.getLogger("plane.mongo")
class MongoConnection:
"""
A singleton class that manages MongoDB connections.
This class ensures only one MongoDB connection is maintained throughout the application.
It provides methods to access the MongoDB client, database, and collections.
Attributes:
_instance (Optional[MongoConnection]): The singleton instance of this class
_client (Optional[MongoClient]): The MongoDB client instance
_db (Optional[Database]): The MongoDB database instance
"""
_instance: Optional["MongoConnection"] = None
_client: Optional[MongoClient] = None
_db: Optional[Database] = None
def __new__(cls: Type[T]) -> T:
"""
Creates a new instance of MongoConnection if one doesn't exist.
Returns:
MongoConnection: The singleton instance
"""
if cls._instance is None:
cls._instance = super(MongoConnection, cls).__new__(cls)
try:
mongo_url = getattr(settings, "MONGO_DB_URL", None)
mongo_db_database = getattr(settings, "MONGO_DB_DATABASE", None)
if not mongo_url or not mongo_db_database:
logger.warning(
"MongoDB connection parameters not configured. MongoDB functionality will be disabled."
)
return cls._instance
cls._client = MongoClient(mongo_url)
cls._db = cls._client[mongo_db_database]
# Test the connection
cls._client.server_info()
logger.info("MongoDB connection established successfully")
except Exception as e:
logger.warning(
f"Failed to initialize MongoDB connection: {str(e)}. MongoDB functionality will be disabled."
)
return cls._instance
@classmethod
def get_client(cls) -> Optional[MongoClient]:
"""
Returns the MongoDB client instance.
Returns:
Optional[MongoClient]: The MongoDB client instance or None if not configured
"""
if cls._client is None:
cls._instance = cls()
return cls._client
@classmethod
def get_db(cls) -> Optional[Database]:
"""
Returns the MongoDB database instance.
Returns:
Optional[Database]: The MongoDB database instance or None if not configured
"""
if cls._db is None:
cls._instance = cls()
return cls._db
@classmethod
def get_collection(cls, collection_name: str) -> Optional[Collection]:
"""
Returns a MongoDB collection by name.
Args:
collection_name (str): The name of the collection to retrieve
Returns:
Optional[Collection]: The MongoDB collection instance or None if not configured
"""
try:
db = cls.get_db()
if db is None:
logger.warning(
f"Cannot access collection '{collection_name}': MongoDB not configured"
)
return None
return db[collection_name]
except Exception as e:
logger.warning(f"Failed to access collection '{collection_name}': {str(e)}")
return None
@classmethod
def is_configured(cls) -> bool:
"""
Check if MongoDB is properly configured and connected.
Returns:
bool: True if MongoDB is configured and connected, False otherwise
"""
return cls._client is not None and cls._db is not None
+5
View File
@@ -83,5 +83,10 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
"plane.mongo": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
},
}
+10 -9
View File
@@ -30,7 +30,6 @@ from plane.db.models import (
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
@@ -290,20 +289,22 @@ class IssueCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date")
# Validate description content for security
if "description" in data and data["description"]:
is_valid, error_msg = validate_json_content(data["description"])
if not is_valid:
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"])
is_valid, error_msg, sanitized_html = validate_html_content(
data["description_html"]
)
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
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
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": error_msg})
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
return data
+11 -270
View File
@@ -1,36 +1,11 @@
# Python imports
import base64
import json
import re
import nh3
from plane.utils.exception_logger import log_exception
# 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",
@@ -41,70 +16,6 @@ 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):
"""
@@ -149,191 +60,21 @@ def validate_binary_data(data):
return True, None
def validate_html_content(html_content):
def validate_html_content(html_content: str):
"""
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)
Sanitize HTML content using nh3.
Returns a tuple: (is_valid, error_message, clean_html)
"""
if not html_content:
return True, None # Empty is OK
return True, None, None
# 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)"
# 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
return False, "HTML content exceeds maximum size limit (10MB)", None
try:
# 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"
clean_html = nh3.clean(html_content)
return True, None, clean_html
except Exception as e:
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
log_exception(e)
return False, "Failed to sanitize HTML", None
+5 -1
View File
@@ -9,6 +9,8 @@ psycopg==3.1.18
psycopg-binary==3.1.18
psycopg-c==3.1.18
dj-database-url==2.1.0
# mongo
pymongo==4.6.3
# redis
redis==5.0.4
django-redis==5.4.0
@@ -66,4 +68,6 @@ opentelemetry-sdk==1.28.1
opentelemetry-instrumentation-django==0.49b1
opentelemetry-exporter-otlp==1.28.1
# OpenAPI Specification
drf-spectacular==0.28.0
drf-spectacular==0.28.0
# html sanitizer
nh3==0.2.18
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/server.js"],
};
-5
View File
@@ -1,5 +0,0 @@
{
"root": true,
"extends": ["@plane/eslint-config/server.js"],
"parser": "@typescript-eslint/parser"
}
+4 -7
View File
@@ -1,20 +1,17 @@
// 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";
// 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";
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
// extensions
import { getExtensions } from "@/core/extensions/index.js";
// lib
import { handleAuthentication } from "@/core/lib/authentication.js";
// types
import { type HocusPocusServerContext } from "@/core/types/common.js";
+2 -2
View File
@@ -1,7 +1,7 @@
// services
import { UserService } from "@/core/services/user.service.js";
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
// services
import { UserService } from "@/core/services/user.service.js";
const userService = new UserService();
@@ -14,6 +14,7 @@ export abstract class APIService {
this.axiosInstance = axios.create({
baseURL,
withCredentials: true,
timeout: 20000,
});
}
+2 -2
View File
@@ -1,13 +1,13 @@
import compression from "compression";
import cors from "cors";
import expressWs from "express-ws";
import express, { Request, Response } from "express";
import expressWs from "express-ws";
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
@@ -0,0 +1,12 @@
.next/*
out/*
public/*
dist/*
node_modules/*
.turbo/*
.env*
.env
.env.local
.env.development
.env.production
.env.test
-2
View File
@@ -1,6 +1,4 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
parser: "@typescript-eslint/parser",
};
+1 -1
View File
@@ -71,7 +71,7 @@ export const isCommentEmpty = (comment: string | undefined): boolean => {
return (
comment?.trim() === "" ||
comment === "<p></p>" ||
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component"])
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component", "embed-component"])
);
};
+10 -1
View File
@@ -1,4 +1,13 @@
.next/*
out/*
public/*
core/local-db/worker/wa-sqlite/src/*
core/local-db/worker/wa-sqlite/src/*
dist/*
node_modules/*
.turbo/*
.env*
.env
.env.local
.env.development
.env.production
.env.test
-2
View File
@@ -1,6 +1,4 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
parser: "@typescript-eslint/parser",
};
@@ -78,6 +78,12 @@ const IssueDetailsPage = observer(() => {
return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar);
}, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]);
useEffect(() => {
if (data?.is_intake) {
router.push(`/${workspaceSlug}/projects/${data.project_id}/intake/?currentTab=open&inboxIssueId=${data?.id}`);
}
}, [workspaceSlug, data]);
return (
<>
<PageHead title={pageTitle} />
@@ -3,7 +3,7 @@
// components
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { ProjectInboxHeader } from "@/plane-web/components/projects/settings/intake";
import { ProjectInboxHeader } from "@/plane-web/components/projects/settings/intake/header";
export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) {
return (
@@ -2,7 +2,7 @@
import { useEffect } from "react";
import { observer } from "mobx-react";
import { redirect, useParams } from "next/navigation";
import { useParams } from "next/navigation";
import { useTheme } from "next-themes";
import useSWR from "swr";
import { useTranslation } from "@plane/i18n";
@@ -34,7 +34,7 @@ const IssueDetailsPage = observer(() => {
useEffect(() => {
if (data) {
redirect(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`);
router.push(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`);
}
}, [workspaceSlug, data]);
@@ -1,36 +1,24 @@
import { FC, useEffect, useRef } from "react";
import { FC } 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 { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
import { SidebarDropdown } from "@/components/workspace/sidebar/dropdown";
import { SidebarWrapper } from "@/components/sidebar/sidebar-wrapper";
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(
@@ -38,55 +26,17 @@ 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 (
<>
<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>
</>
<SidebarWrapper title="Projects" quickActions={<SidebarQuickActions />}>
<SidebarMenuItems />
{/* Favorites Menu */}
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
{/* Teams List */}
<SidebarTeamsList />
{/* Projects List */}
<SidebarProjectsList />
</SidebarWrapper>
);
});
@@ -16,7 +16,7 @@ import { SettingsHeading } from "@/components/settings/heading";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
// plane web imports
import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces";
import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces/teamspace-list";
import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings";
const MembersSettingsPage = observer(() => {
@@ -12,7 +12,7 @@ import type { TNavigationItem } from "@/components/workspace/sidebar/project-nav
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
// local imports
import { getProjectFeatureNavigation } from "../projects/navigation";
import { getProjectFeatureNavigation } from "../projects/navigation/helper";
type TProjectFeatureBreadcrumbProps = {
workspaceSlug: string;
-2
View File
@@ -1,2 +0,0 @@
export * from "./subscription";
export * from "./extended-app-header";
@@ -1 +0,0 @@
export * from "./subscription-pill";
-4
View File
@@ -1,4 +0,0 @@
export * from "./de-dupe-button";
export * from "./duplicate-modal";
export * from "./duplicate-popover";
export * from "./issue-block";
@@ -1 +0,0 @@
export * from "./button-label";
-1
View File
@@ -1 +0,0 @@
export * from "./epic-modal";
@@ -1 +0,0 @@
export * from "./helper";
@@ -1 +0,0 @@
export * from "./header";
@@ -1 +0,0 @@
export * from "./teamspace-list";
@@ -16,10 +16,11 @@ type Props = {
params: IAnalyticsParams;
workspaceSlug: string;
classNames?: string;
isEpic?: boolean;
};
export const AnalyticsSelectParams: React.FC<Props> = observer((props) => {
const { control, params, classNames } = props;
const { control, params, classNames, isEpic } = props;
const xAxisOptions = useMemo(
() => ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.group_by),
[params.group_by]
@@ -42,7 +43,10 @@ export const AnalyticsSelectParams: React.FC<Props> = observer((props) => {
onChange(val);
}}
options={ANALYTICS_Y_AXIS_VALUES}
hiddenOptions={[ChartYAxisMetric.ESTIMATE_POINT_COUNT]}
hiddenOptions={[
ChartYAxisMetric.ESTIMATE_POINT_COUNT,
isEpic ? ChartYAxisMetric.WORK_ITEM_COUNT : ChartYAxisMetric.EPIC_WORK_ITEM_COUNT,
]}
/>
)}
/>
@@ -10,17 +10,13 @@ import AnalyticsSectionWrapper from "../analytics-section-wrapper";
import { AnalyticsSelectParams } from "../select/analytics-params";
import PriorityChart from "./priority-chart";
const defaultValues: IAnalyticsParams = {
x_axis: ChartXAxisProperty.PRIORITY,
y_axis: ChartYAxisMetric.WORK_ITEM_COUNT,
};
const CustomizedInsights = observer(({ peekView }: { peekView?: boolean }) => {
const CustomizedInsights = observer(({ peekView, isEpic }: { peekView?: boolean; isEpic?: boolean }) => {
const { t } = useTranslation();
const { workspaceSlug } = useParams();
const { control, watch, setValue } = useForm<IAnalyticsParams>({
defaultValues: {
...defaultValues,
x_axis: ChartXAxisProperty.PRIORITY,
y_axis: isEpic ? ChartYAxisMetric.EPIC_WORK_ITEM_COUNT : ChartYAxisMetric.WORK_ITEM_COUNT,
},
});
@@ -41,6 +37,7 @@ const CustomizedInsights = observer(({ peekView }: { peekView?: boolean }) => {
setValue={setValue}
params={params}
workspaceSlug={workspaceSlug.toString()}
isEpic={isEpic}
/>
}
>
@@ -17,10 +17,11 @@ type Props = {
projectDetails: IProject | undefined;
cycleDetails: ICycle | undefined;
moduleDetails: IModule | undefined;
isEpic?: boolean;
};
export const WorkItemsModalMainContent: React.FC<Props> = observer((props) => {
const { projectDetails, cycleDetails, moduleDetails, fullScreen } = props;
const { projectDetails, cycleDetails, moduleDetails, fullScreen, isEpic } = props;
const { updateSelectedProjects, updateSelectedCycle, updateSelectedModule, updateIsPeekView } = useAnalytics();
const [isModalConfigured, setIsModalConfigured] = useState(false);
@@ -72,7 +73,7 @@ export const WorkItemsModalMainContent: React.FC<Props> = observer((props) => {
<div className="flex flex-col gap-14 overflow-y-auto p-6">
<TotalInsights analyticsType="work-items" peekView={!fullScreen} />
<CreatedVsResolved />
<CustomizedInsights peekView={!fullScreen} />
<CustomizedInsights peekView={!fullScreen} isEpic={isEpic} />
<WorkItemsInsightTable />
</div>
</Tab.Group>
@@ -20,7 +20,7 @@ type Props = {
export const WorkItemsModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose, projectDetails, moduleDetails, cycleDetails, isEpic } = props;
const { updateIsEpic } = useAnalytics();
const { updateIsEpic, isPeekView } = useAnalytics();
const [fullScreen, setFullScreen] = useState(false);
const handleClose = () => {
@@ -29,18 +29,18 @@ export const WorkItemsModal: React.FC<Props> = observer((props) => {
};
useEffect(() => {
updateIsEpic(isEpic ?? false);
}, [isEpic, updateIsEpic]);
updateIsEpic(isPeekView ? (isEpic ?? false) : false);
}, [isEpic, updateIsEpic, isPeekView]);
const portalContainer = document.getElementById("full-screen-portal") as HTMLElement;
if (!isOpen) return null;
const content = (
<div className={cn("inset-0 z-[25] h-full w-full overflow-y-auto", fullScreen ? "absolute" : "fixed")}>
<div className={cn("z-[25] h-full w-full overflow-y-auto absolute")}>
<div
className={`right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${
fullScreen ? "w-full p-2 absolute" : "w-full sm:w-full md:w-1/2 fixed"
className={`top-0 right-0 z-[25] h-full bg-custom-background-100 shadow-custom-shadow-md absolute ${
fullScreen ? "w-full p-2" : "w-1/2"
}`}
>
<div
@@ -61,11 +61,12 @@ export const WorkItemsModal: React.FC<Props> = observer((props) => {
projectDetails={projectDetails}
cycleDetails={cycleDetails}
moduleDetails={moduleDetails}
isEpic={isEpic}
/>
</div>
</div>
</div>
);
return fullScreen && portalContainer ? createPortal(content, portalContainer) : content;
return portalContainer ? createPortal(content, portalContainer) : content;
});
+1 -1
View File
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
// plane imports
import { Row } from "@plane/ui";
// components
import { ExtendedAppHeader } from "@/plane-web/components/common";
import { ExtendedAppHeader } from "@/plane-web/components/common/extended-app-header";
export interface AppHeaderProps {
header: ReactNode;
@@ -1,6 +1,7 @@
"use client";
import React, { useEffect, useState } from "react";
import { filter } from "lodash";
import { Rocket, Search, X } from "lucide-react";
import { Combobox, Dialog, Transition } from "@headlessui/react";
// i18n
@@ -20,7 +21,6 @@ import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/iss
import { ProjectService } from "@/services/project";
// components
import { IssueSearchModalEmptyState } from "./issue-search-modal-empty-state";
import { filter } from "lodash";
type Props = {
workspaceSlug: string | undefined;
@@ -1,6 +1,6 @@
"use client";
import { FC, MouseEvent, useRef } from "react";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { usePathname, useSearchParams } from "next/navigation";
import { Check } from "lucide-react";
@@ -53,11 +53,6 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const isCompleted = cycleStatus === "completed";
const isActive = cycleStatus === "current";
const completionPercentage =
((cycleDetails.completed_issues + cycleDetails.cancelled_issues) / cycleDetails.total_issues) * 100;
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
// handlers
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.preventDefault();
@@ -78,6 +73,21 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const handleItemClick = cycleDetails.archived_at ? handleArchivedCycleClick : undefined;
const getCycleProgress = () => {
let completionPercentage =
((cycleDetails.completed_issues + cycleDetails.cancelled_issues) / cycleDetails.total_issues) * 100;
if (isCompleted && !isEmpty(cycleDetails.progress_snapshot)) {
completionPercentage =
((cycleDetails.progress_snapshot.completed_issues + cycleDetails.progress_snapshot.cancelled_issues) /
cycleDetails.progress_snapshot.total_issues) *
100;
}
return isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
};
const progress = getCycleProgress();
return (
<ListItem
title={cycleDetails?.name ?? ""}
@@ -1,6 +1,7 @@
import React, { useState } from "react";
// plane imports
// plane constants
import { EIssueCommentAccessSpecifier } from "@plane/constants";
// plane imports
import { type EditorRefApi, type ILiteTextEditorProps, LiteTextEditorWithRef, type TFileHandler } from "@plane/editor";
import { useTranslation } from "@plane/i18n";
import type { MakeOptional } from "@plane/types";
@@ -87,7 +88,6 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
// derived values
const isEmpty = isCommentEmpty(props.initialValue);
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
return (
<div
className={cn(
@@ -59,7 +59,6 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
}
// derived values
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
return (
<div
className={cn("relative border border-custom-border-200 rounded", parentClassName)}
@@ -104,8 +104,6 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
const currentInboxIssueId = inboxIssue?.issue?.id;
const intakeIssueLink = `${workspaceSlug}/projects/${issue?.project_id}/intake/?currentTab=${currentTab}&inboxIssueId=${currentInboxIssueId}`;
const redirectIssue = (): string | undefined => {
let nextOrPreviousIssueId: string | undefined = undefined;
const currentIssueIndex = filteredInboxIssueIds.findIndex((id) => id === currentInboxIssueId);
@@ -413,7 +411,7 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleCopyIssueLink(intakeIssueLink)}>
<CustomMenu.MenuItem onClick={() => handleCopyIssueLink(workItemLink)}>
<div className="flex items-center gap-2">
<Copy size={14} strokeWidth={2} />
{t("inbox_issue.actions.copy")}
@@ -26,7 +26,7 @@ import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useUser } from "@/hooks/store/user";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// store types
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe";
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover";
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
// services
import { IntakeWorkItemVersionService } from "@/services/inbox";
@@ -19,7 +19,8 @@ import { useAppRouter } from "@/hooks/use-app-router";
import useKeypress from "@/hooks/use-keypress";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { DeDupeButtonRoot, DuplicateModalRoot } from "@/plane-web/components/de-dupe";
import { DeDupeButtonRoot } from "@/plane-web/components/de-dupe/de-dupe-button";
import { DuplicateModalRoot } from "@/plane-web/components/de-dupe/duplicate-modal";
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
// services
import { FileService } from "@/services/file.service";
@@ -10,7 +10,7 @@ import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// Plane-web
import { CreateUpdateEpicModal } from "@/plane-web/components/epics";
import { CreateUpdateEpicModal } from "@/plane-web/components/epics/epic-modal";
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
import { TIssueRelationTypes } from "@/plane-web/types";
// helper
@@ -16,7 +16,7 @@ import { useUser } from "@/hooks/store/user";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
import useSize from "@/hooks/use-window-size";
// plane web components
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe";
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover";
import { IssueTypeSwitcher } from "@/plane-web/components/issues/issue-details/issue-type-switcher";
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
// services
@@ -71,7 +71,8 @@ export const isWorkspaceLevel = (type: EIssuesStoreType) =>
EIssuesStoreType.GLOBAL,
EIssuesStoreType.TEAM,
EIssuesStoreType.TEAM_VIEW,
EIssuesStoreType.PROJECT_VIEW,
EIssuesStoreType.TEAM_PROJECT_WORK_ITEMS,
EIssuesStoreType.WORKSPACE_DRAFT,
].includes(type)
? true
: false;
@@ -40,7 +40,8 @@ import { useWorkspaceDraftIssues } from "@/hooks/store/workspace-draft";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties";
// plane web imports
import { DeDupeButtonRoot, DuplicateModalRoot } from "@/plane-web/components/de-dupe";
import { DeDupeButtonRoot } from "@/plane-web/components/de-dupe/de-dupe-button";
import { DuplicateModalRoot } from "@/plane-web/components/de-dupe/duplicate-modal";
import { IssueTypeSelect, WorkItemTemplateSelect } from "@/plane-web/components/issues/issue-modal";
import { WorkItemModalAdditionalProperties } from "@/plane-web/components/issues/issue-modal/modal-additional-properties";
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
@@ -14,7 +14,7 @@ import { useProject } from "@/hooks/store/use-project";
import { useUser } from "@/hooks/store/user";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// plane web components
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe";
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover";
import { IssueTypeSwitcher } from "@/plane-web/components/issues/issue-details/issue-type-switcher";
// plane web hooks
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
@@ -110,16 +110,16 @@ export const IssueView: FC<IIssueView> = observer((props) => {
const peekOverviewIssueClassName = cn(
!embedIssue
? "fixed z-[25] flex flex-col overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 transition-all duration-300"
? "absolute z-[25] flex flex-col overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 transition-all duration-300"
: `w-full h-full`,
!embedIssue && {
"top-2 bottom-2 right-2 w-full md:w-[50%] border-0 border-l": peekMode === "side-peek",
"top-0 bottom-0 right-0 w-full md:w-[50%] border-0 border-l": peekMode === "side-peek",
"size-5/6 top-[8.33%] left-[8.33%]": peekMode === "modal",
"inset-0 m-4 absolute": peekMode === "full-screen",
}
);
const shouldUsePortal = !embedIssue && peekMode === "full-screen";
const shouldUsePortal = !embedIssue;
const portalContainer = document.getElementById("full-screen-portal") as HTMLElement;
@@ -6,7 +6,7 @@ import { WorkspaceLogo } from "@/components/workspace/logo";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
// plane web imports
import { SubscriptionPill } from "@/plane-web/components/common";
import { SubscriptionPill } from "@/plane-web/components/common/subscription/subscription-pill";
export const SettingsSidebarHeader = observer((props: { customHeader?: React.ReactNode }) => {
const { customHeader } = props;
@@ -0,0 +1,72 @@
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>
);
});
@@ -3,15 +3,14 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Check, Settings, UserPlus } from "lucide-react";
// plane imports
import { Menu } from "@headlessui/react";
// plane imports
import { EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IWorkspace } from "@plane/types";
import { cn, getFileURL, getUserRole } from "@plane/utils";
// helpers
// plane web imports
import { SubscriptionPill } from "@/plane-web/components/common/subscription";
import { SubscriptionPill } from "@/plane-web/components/common/subscription/subscription-pill";
type TProps = {
workspace: IWorkspace;
+15 -12
View File
@@ -1,4 +1,4 @@
// types
// plane imports
import { API_BASE_URL } from "@plane/constants";
import {
EIssueServiceType,
@@ -12,12 +12,8 @@ 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 {
@@ -85,7 +81,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;
}
@@ -118,9 +114,10 @@ export class IssueService extends APIService {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/`, {
params: queries,
})
.then((response) => {
.then(async (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
@@ -138,8 +135,9 @@ export class IssueService extends APIService {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/list/`, {
params: { issues: issueIds.join(",") },
})
.then((response) => {
.then(async (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;
@@ -246,6 +244,7 @@ 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}/`)
@@ -354,8 +353,9 @@ 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((response) => {
.then(async (response) => {
if (this.serviceType === EIssueServiceType.ISSUES) {
const { persistence } = await import("@/local-db/storage.sqlite");
persistence.syncIssues(projectId);
}
return response?.data;
@@ -373,8 +373,9 @@ export class IssueService extends APIService {
}
): Promise<any> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-delete-issues/`, data)
.then((response) => {
.then(async (response) => {
if (this.serviceType === EIssueServiceType.ISSUES) {
const { persistence } = await import("@/local-db/storage.sqlite");
persistence.syncIssues(projectId);
}
return response?.data;
@@ -394,8 +395,9 @@ export class IssueService extends APIService {
archived_at: string;
}> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-archive-issues/`, data)
.then((response) => {
.then(async (response) => {
if (this.serviceType === EIssueServiceType.ISSUES) {
const { persistence } = await import("@/local-db/storage.sqlite");
persistence.syncIssues(projectId);
}
return response?.data;
@@ -476,9 +478,10 @@ export class IssueService extends APIService {
return this.get(`/api/workspaces/${workspaceSlug}/work-items/${project_identifier}-${issue_sequence}/`, {
params: queries,
})
.then((response) => {
.then(async (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
@@ -95,6 +95,7 @@ export interface IIssueDetail
attachmentDeleteModalId: string | null;
// computed
isAnyModalOpen: boolean;
isPeekOpen: boolean;
// helper actions
getIsIssuePeeked: (issueId: string) => boolean;
// actions
@@ -188,6 +189,7 @@ export abstract class IssueDetail implements IIssueDetail {
lastWidgetAction: observable.ref,
// computed
isAnyModalOpen: computed,
isPeekOpen: computed,
// action
setPeekIssue: action,
setIssueLinkData: action,
@@ -235,6 +237,10 @@ export abstract class IssueDetail implements IIssueDetail {
);
}
get isPeekOpen() {
return !!this.peekIssue;
}
// helper actions
getIsIssuePeeked = (issueId: string) => this.peekIssue?.issueId === issueId;
-1
View File
@@ -1 +0,0 @@
export * from "ce/components/de-dupe";
-1
View File
@@ -1 +0,0 @@
export * from "ce/components/epics";
@@ -1 +0,0 @@
export * from "ce/components/projects/settings/intake";
+1 -1
View File
@@ -12,7 +12,7 @@ const nextConfig = {
return [
{
source: "/(.*)?",
headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }],
headers: [{ key: "X-Frame-Options", value: "DENY" }],
},
];
},
+5 -1
View File
@@ -15,6 +15,7 @@
"start": "turbo run start",
"clean": "turbo run clean && rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist",
"fix": "turbo run fix",
"fix:format": "turbo run fix:format",
"check": "turbo run check",
"check:lint": "turbo run check:lint",
"check:format": "turbo run check:format"
@@ -39,5 +40,8 @@
"sharp": "0.33.5"
}
},
"packageManager": "pnpm@10.12.1"
"packageManager": "pnpm@10.12.1",
"engines": {
"node": ">=22.18.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
node_modules
build/*
dist/*
out/*
@@ -1,5 +1,4 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
};
@@ -174,6 +174,10 @@ export const ANALYTICS_Y_AXIS_VALUES: { value: ChartYAxisMetric; label: string }
value: ChartYAxisMetric.ESTIMATE_POINT_COUNT,
label: "Estimate",
},
{
value: ChartYAxisMetric.EPIC_WORK_ITEM_COUNT,
label: "Epic",
},
];
export const ANALYTICS_V2_DATE_KEYS = ["completed_at", "target_date", "start_date", "created_at"];
-2
View File
@@ -1,6 +1,4 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
};
+4
View File
@@ -0,0 +1,4 @@
node_modules
build/*
dist/*
out/*
-1
View File
@@ -1,5 +1,4 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
};
@@ -4,7 +4,7 @@ import type { IEditorProps } from "@/types";
export type TCoreAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "embedHandler"
>;
export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => {
@@ -108,6 +108,8 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
isTouchDevice={!!isTouchDevice}
isLoading={!hasServerSynced && !hasServerConnectionFailed}
tabIndex={tabIndex}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
/>
);
};
@@ -82,6 +82,7 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
initialValue: value,
mentionHandler,
onChange,
embedHandler,
});
const editorContainerClassName = getEditorClassNames({
@@ -97,6 +98,8 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
editor={editor}
editorContainerClassName={cn(editorContainerClassName, "document-editor")}
id={id}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
isTouchDevice={!!isTouchDevice}
/>
);
@@ -5,7 +5,7 @@ import { cn } from "@plane/utils";
import { DocumentContentLoader, EditorContainer, EditorContentWrapper } from "@/components/editors";
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
// types
import { TAIHandler, TDisplayConfig } from "@/types";
import { IEditorProps, TAIHandler, TDisplayConfig } from "@/types";
type Props = {
aiHandler?: TAIHandler;
@@ -18,6 +18,8 @@ type Props = {
isLoading?: boolean;
isTouchDevice: boolean;
tabIndex?: number;
flaggedExtensions: IEditorProps["flaggedExtensions"];
disabledExtensions: IEditorProps["disabledExtensions"];
};
export const PageRenderer = (props: Props) => {
@@ -32,6 +34,8 @@ export const PageRenderer = (props: Props) => {
isLoading,
isTouchDevice,
tabIndex,
flaggedExtensions,
disabledExtensions,
} = props;
return (
@@ -54,7 +58,11 @@ export const PageRenderer = (props: Props) => {
{editor.isEditable && !isTouchDevice && (
<div>
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
<BlockMenu editor={editor} />
<BlockMenu
editor={editor}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
/>
<AIFeaturesMenu menu={aiHandler?.menu} />
</div>
)}
@@ -41,6 +41,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
placeholder,
tabIndex,
value,
embedHandler,
} = props;
const editor = useEditor({
@@ -65,6 +66,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
placeholder,
tabIndex,
value,
embedHandler,
});
const editorContainerClassName = getEditorClassNames({
@@ -1,8 +1,12 @@
import { autoUpdate, flip, hide, shift, useDismiss, useFloating, useInteractions } from "@floating-ui/react";
import { Editor, useEditorState } from "@tiptap/react";
import { FC, useCallback, useEffect, useRef, useState } from "react";
// components
import { LinkView, LinkViewProps } from "@/components/links";
import { CORE_EXTENSIONS } from "@/constants/extension";
// components
import { getExtensionStorage } from "@/helpers/get-extension-storage";
type Props = {
editor: Editor;
@@ -18,7 +22,7 @@ export const LinkViewContainer: FC<Props> = ({ editor, containerRef }) => {
const editorState = useEditorState({
editor,
selector: ({ editor }: { editor: Editor }) => ({
linkExtensionStorage: editor.storage.link,
linkExtensionStorage: getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_LINK),
}),
});

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