Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 801a40acd5 | |||
| ddeabeeeb1 | |||
| 0e6fbaee3a | |||
| cfe710d492 | |||
| 4a3c172992 | |||
| 5d1ad8a183 | |||
| f95a07d8c8 | |||
| 0af75897f5 | |||
| 3602ff6930 | |||
| 6d1275d58c | |||
| cf7b288f93 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.next/*
|
||||
out/*
|
||||
public/*
|
||||
dist/*
|
||||
node_modules/*
|
||||
.turbo/*
|
||||
.env*
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
.env.test
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", []):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -200,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
|
||||
|
||||
|
||||
@@ -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", []):
|
||||
|
||||
@@ -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", []):
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -465,3 +465,7 @@ if ENABLE_DRF_SPECTACULAR:
|
||||
REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema"
|
||||
INSTALLED_APPS.append("drf_spectacular")
|
||||
from .openapi import SPECTACULAR_SETTINGS # noqa: F401
|
||||
|
||||
# MongoDB Settings
|
||||
MONGO_DB_URL = os.environ.get("MONGO_DB_URL", False)
|
||||
MONGO_DB_DATABASE = os.environ.get("MONGO_DB_DATABASE", False)
|
||||
|
||||
@@ -118,4 +118,7 @@ class MongoConnection:
|
||||
Returns:
|
||||
bool: True if MongoDB is configured and connected, False otherwise
|
||||
"""
|
||||
|
||||
if cls._client is None:
|
||||
cls._instance = cls()
|
||||
return cls._client is not None and cls._db is not None
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,3 +69,5 @@ opentelemetry-instrumentation-django==0.49b1
|
||||
opentelemetry-exporter-otlp==1.28.1
|
||||
# OpenAPI Specification
|
||||
drf-spectacular==0.28.0
|
||||
# html sanitizer
|
||||
nh3==0.2.18
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/server.js"],
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["@plane/eslint-config/server.js"],
|
||||
"parser": "@typescript-eslint/parser"
|
||||
}
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.next/*
|
||||
out/*
|
||||
public/*
|
||||
dist/*
|
||||
node_modules/*
|
||||
.turbo/*
|
||||
.env*
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
.env.test
|
||||
@@ -1,6 +1,4 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/next.js"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
};
|
||||
|
||||
+10
-1
@@ -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
|
||||
@@ -1,6 +1,4 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/next.js"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -80,7 +80,7 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
|
||||
const payload: any = { ...data };
|
||||
if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder;
|
||||
|
||||
updateIssue && (await updateIssue(issue.project_id, issue.id, payload));
|
||||
await updateIssue?.(issue.project_id, issue.id, payload);
|
||||
};
|
||||
|
||||
const isAllowed = allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT);
|
||||
|
||||
@@ -112,8 +112,8 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
const issueDetails = getIssueById(issueId);
|
||||
const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);
|
||||
|
||||
const handleIssuePeekOverview = (e: any) => {
|
||||
e.stopPropagation(true);
|
||||
const handleIssuePeekOverview = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleRedirection(workspaceSlug, issueDetails, isMobile);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-sc
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { EIssueFilterType, EUserPermissions, EUserPermissionsLevel, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { EIssueServiceType, EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
import { EIssueServiceType, EIssuesStoreType, EIssueLayoutTypes, TIssue } from "@plane/types";
|
||||
//constants
|
||||
//hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
@@ -42,7 +42,7 @@ export type KanbanStoreType =
|
||||
|
||||
export interface IBaseKanBanLayout {
|
||||
QuickActions: FC<IQuickActionProps>;
|
||||
addIssuesToView?: (issueIds: string[]) => Promise<any>;
|
||||
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
||||
canEditPropertiesBasedOnProject?: (projectId: string) => boolean;
|
||||
isCompletedCycle?: boolean;
|
||||
viewId?: string | undefined;
|
||||
|
||||
@@ -238,7 +238,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [cardRef?.current, issue?.id, isDragAllowed, canDropOverIssue, setIsCurrentBlockDragging, setIsDraggingOverBlock]);
|
||||
}, [issue?.id, isDragAllowed, canDropOverIssue, setIsCurrentBlockDragging, setIsDraggingOverBlock]);
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { MutableRefObject } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// plane imports
|
||||
import {
|
||||
GroupByColumnTypes,
|
||||
IGroupByColumn,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
TIssueGroupByOptions,
|
||||
TIssueOrderByOptions,
|
||||
} from "@plane/types";
|
||||
// constants
|
||||
import { ContentWrapper } from "@plane/ui";
|
||||
// components
|
||||
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
|
||||
@@ -92,8 +90,6 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
subGroupIndex = 0,
|
||||
isEpic = false,
|
||||
} = props;
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const storeType = useIssueStoreType();
|
||||
const issueKanBanView = useKanbanView();
|
||||
|
||||
@@ -78,7 +78,7 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
|
||||
title: "Success!",
|
||||
message: "Work items added to the cycle successfully.",
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||
import { IssueBlockRoot } from "./block-root";
|
||||
import { TRenderQuickActions } from "./list-view-types";
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
issueIds: TGroupedIssues | any;
|
||||
issuesMap: TIssueMap;
|
||||
groupId: string;
|
||||
@@ -20,7 +20,7 @@ interface Props {
|
||||
canDropOverIssue: boolean;
|
||||
selectionHelpers: TSelectionHelper;
|
||||
isEpic?: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
export const IssueBlocksList: FC<Props> = (props) => {
|
||||
const {
|
||||
|
||||
@@ -81,7 +81,7 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
|
||||
title: "Success!",
|
||||
message: "Work items added to the cycle successfully.",
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
|
||||
@@ -192,7 +192,9 @@ export const ListGroup = observer((props: Props) => {
|
||||
const sourceGroupId = source?.data?.groupId as string | undefined;
|
||||
const currentGroupId = group.id;
|
||||
|
||||
sourceGroupId && handleWorkFlowState(sourceGroupId, currentGroupId);
|
||||
if (sourceGroupId) {
|
||||
handleWorkFlowState(sourceGroupId, currentGroupId);
|
||||
}
|
||||
|
||||
const sourceIndex = getGroupIndex(sourceGroupId);
|
||||
const currentIndex = getGroupIndex(currentGroupId);
|
||||
@@ -230,15 +232,7 @@ export const ListGroup = observer((props: Props) => {
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [
|
||||
groupRef?.current,
|
||||
group,
|
||||
orderBy,
|
||||
getGroupIndex,
|
||||
setDragColumnOrientation,
|
||||
setIsDraggingOverColumn,
|
||||
isWorkflowDropDisabled,
|
||||
]);
|
||||
}, [group, orderBy, getGroupIndex, setDragColumnOrientation, setIsDraggingOverColumn, isWorkflowDropDisabled]);
|
||||
|
||||
const isDragAllowed = !!group_by && DRAG_ALLOWED_GROUPS.includes(group_by);
|
||||
const canOverlayBeVisible = isWorkflowDropDisabled || orderBy !== "sort_order" || !!group.isDropDisabled;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { FC, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { Link2, MoveDiagonal, MoveRight } from "lucide-react";
|
||||
import { Link2, type LucideIcon, MoveDiagonal, MoveRight } from "lucide-react";
|
||||
// plane imports
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CenterPanelIcon,
|
||||
CustomSelect,
|
||||
FullScreenPanelIcon,
|
||||
type ISvgIcons,
|
||||
SidePanelIcon,
|
||||
TOAST_TYPE,
|
||||
Tooltip,
|
||||
@@ -33,7 +34,7 @@ import { NameDescriptionUpdateStatus } from "../issue-update-status";
|
||||
|
||||
export type TPeekModes = "side-peek" | "modal" | "full-screen";
|
||||
|
||||
const PEEK_OPTIONS: { key: TPeekModes; icon: any; i18n_title: string }[] = [
|
||||
const PEEK_OPTIONS: { key: TPeekModes; icon: LucideIcon | React.FC<ISvgIcons>; i18n_title: string }[] = [
|
||||
{
|
||||
key: "side-peek",
|
||||
icon: SidePanelIcon,
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
import { PROJECT_SETTINGS_TRACKER_ELEMENTS, PROJECT_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { PROJECT_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
|
||||
import type { IIssueLabel } from "@plane/types";
|
||||
// ui
|
||||
import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
|
||||
@@ -154,7 +154,7 @@ export const LabelDndHOC = observer((props: Props) => {
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [labelRef?.current, dragHandleRef?.current, label, isChild, isGroup, isLastChild, onDrop]);
|
||||
}, [dragHandleRef?.current, label, isChild, isGroup, isLastChild, onDrop]);
|
||||
|
||||
const isMakeChild = instruction == "make-child";
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
|
||||
await fetchModuleDetails(workspaceSlug, projectId, moduleId);
|
||||
}
|
||||
setLoader(false);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setLoader(false);
|
||||
setPlotType(moduleId, plotType);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@plane/constants";
|
||||
// plane types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ILinkDetails, IModule, ModuleLink } from "@plane/types";
|
||||
import { ILinkDetails, IModule, ModuleLink, TModuleStatus } from "@plane/types";
|
||||
// plane ui
|
||||
import { Loader, LayersIcon, CustomSelect, ModuleStatusIcon, TOAST_TYPE, setToast, TextArea } from "@plane/ui";
|
||||
// components
|
||||
@@ -270,7 +270,7 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||
</span>
|
||||
}
|
||||
value={value}
|
||||
onChange={(value: any) => {
|
||||
onChange={(value: TModuleStatus) => {
|
||||
submitChanges({ status: value });
|
||||
}}
|
||||
disabled={!isEditingAllowed || isArchived}
|
||||
|
||||
@@ -27,7 +27,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const CreateOrJoinWorkspaces: React.FC<Props> = observer((props) => {
|
||||
const { invitations, totalSteps, stepChange, finishOnboarding } = props;
|
||||
const { invitations, totalSteps: _totalSteps, stepChange, finishOnboarding } = props;
|
||||
// states
|
||||
const [currentView, setCurrentView] = useState<ECreateOrJoinWorkspaceViews | null>(null);
|
||||
// store hooks
|
||||
|
||||
@@ -262,7 +262,7 @@ const InviteMemberInput: React.FC<InviteMemberFormProps> = observer((props) => {
|
||||
});
|
||||
|
||||
export const InviteMembers: React.FC<Props> = (props) => {
|
||||
const { finishOnboarding, totalSteps, workspace } = props;
|
||||
const { finishOnboarding, totalSteps: _totalSteps, workspace } = props;
|
||||
|
||||
const [isInvitationDisabled, setIsInvitationDisabled] = useState(true);
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Listbox } from "@headlessui/react";
|
||||
import { ROLE, ROLE_DETAILS, EUserPermissions, MEMBER_TRACKER_EVENTS, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { EOnboardingSteps, IWorkspace } from "@plane/types";
|
||||
import { EOnboardingSteps } from "@plane/types";
|
||||
// ui
|
||||
import { Button, Input, Spinner, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// constants
|
||||
@@ -30,7 +30,6 @@ import { Button, Input, Spinner, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser, useUserProfile } from "@/hooks/store/user";
|
||||
// services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// components
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
// icons
|
||||
import { FileText, Layers } from "lucide-react";
|
||||
import { ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui";
|
||||
// types
|
||||
import { TTourSteps } from "./root";
|
||||
import { FileText, Layers, type LucideIcon } from "lucide-react";
|
||||
// plane imports
|
||||
import { ContrastIcon, DiceIcon, type ISvgIcons, LayersIcon } from "@plane/ui";
|
||||
// local imports
|
||||
import type { TTourSteps } from "./root";
|
||||
|
||||
const sidebarOptions: {
|
||||
key: TTourSteps;
|
||||
label: string;
|
||||
Icon: any;
|
||||
Icon: LucideIcon | React.FC<ISvgIcons>;
|
||||
}[] = [
|
||||
{
|
||||
key: "work-items",
|
||||
|
||||
@@ -27,7 +27,7 @@ import { PageEditorToolbarRoot } from "./toolbar";
|
||||
export type TPageRootHandlers = {
|
||||
create: (payload: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
|
||||
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
|
||||
fetchDescriptionBinary: () => Promise<any>;
|
||||
fetchDescriptionBinary: () => Promise<ArrayBuffer>;
|
||||
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
|
||||
getRedirectionLink: (pageId: string) => string;
|
||||
updateDescription: (document: TDocumentPayload) => Promise<void>;
|
||||
|
||||
@@ -77,7 +77,7 @@ export const PageForm: React.FC<Props> = (props) => {
|
||||
</>
|
||||
</span>
|
||||
}
|
||||
onChange={(val: any) => {
|
||||
onChange={(val) => {
|
||||
let logoValue = {};
|
||||
|
||||
if (val?.type === "emoji")
|
||||
|
||||
@@ -56,7 +56,7 @@ export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
|
||||
<>
|
||||
{userProfileActivity ? (
|
||||
<ul role="list">
|
||||
{userProfileActivity.results.map((activityItem: any) => {
|
||||
{userProfileActivity.results.map((activityItem) => {
|
||||
if (activityItem.field === "comment")
|
||||
return (
|
||||
<div key={activityItem.id} className="mt-2">
|
||||
@@ -98,7 +98,7 @@ export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
|
||||
editable={false}
|
||||
id={activityItem.id}
|
||||
initialValue={
|
||||
activityItem?.new_value !== "" ? activityItem.new_value : activityItem.old_value
|
||||
(activityItem?.new_value !== "" ? activityItem.new_value : activityItem.old_value) ?? ""
|
||||
}
|
||||
containerClassName="text-xs bg-custom-background-100"
|
||||
workspaceId={activityItem?.workspace_detail?.id?.toString() ?? ""}
|
||||
@@ -155,7 +155,7 @@ export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
|
||||
<span className="text-gray font-medium">{activityItem.actor_detail.first_name} Bot</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`/${activityItem.workspace_detail.slug}/profile/${activityItem.actor_detail.id}`}
|
||||
href={`/${activityItem.workspace_detail?.slug}/profile/${activityItem.actor_detail.id}`}
|
||||
className="inline"
|
||||
>
|
||||
<span className="text-gray font-medium">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { IFavorite, InstructionType, IPragmaticPayloadLocation, TDropTarget } from "@plane/types";
|
||||
|
||||
export type TargetData = {
|
||||
|
||||
@@ -66,7 +66,7 @@ export const UserMenuRoot = observer((props: Props) => {
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative flex-shrink-0">
|
||||
{({ open, close }: { open: boolean; close: () => void }) => {
|
||||
{({ open }: { open: boolean; close: () => void }) => {
|
||||
// Update local state directly
|
||||
if (isUserMenuOpen !== open) {
|
||||
setIsUserMenuOpen(open);
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
List,
|
||||
ListOrdered,
|
||||
ListTodo,
|
||||
LucideIcon,
|
||||
type LucideIcon,
|
||||
Strikethrough,
|
||||
Table,
|
||||
TextQuote,
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from "lucide-react";
|
||||
// plane imports
|
||||
import type { TCommandExtraProps, TEditorCommands, TEditorFontStyle } from "@plane/editor";
|
||||
import { MonospaceIcon, SansSerifIcon, SerifIcon } from "@plane/ui";
|
||||
import { type ISvgIcons, MonospaceIcon, SansSerifIcon, SerifIcon } from "@plane/ui";
|
||||
import { convertRemToPixel } from "@plane/utils";
|
||||
|
||||
type TEditorTypes = "lite" | "document" | "sticky";
|
||||
@@ -191,7 +191,7 @@ export const TOOLBAR_ITEMS: {
|
||||
export const EDITOR_FONT_STYLES: {
|
||||
key: TEditorFontStyle;
|
||||
label: string;
|
||||
icon: any;
|
||||
icon: LucideIcon | React.FC<ISvgIcons>;
|
||||
}[] = [
|
||||
{
|
||||
key: "sans-serif",
|
||||
|
||||
@@ -90,7 +90,7 @@ export const useGroupIssuesDragNDrop = (
|
||||
delete data[moduleKey];
|
||||
}
|
||||
|
||||
updateIssue && updateIssue(projectId, issueId, data).catch(() => setToast(errorToastProps));
|
||||
updateIssue?.(projectId, issueId, data).catch(() => setToast(errorToastProps));
|
||||
};
|
||||
|
||||
const handleOnDrop = async (source: GroupDropLocation, destination: GroupDropLocation) => {
|
||||
|
||||
@@ -18,7 +18,7 @@ export const useIntersectionObserver = (
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[entries.length - 1].isIntersecting) {
|
||||
callback && callback();
|
||||
callback?.();
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
export const getValueFromLocalStorage = (key: string, defaultValue: any) => {
|
||||
export const getValueFromLocalStorage = (key: string, defaultValue: unknown) => {
|
||||
if (typeof window === undefined || typeof window === "undefined") return defaultValue;
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
window.localStorage.removeItem(key);
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
export const setValueIntoLocalStorage = (key: string, value: any) => {
|
||||
export const setValueIntoLocalStorage = (key: string, value: unknown) => {
|
||||
if (typeof window === undefined || typeof window === "undefined") return false;
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ const useReloadConfirmations = (isActive = true, message?: string, defaultShowAl
|
||||
// show confirm dialog
|
||||
const isLeaving = confirm(alertMessage);
|
||||
if (isLeaving) {
|
||||
onLeave && onLeave();
|
||||
onLeave?.();
|
||||
} else {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -18,6 +18,7 @@ import { runQuery } from "./utils/query-executor";
|
||||
import { sanitizeWorkItemQueries } from "./utils/query-sanitizer.ts";
|
||||
import { createTables } from "./utils/tables";
|
||||
import { clearOPFS, getGroupedIssueResults, getSubGroupedIssueResults, log, logError } from "./utils/utils";
|
||||
import type { DBClass } from "./worker/db";
|
||||
|
||||
const DB_VERSION = 1.3;
|
||||
const PAGE_SIZE = 500;
|
||||
@@ -70,7 +71,6 @@ export class Storage {
|
||||
};
|
||||
|
||||
private initializeWorker = async (workspaceSlug: string) => {
|
||||
const { DBClass } = await import("./worker/db");
|
||||
const worker = new Worker(new URL("./worker/db.ts", import.meta.url));
|
||||
const MyWorker = Comlink.wrap<typeof DBClass>(worker);
|
||||
|
||||
@@ -367,7 +367,6 @@ export class Storage {
|
||||
issueResults = getGroupedIssueResults(issueResults);
|
||||
}
|
||||
}
|
||||
const groupCount = group_by ? Object.keys(issueResults).length : undefined;
|
||||
// const subGroupCount = sub_group_by ? Object.keys(issueResults[Object.keys(issueResults)[0]]).length : undefined;
|
||||
const groupingEnd = performance.now();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -98,9 +98,11 @@ export class UserSettingsStore implements IUserSettingsStore {
|
||||
} else if (workspaceSlug) {
|
||||
await persistence.initialize(workspaceSlug);
|
||||
persistence.syncWorkspace();
|
||||
projectId && persistence.syncIssues(projectId);
|
||||
if (projectId) {
|
||||
persistence.syncIssues(projectId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
console.warn("error while toggling local DB");
|
||||
runInAction(() => {
|
||||
this.canUseLocalDB = currentLocalDBValue;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -1,6 +1,4 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@plane/eslint-config/library.js"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const DRAG_HANDLE_ADDITIONAL_SELECTORS = [];
|
||||
@@ -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 => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { AnyExtension } from "@tiptap/core";
|
||||
import { SlashCommands } from "@/extensions";
|
||||
// plane editor types
|
||||
import type { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import type { IEditorProps, TExtensions, TUserDetails } from "@/types";
|
||||
|
||||
@@ -8,6 +10,7 @@ export type TDocumentEditorAdditionalExtensionsProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
> & {
|
||||
embedConfig: TEmbedConfig | undefined;
|
||||
isEditable: boolean;
|
||||
provider?: HocuspocusProvider;
|
||||
userDetails: TUserDetails;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
// helpers
|
||||
import type { TEditorRefHelperArgs } from "@/helpers/editor-ref";
|
||||
// local imports
|
||||
import type { TAdditionalEditorRefApiMethods } from "../types/editor";
|
||||
|
||||
export const getAdditionalEditorRefHelpers = (_args: TEditorRefHelperArgs): TAdditionalEditorRefApiMethods => ({});
|
||||
@@ -1,7 +0,0 @@
|
||||
export type TAdditionalEditorCommands = never;
|
||||
|
||||
export type TAdditionalCommandExtraProps = {};
|
||||
|
||||
export type TAdditionalEditorRefApiMethods = {};
|
||||
|
||||
export type IEditorPropsExtended = {};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./issue-embed";
|
||||
@@ -0,0 +1,17 @@
|
||||
export type TEmbedConfig = {
|
||||
issue?: TIssueEmbedConfig;
|
||||
};
|
||||
|
||||
export type TReadOnlyEmbedConfig = TEmbedConfig;
|
||||
|
||||
export type TIssueEmbedConfig = {
|
||||
widgetCallback: ({
|
||||
issueId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
}: {
|
||||
issueId: string;
|
||||
projectId: string | undefined;
|
||||
workspaceSlug: string | undefined;
|
||||
}) => React.ReactNode;
|
||||
};
|
||||
@@ -6,6 +6,8 @@ import { cn } from "@plane/utils";
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// extensions
|
||||
import { WorkItemEmbedExtension } from "@/extensions";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
@@ -19,12 +21,13 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
||||
bubbleMenuEnabled = true,
|
||||
containerClassName,
|
||||
documentLoaderClassName,
|
||||
extensions,
|
||||
extensions: externalExtensions = [],
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editable,
|
||||
editorClassName = "",
|
||||
editorProps,
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
@@ -44,12 +47,27 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
||||
user,
|
||||
} = props;
|
||||
|
||||
const extensions: Extensions = useMemo(() => {
|
||||
const allExtensions = [...externalExtensions];
|
||||
|
||||
if (embedHandler?.issue) {
|
||||
allExtensions.push(
|
||||
WorkItemEmbedExtension({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return allExtensions;
|
||||
}, [externalExtensions, embedHandler.issue]);
|
||||
|
||||
// use document editor
|
||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
|
||||
disabledExtensions,
|
||||
editable,
|
||||
editorClassName,
|
||||
editorProps,
|
||||
embedHandler,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// extensions
|
||||
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
|
||||
import { HeadingListExtension, WorkItemEmbedExtension, SideMenuExtension } from "@/extensions";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
@@ -25,6 +25,7 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editable,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
@@ -38,6 +39,13 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
|
||||
} = props;
|
||||
const extensions: Extensions = useMemo(() => {
|
||||
const additionalExtensions: Extensions = [];
|
||||
if (embedHandler?.issue) {
|
||||
additionalExtensions.push(
|
||||
WorkItemEmbedExtension({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
})
|
||||
);
|
||||
}
|
||||
additionalExtensions.push(
|
||||
SideMenuExtension({
|
||||
aiEnabled: !disabledExtensions?.includes("ai"),
|
||||
@@ -46,6 +54,7 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
|
||||
HeadingListExtension,
|
||||
...DocumentEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
embedConfig: embedHandler,
|
||||
flaggedExtensions,
|
||||
isEditable: editable,
|
||||
fileHandler,
|
||||
@@ -73,6 +82,7 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
|
||||
initialValue: value,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
embedHandler,
|
||||
});
|
||||
|
||||
const editorContainerClassName = getEditorClassNames({
|
||||
@@ -88,9 +98,9 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
|
||||
editor={editor}
|
||||
editorContainerClassName={cn(editorContainerClassName, "document-editor")}
|
||||
id={id}
|
||||
isTouchDevice={!!isTouchDevice}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
disabledExtensions={disabledExtensions}
|
||||
isTouchDevice={!!isTouchDevice}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,8 +18,8 @@ type Props = {
|
||||
isLoading?: boolean;
|
||||
isTouchDevice: boolean;
|
||||
tabIndex?: number;
|
||||
flaggedExtensions?: IEditorProps["flaggedExtensions"];
|
||||
disabledExtensions?: IEditorProps["disabledExtensions"];
|
||||
flaggedExtensions: IEditorProps["flaggedExtensions"];
|
||||
disabledExtensions: IEditorProps["disabledExtensions"];
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: Props) => {
|
||||
|
||||
@@ -41,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({
|
||||
|
||||
@@ -55,6 +55,7 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
|
||||
isActive: () => editorState.right,
|
||||
},
|
||||
];
|
||||
if (editorState.code) return null;
|
||||
|
||||
return (
|
||||
<div className="flex gap-0.5 px-2">
|
||||
|
||||
@@ -21,6 +21,7 @@ import { CustomMentionExtensionConfig } from "./mentions/extension-config";
|
||||
import { CustomQuoteExtension } from "./quote";
|
||||
import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||
import { CustomTextAlignExtension } from "./text-align";
|
||||
import { WorkItemEmbedExtensionConfig } from "./work-item-embed/extension-config";
|
||||
|
||||
export const CoreEditorExtensionsWithoutProps = [
|
||||
StarterKit.configure({
|
||||
@@ -100,3 +101,5 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
CustomColorExtension,
|
||||
...CoreEditorAdditionalExtensionsWithoutProps,
|
||||
];
|
||||
|
||||
export const DocumentEditorExtensionsWithoutProps = [WorkItemEmbedExtensionConfig];
|
||||
|
||||
@@ -16,7 +16,9 @@ import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import emojiRegex from "emoji-regex";
|
||||
import { isEmojiSupported } from "is-emoji-supported";
|
||||
// helpers
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { customFindSuggestionMatch } from "@/helpers/find-suggestion-match";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
@@ -342,6 +344,10 @@ export const Emoji = Node.create<EmojiOptions, EmojiStorage>({
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const isTouchDevice = !!getExtensionStorage(this.editor, CORE_EXTENSIONS.UTILITY).isTouchDevice;
|
||||
if (isTouchDevice) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { gitHubEmojis, shortcodeToEmoji } from "@tiptap/extension-emoji";
|
||||
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { Emoji } from "./emoji";
|
||||
import suggestion from "./suggestion";
|
||||
import { emojiSuggestion } from "./suggestion";
|
||||
|
||||
export const EmojiExtension = Emoji.extend({
|
||||
addStorage() {
|
||||
@@ -25,6 +25,6 @@ export const EmojiExtension = Emoji.extend({
|
||||
},
|
||||
}).configure({
|
||||
emojis: gitHubEmojis,
|
||||
suggestion: suggestion,
|
||||
suggestion: emojiSuggestion,
|
||||
enableEmoticons: true,
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { EmojiItem, EmojiList, EmojiListRef } from "./components/emojis-list";
|
||||
|
||||
const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"];
|
||||
|
||||
const emojiSuggestion: EmojiOptions["suggestion"] = {
|
||||
export const emojiSuggestion: EmojiOptions["suggestion"] = {
|
||||
items: ({ editor, query }: { editor: Editor; query: string }): EmojiItem[] => {
|
||||
const { emojis } = getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI);
|
||||
const { isSupported } = getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI);
|
||||
@@ -116,5 +116,3 @@ const emojiSuggestion: EmojiOptions["suggestion"] = {
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default emojiSuggestion;
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import type { IEditorProps } from "@/types";
|
||||
import type { IEditorProps, TEmbedConfig } from "@/types";
|
||||
// local imports
|
||||
import { CustomImageExtension } from "./custom-image/extension";
|
||||
import { EmojiExtension } from "./emoji/extension";
|
||||
@@ -45,9 +45,11 @@ type TArguments = Pick<
|
||||
| "mentionHandler"
|
||||
| "placeholder"
|
||||
| "tabIndex"
|
||||
| "embedHandler"
|
||||
> & {
|
||||
enableHistory: boolean;
|
||||
editable: boolean;
|
||||
embedHandler?: TEmbedConfig;
|
||||
};
|
||||
|
||||
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
@@ -60,6 +62,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
embedHandler,
|
||||
editable,
|
||||
} = args;
|
||||
|
||||
@@ -115,6 +118,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
disabledExtensions,
|
||||
flaggedExtensions,
|
||||
fileHandler,
|
||||
embedHandler,
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from "./mentions";
|
||||
export * from "./slash-commands";
|
||||
export * from "./table";
|
||||
export * from "./typography";
|
||||
export * from "./work-item-embed";
|
||||
export * from "./core-without-props";
|
||||
export * from "./custom-color";
|
||||
export * from "./enter-key";
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export const WorkItemEmbedExtensionConfig = Node.create({
|
||||
name: CORE_EXTENSIONS.WORK_ITEM_EMBED,
|
||||
group: "block",
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
entity_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
project_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
workspace_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
id: {
|
||||
default: undefined,
|
||||
},
|
||||
entity_name: {
|
||||
default: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "issue-embed-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
|
||||
// local imports
|
||||
import { WorkItemEmbedExtensionConfig } from "./extension-config";
|
||||
|
||||
type Props = {
|
||||
widgetCallback: ({
|
||||
issueId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
}: {
|
||||
issueId: string;
|
||||
projectId: string | undefined;
|
||||
workspaceSlug: string | undefined;
|
||||
}) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const WorkItemEmbedExtension = (props: Props) =>
|
||||
WorkItemEmbedExtensionConfig.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer((issueProps: NodeViewProps) => (
|
||||
<NodeViewWrapper>
|
||||
{props.widgetCallback({
|
||||
issueId: issueProps.node.attrs.entity_identifier,
|
||||
projectId: issueProps.node.attrs.project_identifier,
|
||||
workspaceSlug: issueProps.node.attrs.workspace_identifier,
|
||||
})}
|
||||
</NodeViewWrapper>
|
||||
));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./extension";
|
||||
@@ -9,20 +9,18 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
// types
|
||||
import type { EditorRefApi, TEditorCommands } from "@/types";
|
||||
// plane editor imports
|
||||
import { getAdditionalEditorRefHelpers } from "@/plane-editor/helpers/editor-ref";
|
||||
// local imports
|
||||
import { getParagraphCount } from "./common";
|
||||
import { getExtensionStorage } from "./get-extension-storage";
|
||||
import { insertContentAtSavedSelection } from "./insert-content-at-cursor-position";
|
||||
import { scrollSummary, scrollToNodeViaDOMCoordinates } from "./scroll-to-node";
|
||||
|
||||
export type TEditorRefHelperArgs = {
|
||||
type TArgs = {
|
||||
editor: Editor | null;
|
||||
provider: HocuspocusProvider | undefined;
|
||||
};
|
||||
|
||||
export const getEditorRefHelpers = (args: TEditorRefHelperArgs): EditorRefApi => {
|
||||
export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
|
||||
const { editor, provider } = args;
|
||||
|
||||
return {
|
||||
@@ -240,6 +238,5 @@ export const getEditorRefHelpers = (args: TEditorRefHelperArgs): EditorRefApi =>
|
||||
Y.applyUpdate(document, value);
|
||||
},
|
||||
undo: () => editor?.commands.undo(),
|
||||
...getAdditionalEditorRefHelpers(args),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,11 +4,14 @@ import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-pros
|
||||
import * as Y from "yjs";
|
||||
// extensions
|
||||
import { TDocumentPayload } from "@plane/types";
|
||||
import { CoreEditorExtensionsWithoutProps } from "@/extensions/core-without-props";
|
||||
import {
|
||||
CoreEditorExtensionsWithoutProps,
|
||||
DocumentEditorExtensionsWithoutProps,
|
||||
} from "@/extensions/core-without-props";
|
||||
|
||||
// editor extension configs
|
||||
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
|
||||
// editor schemas
|
||||
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
@@ -20,6 +20,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
||||
editable,
|
||||
editorClassName = "",
|
||||
editorProps = {},
|
||||
embedHandler,
|
||||
extensions = [],
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
@@ -79,6 +80,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
embedHandler,
|
||||
disabledExtensions,
|
||||
id,
|
||||
editable,
|
||||
@@ -97,6 +99,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
||||
...extensions,
|
||||
...DocumentEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
embedConfig: embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
isEditable: editable,
|
||||
|
||||
@@ -32,6 +32,7 @@ export const useEditor = (props: TEditorHookProps) => {
|
||||
onAssetChange,
|
||||
onChange,
|
||||
onEditorFocus,
|
||||
embedHandler,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
provider,
|
||||
@@ -63,6 +64,7 @@ export const useEditor = (props: TEditorHookProps) => {
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
embedHandler,
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
|
||||
@@ -4,9 +4,7 @@ import { EditorView } from "@tiptap/pm/view";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions/side-menu";
|
||||
// plane editor imports
|
||||
import { DRAG_HANDLE_ADDITIONAL_SELECTORS } from "@/plane-editor/constants/common";
|
||||
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
|
||||
|
||||
const verticalEllipsisIcon =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis-vertical"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>';
|
||||
@@ -19,10 +17,11 @@ const generalSelectors = [
|
||||
"h1.editor-heading-block, h2.editor-heading-block, h3.editor-heading-block, h4.editor-heading-block, h5.editor-heading-block, h6.editor-heading-block",
|
||||
"[data-type=horizontalRule]",
|
||||
"table:not(.table-drag-preview)",
|
||||
".issue-embed",
|
||||
".image-component",
|
||||
".image-upload-component",
|
||||
".editor-callout-component",
|
||||
...DRAG_HANDLE_ADDITIONAL_SELECTORS,
|
||||
".editor-embed-component",
|
||||
].join(", ");
|
||||
|
||||
const maxScrollSpeed = 20;
|
||||
|
||||
@@ -2,15 +2,9 @@ import type { Content, Extensions, JSONContent, RawCommands } from "@tiptap/core
|
||||
import type { MarkType, NodeType } from "@tiptap/pm/model";
|
||||
import type { Selection } from "@tiptap/pm/state";
|
||||
import type { EditorProps, EditorView } from "@tiptap/pm/view";
|
||||
import type { NodeViewProps as TNodeViewProps } from "@tiptap/react";
|
||||
// extension types
|
||||
import type { TTextAlign } from "@/extensions/text-align";
|
||||
// plane editor imports
|
||||
import type {
|
||||
IEditorPropsExtended,
|
||||
TAdditionalCommandExtraProps,
|
||||
TAdditionalEditorCommands,
|
||||
TAdditionalEditorRefApiMethods,
|
||||
} from "@/plane-editor/types/editor";
|
||||
import type { TTextAlign } from "@/extensions";
|
||||
// types
|
||||
import type {
|
||||
IMarking,
|
||||
@@ -19,6 +13,7 @@ import type {
|
||||
TDocumentEventEmitter,
|
||||
TDocumentEventsServer,
|
||||
TEditorAsset,
|
||||
TEmbedConfig,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
TMentionHandler,
|
||||
@@ -48,17 +43,22 @@ export type TEditorCommands =
|
||||
| "image"
|
||||
| "divider"
|
||||
| "link"
|
||||
| "issue-embed"
|
||||
| "text-color"
|
||||
| "background-color"
|
||||
| "text-align"
|
||||
| "callout"
|
||||
| "attachment"
|
||||
| "emoji"
|
||||
| TAdditionalEditorCommands;
|
||||
| "external-embed";
|
||||
|
||||
export type TCommandExtraProps = {
|
||||
image: {
|
||||
savedSelection: Selection | null;
|
||||
};
|
||||
attachment: {
|
||||
savedSelection: Selection | null;
|
||||
};
|
||||
"text-color": {
|
||||
color: string | undefined;
|
||||
};
|
||||
@@ -72,7 +72,7 @@ export type TCommandExtraProps = {
|
||||
"text-align": {
|
||||
alignment: TTextAlign;
|
||||
};
|
||||
} & TAdditionalCommandExtraProps;
|
||||
};
|
||||
|
||||
// Create a utility type that maps a command to its extra props or an empty object if none are defined
|
||||
export type TCommandWithProps<T extends TEditorCommands> = T extends keyof TCommandExtraProps
|
||||
@@ -127,7 +127,7 @@ export type EditorRefApi = {
|
||||
setFocusAtPosition: (position: number) => void;
|
||||
setProviderDocument: (value: Uint8Array) => void;
|
||||
undo: () => void;
|
||||
} & TAdditionalEditorRefApiMethods;
|
||||
};
|
||||
|
||||
// editor props
|
||||
export type IEditorProps = {
|
||||
@@ -140,6 +140,7 @@ export type IEditorProps = {
|
||||
editorClassName?: string;
|
||||
editorProps?: EditorProps;
|
||||
extensions?: Extensions;
|
||||
embedHandler?: TEmbedConfig;
|
||||
flaggedExtensions: TExtensions[];
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
@@ -149,14 +150,14 @@ export type IEditorProps = {
|
||||
isTouchDevice?: boolean;
|
||||
mentionHandler: TMentionHandler;
|
||||
onAssetChange?: (assets: TEditorAsset[]) => void;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
onEditorFocus?: () => void;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
onEnterKeyPress?: (e?: any) => void;
|
||||
onTransaction?: () => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
value?: string | null;
|
||||
} & IEditorPropsExtended;
|
||||
};
|
||||
|
||||
export type ILiteTextEditorProps = IEditorProps;
|
||||
|
||||
@@ -169,6 +170,7 @@ export type ICollaborativeDocumentEditorProps = Omit<IEditorProps, "initialValue
|
||||
documentLoaderClassName?: string;
|
||||
dragDropEnabled?: boolean;
|
||||
editable: boolean;
|
||||
embedHandler: TEmbedConfig;
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
serverHandler?: TServerHandler;
|
||||
user: TUserDetails;
|
||||
@@ -176,6 +178,7 @@ export type ICollaborativeDocumentEditorProps = Omit<IEditorProps, "initialValue
|
||||
|
||||
export type IDocumentEditorProps = Omit<IEditorProps, "initialValue" | "onEnterKeyPress" | "value"> & {
|
||||
aiHandler?: TAIHandler;
|
||||
embedHandler: TEmbedConfig;
|
||||
user?: TUserDetails;
|
||||
value: Content;
|
||||
};
|
||||
@@ -191,3 +194,5 @@ export type EditorEvents = {
|
||||
destroy: never;
|
||||
ready: { height: number };
|
||||
};
|
||||
|
||||
export type NodeViewProps = TNodeViewProps;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user