Compare commits

..
98 changed files with 938 additions and 3028 deletions
+2 -21
View File
@@ -22,11 +22,6 @@ from plane.db.models import (
User,
EstimatePoint,
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
from .base import BaseSerializer
from .cycle import CycleLiteSerializer, CycleSerializer
@@ -88,22 +83,6 @@ class IssueSerializer(BaseSerializer):
except Exception:
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"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if data.get("description_binary"):
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": error_msg})
# Validate assignees are from project
if data.get("assignees", []):
data["assignees"] = ProjectMember.objects.filter(
@@ -669,6 +648,7 @@ class IssueExpandSerializer(BaseSerializer):
assignees = serializers.SerializerMethodField()
state = StateLiteSerializer(read_only=True)
def get_labels(self, obj):
expand = self.context.get("expand", [])
if "labels" in expand:
@@ -686,6 +666,7 @@ class IssueExpandSerializer(BaseSerializer):
).data
return [ia.assignee_id for ia in obj.issue_assignee.all()]
class Meta:
model = Issue
fields = "__all__"
-27
View File
@@ -10,10 +10,6 @@ from plane.db.models import (
Estimate,
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
)
from .base import BaseSerializer
@@ -195,29 +191,6 @@ class ProjectSerializer(BaseSerializer):
"Default assignee should be a user in the workspace"
)
# 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"])
)
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
return data
def create(self, validated_data):
@@ -96,7 +96,6 @@ from .page import (
SubPageSerializer,
PageDetailSerializer,
PageVersionSerializer,
PageBinaryUpdateSerializer,
PageVersionDetailSerializer,
)
+7 -19
View File
@@ -21,11 +21,6 @@ from plane.db.models import (
ProjectMember,
EstimatePoint,
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
from plane.app.permissions import ROLE
@@ -75,21 +70,14 @@ 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})
try:
if attrs.get("description_html", None) is not None:
parsed = html.fromstring(attrs["description_html"])
parsed_str = html.tostring(parsed, encoding="unicode")
attrs["description_html"] = parsed_str
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg = validate_html_content(attrs["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if "description_binary" in attrs and attrs["description_binary"]:
is_valid, error_msg = validate_binary_data(attrs["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": error_msg})
except Exception:
raise serializers.ValidationError("Invalid HTML passed")
# Validate assignees are from project
if attrs.get("assignee_ids", []):
+7 -19
View File
@@ -41,11 +41,6 @@ from plane.db.models import (
ProjectMember,
EstimatePoint,
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
class IssueFlatSerializer(BaseSerializer):
@@ -127,21 +122,14 @@ 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})
try:
if attrs.get("description_html", None) is not None:
parsed = html.fromstring(attrs["description_html"])
parsed_str = html.tostring(parsed, encoding="unicode")
attrs["description_html"] = parsed_str
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg = validate_html_content(attrs["description_html"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if "description_binary" in attrs and attrs["description_binary"]:
is_valid, error_msg = validate_binary_data(attrs["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": error_msg})
except Exception:
raise serializers.ValidationError("Invalid HTML passed")
# Validate assignees are from project
if attrs.get("assignee_ids", []):
+10 -33
View File
@@ -4,11 +4,7 @@ import base64
# Module imports
from .base import BaseSerializer
from plane.utils.content_validator import (
validate_binary_data,
validate_html_content,
validate_json_content,
)
from plane.utils.binary_validator import validate_binary_data
from plane.db.models import (
Page,
PageLog,
@@ -217,46 +213,27 @@ class PageBinaryUpdateSerializer(serializers.Serializer):
f"Invalid binary data: {error_message}"
)
return binary_data
return value
except Exception as e:
if isinstance(e, serializers.ValidationError):
raise
raise serializers.ValidationError("Failed to decode base64 data")
def validate_description_html(self, value):
"""Validate the HTML content"""
if not value:
return value
# Use the validation function from utils
is_valid, error_message = validate_html_content(value)
if not is_valid:
raise serializers.ValidationError(error_message)
return value
def validate_description(self, value):
"""Validate the JSON description"""
if not value:
return value
# Use the validation function from utils
is_valid, error_message = validate_json_content(value)
if not is_valid:
raise serializers.ValidationError(error_message)
return value
def update(self, instance, validated_data):
"""Update the page instance with validated data"""
if "description_binary" in validated_data:
instance.description_binary = validated_data.get("description_binary")
if validated_data["description_binary"]:
instance.description_binary = base64.b64decode(
validated_data["description_binary"]
)
else:
instance.description_binary = None
if "description_html" in validated_data:
instance.description_html = validated_data.get("description_html")
instance.description_html = validated_data["description_html"]
if "description" in validated_data:
instance.description = validated_data.get("description")
instance.description = validated_data["description"]
instance.save()
return instance
-31
View File
@@ -13,11 +13,6 @@ from plane.db.models import (
DeployBoard,
ProjectPublicMember,
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
class ProjectSerializer(BaseSerializer):
@@ -63,32 +58,6 @@ class ProjectSerializer(BaseSerializer):
return identifier
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"])
)
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
return data
def create(self, validated_data):
workspace_id = self.context["workspace_id"]
@@ -24,11 +24,6 @@ from plane.db.models import (
)
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,
)
# Django imports
from django.core.validators import URLValidator
@@ -317,25 +312,6 @@ class StickySerializer(BaseSerializer):
read_only_fields = ["workspace", "owner"]
extra_kwargs = {"name": {"required": False}}
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"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if "description_binary" in data and data["description_binary"]:
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": error_msg})
return data
class WorkspaceUserPreferenceSerializer(BaseSerializer):
class Meta:
-3
View File
@@ -226,9 +226,6 @@ class Profile(TimeAuditModel):
goals = models.JSONField(default=dict)
background_color = models.CharField(max_length=255, default=get_random_color)
# marketing
has_marketing_email_consent = models.BooleanField(default=False)
class Meta:
verbose_name = "Profile"
verbose_name_plural = "Profiles"
-22
View File
@@ -28,11 +28,6 @@ from plane.db.models import (
IssueVote,
IssueRelation,
)
from plane.utils.content_validator import (
validate_html_content,
validate_json_content,
validate_binary_data,
)
class IssueStateFlatSerializer(BaseSerializer):
@@ -288,23 +283,6 @@ class IssueCreateSerializer(BaseSerializer):
and data.get("start_date", None) > data.get("target_date", None)
):
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"])
if not is_valid:
raise serializers.ValidationError({"description_html": error_msg})
if "description_binary" in data and data["description_binary"]:
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": error_msg})
return data
def create(self, validated_data):
+42
View File
@@ -0,0 +1,42 @@
# Python imports
def validate_binary_data(binary_data):
"""
Validate that binary data appears to be valid document format and doesn't contain malicious content.
Args:
binary_data (bytes): The binary data to validate
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
if not binary_data:
return True, None # Empty is OK
# Size check - 10MB limit
MAX_SIZE = 10 * 1024 * 1024
if len(binary_data) > MAX_SIZE:
return False, "Binary data exceeds maximum size limit (10MB)"
# Basic format validation
if len(binary_data) < 4:
return False, "Binary data too short to be valid document format"
# Check for suspicious text patterns (HTML/JS)
try:
decoded_text = binary_data.decode("utf-8", errors="ignore")[:200]
suspicious_patterns = [
"<html",
"<!doctype",
"<script",
"javascript:",
"data:",
"<iframe",
]
if any(pattern in decoded_text.lower() for pattern in suspicious_patterns):
return False, "Binary data contains suspicious content patterns"
except:
pass # Binary data might not be decodable as text, which is fine
return True, None
-357
View File
@@ -1,357 +0,0 @@
# Python imports
import base64
import json
import re
# Maximum allowed size for binary data (10MB)
MAX_SIZE = 10 * 1024 * 1024
# Maximum recursion depth to prevent stack overflow
MAX_RECURSION_DEPTH = 20
# Dangerous text patterns that could indicate XSS or script injection
DANGEROUS_TEXT_PATTERNS = [
r"<script[^>]*>.*?</script>",
r"javascript\s*:",
r"data\s*:\s*text/html",
r"eval\s*\(",
r"document\s*\.",
r"window\s*\.",
r"location\s*\.",
]
# Dangerous attribute patterns for HTML attributes
DANGEROUS_ATTR_PATTERNS = [
r"javascript\s*:",
r"data\s*:\s*text/html",
r"eval\s*\(",
r"alert\s*\(",
r"document\s*\.",
r"window\s*\.",
]
# Suspicious patterns for binary data content
SUSPICIOUS_BINARY_PATTERNS = [
"<html",
"<!doctype",
"<script",
"javascript:",
"data:",
"<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):
"""
Validate that binary data appears to be valid document format and doesn't contain malicious content.
Args:
data (bytes or str): The binary data to validate, or base64-encoded string
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
if not data:
return True, None # Empty is OK
# Handle base64-encoded strings by decoding them first
if isinstance(data, str):
try:
binary_data = base64.b64decode(data)
except Exception:
return False, "Invalid base64 encoding"
else:
binary_data = data
# Size check - 10MB limit
if len(binary_data) > MAX_SIZE:
return False, "Binary data exceeds maximum size limit (10MB)"
# Basic format validation
if len(binary_data) < 4:
return False, "Binary data too short to be valid document format"
# Check for suspicious text patterns (HTML/JS)
try:
decoded_text = binary_data.decode("utf-8", errors="ignore")[:200]
if any(
pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS
):
return False, "Binary data contains suspicious content patterns"
except Exception:
pass # Binary data might not be decodable as text, which is fine
return True, None
def validate_html_content(html_content):
"""
Validate that HTML content is safe and doesn't contain malicious patterns.
Args:
html_content (str): The HTML content to validate
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
if not html_content:
return True, None # Empty is OK
# Size check - 10MB limit (consistent with binary validation)
if len(html_content.encode("utf-8")) > MAX_SIZE:
return False, "HTML content exceeds maximum size limit (10MB)"
# 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]}",
)
# Basic HTML structure validation - check for common malformed tags
try:
# Count opening and closing tags for basic structure validation
opening_tags = re.findall(r"<(\w+)[^>]*>", html_content)
closing_tags = re.findall(r"</(\w+)>", html_content)
# Filter out self-closing tags from opening tags
opening_tags_filtered = [
tag for tag in opening_tags if tag.lower() not in SELF_CLOSING_TAGS
]
# Basic check - if we have significantly more opening than closing tags, it might be malformed
if len(opening_tags_filtered) > len(closing_tags) + 10: # Allow some tolerance
return False, "HTML content appears to be malformed (unmatched tags)"
except Exception:
# If HTML parsing fails, we'll allow it
pass
return True, None
def validate_json_content(json_content):
"""
Validate that JSON content is safe and doesn't contain malicious patterns.
Args:
json_content (dict): The JSON content to validate
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
if not json_content:
return True, None # Empty is OK
try:
# Size check - 10MB limit (consistent with other validations)
json_str = json.dumps(json_content)
if len(json_str.encode("utf-8")) > MAX_SIZE:
return False, "JSON content exceeds maximum size limit (10MB)"
# Basic structure validation for page description JSON
if isinstance(json_content, dict):
# Check for expected page description structure
# This is based on ProseMirror/Tiptap JSON structure
if "type" in json_content and json_content.get("type") == "doc":
# Valid document structure
if "content" in json_content and isinstance(
json_content["content"], list
):
# Recursively check content for suspicious patterns
is_valid, error_msg = _validate_json_content_array(
json_content["content"]
)
if not is_valid:
return False, error_msg
elif "type" not in json_content and "content" not in json_content:
# Allow other JSON structures but validate for suspicious content
is_valid, error_msg = _validate_json_content_recursive(json_content)
if not is_valid:
return False, error_msg
else:
return False, "JSON description must be a valid object"
except (TypeError, ValueError) as e:
return False, "Invalid JSON structure"
except Exception as e:
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
@@ -1,4 +1,5 @@
export * from "./embeds";
export * from "./lite-text-editor";
export * from "./lite-text-read-only-editor";
export * from "./rich-text-editor";
export * from "./toolbar";
@@ -1,7 +1,7 @@
import React from "react";
// plane imports
import { type EditorRefApi, type ILiteTextEditorProps, LiteTextEditorWithRef, type TFileHandler } from "@plane/editor";
import type { MakeOptional } from "@plane/types";
import { EditorRefApi, ILiteTextEditorProps, LiteTextEditorWithRef, TFileHandler } from "@plane/editor";
import { MakeOptional } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { EditorMentionsRoot, IssueCommentToolbar } from "@/components/editor";
@@ -9,34 +9,28 @@ import { EditorMentionsRoot, IssueCommentToolbar } from "@/components/editor";
import { getEditorFileHandlers } from "@/helpers/editor.helper";
import { isCommentEmpty } from "@/helpers/string.helper";
type LiteTextEditorWrapperProps = MakeOptional<
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler">,
"disabledExtensions" | "flaggedExtensions"
> & {
interface LiteTextEditorWrapperProps
extends MakeOptional<
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler">,
"disabledExtensions" | "flaggedExtensions"
> {
anchor: string;
workspaceId: string;
isSubmitting?: boolean;
showSubmitButton?: boolean;
workspaceId: string;
} & (
| {
editable: false;
}
| {
editable: true;
uploadFile: TFileHandler["upload"];
}
);
uploadFile: TFileHandler["upload"];
}
export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapperProps>((props, ref) => {
const {
anchor,
containerClassName,
disabledExtensions,
editable,
flaggedExtensions,
workspaceId,
isSubmitting = false,
showSubmitButton = true,
workspaceId,
uploadFile,
disabledExtensions,
flaggedExtensions,
...rest
} = props;
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
@@ -52,10 +46,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
ref={ref}
disabledExtensions={disabledExtensions ?? []}
flaggedExtensions={flaggedExtensions ?? []}
editable={editable}
fileHandler={getEditorFileHandlers({
anchor,
uploadFile: editable ? props.uploadFile : async () => "",
uploadFile,
workspaceId,
})}
mentionHandler={{
@@ -0,0 +1,48 @@
import React from "react";
// plane imports
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps, LiteTextReadOnlyEditorWithRef } from "@plane/editor";
import { MakeOptional } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { EditorMentionsRoot } from "@/components/editor";
// helpers
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
// store hooks
import { useMember } from "@/hooks/store";
type LiteTextReadOnlyEditorWrapperProps = MakeOptional<
Omit<ILiteTextReadOnlyEditorProps, "fileHandler" | "mentionHandler">,
"disabledExtensions" | "flaggedExtensions"
> & {
anchor: string;
workspaceId: string;
};
export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, LiteTextReadOnlyEditorWrapperProps>(
({ anchor, workspaceId, disabledExtensions, flaggedExtensions, ...props }, ref) => {
const { getMemberById } = useMember();
return (
<LiteTextReadOnlyEditorWithRef
ref={ref}
disabledExtensions={disabledExtensions ?? []}
flaggedExtensions={flaggedExtensions ?? []}
fileHandler={getReadOnlyEditorFileHandlers({
anchor,
workspaceId,
})}
mentionHandler={{
renderComponent: (props) => <EditorMentionsRoot {...props} />,
getMentionedEntityDetails: (id: string) => ({
display_name: getMemberById(id)?.member__display_name ?? "",
}),
}}
{...props}
// overriding the customClassName to add relative class passed
containerClassName={cn(props.containerClassName, "relative p-2")}
/>
);
}
);
LiteTextReadOnlyEditor.displayName = "LiteTextReadOnlyEditor";
@@ -75,7 +75,6 @@ export const AddComment: React.FC<Props> = observer((props) => {
control={control}
render={({ field: { value, onChange } }) => (
<LiteTextEditor
editable
onEnterKeyPress={(e) => {
if (currentUser) handleSubmit(onSubmit)(e);
}}
@@ -8,7 +8,7 @@ import { EditorRefApi } from "@plane/editor";
import { TIssuePublicComment } from "@plane/types";
import { getFileURL } from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor";
import { LiteTextEditor, LiteTextReadOnlyEditor } from "@/components/editor";
import { CommentReactions } from "@/components/issues/peek-overview";
// helpers
import { timeAgo } from "@/helpers/date-time.helper";
@@ -102,7 +102,6 @@ export const CommentCard: React.FC<Props> = observer((props) => {
name="comment_html"
render={({ field: { onChange, value } }) => (
<LiteTextEditor
editable
anchor={anchor}
workspaceId={workspaceID?.toString() ?? ""}
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
@@ -139,8 +138,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
</div>
</form>
<div className={`${isEditing ? "hidden" : ""}`}>
<LiteTextEditor
editable={false}
<LiteTextReadOnlyEditor
anchor={anchor}
workspaceId={workspaceID?.toString() ?? ""}
ref={showEditorRef}
+27 -13
View File
@@ -1,6 +1,6 @@
// plane imports
import { MAX_FILE_SIZE } from "@plane/constants";
import { TFileHandler } from "@plane/editor";
import { TFileHandler, TReadOnlyFileHandler } from "@plane/editor";
import { SitesFileService } from "@plane/services";
import { getFileURL } from "@plane/utils";
// services
@@ -22,11 +22,10 @@ type TArgs = {
};
/**
* @description this function returns the file handler required by the editors
* @param {TArgs} args
* @description this function returns the file handler required by the read-only editors
*/
export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
const { anchor, uploadFile, workspaceId } = args;
export const getReadOnlyEditorFileHandlers = (args: Pick<TArgs, "anchor" | "workspaceId">): TReadOnlyFileHandler => {
const { anchor, workspaceId } = args;
const getAssetSrc = async (path: string) => {
if (!path) return "";
@@ -39,9 +38,31 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
return {
checkIfAssetExists: async () => true,
assetsUploadStatus: {},
getAssetDownloadSrc: getAssetSrc,
getAssetSrc: getAssetSrc,
restore: async (src: string) => {
if (src?.startsWith("http")) {
await sitesFileService.restoreOldEditorAsset(workspaceId, src);
} else {
await sitesFileService.restoreNewAsset(anchor, src);
}
},
};
};
/**
* @description this function returns the file handler required by the editors
* @param {TArgs} args
*/
export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
const { anchor, uploadFile, workspaceId } = args;
return {
...getReadOnlyEditorFileHandlers({
anchor,
workspaceId,
}),
assetsUploadStatus: {},
upload: uploadFile,
delete: async (src: string) => {
if (src?.startsWith("http")) {
@@ -51,13 +72,6 @@ export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
}
},
cancel: sitesFileService.cancelUpload,
restore: async (src: string) => {
if (src?.startsWith("http")) {
await sitesFileService.restoreOldEditorAsset(workspaceId, src);
} else {
await sitesFileService.restoreNewAsset(anchor, src);
}
},
validation: {
maxFileSize: MAX_FILE_SIZE,
},
@@ -3,7 +3,7 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// ui
import { EProjectFeatureKey, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EProjectFeatureKey } from "@plane/constants";
import { Breadcrumbs, Button, Header } from "@plane/ui";
// components
import { ViewListHeader } from "@/components/views";
@@ -34,12 +34,7 @@ export const ProjectViewsHeader = observer(() => {
<Header.RightItem>
<ViewListHeader />
<div>
<Button
data-ph-element={PROJECT_VIEW_TRACKER_ELEMENTS.RIGHT_HEADER_ADD_BUTTON}
variant="primary"
size="sm"
onClick={() => toggleCreateViewModal(true)}
>
<Button variant="primary" size="sm" onClick={() => toggleCreateViewModal(true)}>
Add view
</Button>
</div>
@@ -9,7 +9,7 @@ import { useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import { Eye, EyeOff } from "lucide-react";
// plane imports
import { AUTH_TRACKER_ELEMENTS, AUTH_TRACKER_EVENTS, E_PASSWORD_STRENGTH } from "@plane/constants";
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button, Input, PasswordStrengthIndicator, TOAST_TYPE, setToast } from "@plane/ui";
// components
@@ -17,7 +17,6 @@ import { getPasswordStrength } from "@plane/utils";
// helpers
import { EPageTypes } from "@/helpers/authentication.helper";
// hooks
import { captureError, captureSuccess, captureView } from "@/helpers/event-tracker.helper";
import { useUser } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// wrappers
@@ -68,12 +67,6 @@ const SetPasswordPage = observer(() => {
const { resolvedTheme } = useTheme();
const { data: user, handleSetPassword } = useUser();
useEffect(() => {
captureView({
elementName: AUTH_TRACKER_ELEMENTS.SET_PASSWORD_FORM,
});
}, []);
useEffect(() => {
if (csrfToken === undefined)
authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
@@ -100,9 +93,6 @@ const SetPasswordPage = observer(() => {
e.preventDefault();
if (!csrfToken) throw new Error("csrf token not found");
await handleSetPassword(csrfToken, { password: passwordFormData.password });
captureSuccess({
eventName: AUTH_TRACKER_EVENTS.password_created,
});
router.push("/");
} catch (error: unknown) {
let message = undefined;
@@ -110,9 +100,7 @@ const SetPasswordPage = observer(() => {
const err = error as Error & { error?: string };
message = err.error;
}
captureError({
eventName: AUTH_TRACKER_EVENTS.password_created,
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("common.errors.default.title"),
@@ -128,7 +116,8 @@ const SetPasswordPage = observer(() => {
const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo;
return (
<AuthenticationWrapper pageType={EPageTypes.SET_PASSWORD}>
// TODO: change to EPageTypes.SET_PASSWORD
<AuthenticationWrapper pageType={EPageTypes.NON_AUTHENTICATED}>
<div className="relative w-screen h-screen overflow-hidden">
<div className="absolute inset-0 z-0">
<Image
@@ -37,9 +37,7 @@ export const ProjectBreadcrumb = observer((props: TProjectBreadcrumbProps) => {
return {
value: projectId,
query: project?.name,
content: (
<SwitcherLabel name={project?.name} logo_props={project?.logo_props} LabelIcon={Briefcase} type="material" />
),
content: <SwitcherLabel name={project?.name} logo_props={project?.logo_props} LabelIcon={Briefcase} />,
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
@@ -1,9 +1,8 @@
import { ReactNode } from "react";
import { observer } from "mobx-react";
import { AppSidebarToggleButton } from "@/components/sidebar";
import { useAppTheme } from "@/hooks/store/use-app-theme";
export const ExtendedAppHeader = observer((props: { header: ReactNode }) => {
export const ExtendedAppHeader = (props: { header: ReactNode }) => {
const { header } = props;
// store hooks
const { sidebarCollapsed } = useAppTheme();
@@ -14,4 +13,4 @@ export const ExtendedAppHeader = observer((props: { header: ReactNode }) => {
<div className="w-full">{header}</div>
</>
);
});
};
@@ -5,4 +5,3 @@ export * from "./issue-type-activity";
export * from "./parent-select-root";
export * from "./issue-creator";
export * from "./additional-activity-root";
export * from "./page";
@@ -1,8 +0,0 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
export type TIssuePageActivity = { activityId: string; showIssue?: boolean; ends?: "top" | "bottom" | undefined };
export const IssuePageActivity: FC<TIssuePageActivity> = observer(() => <></>);
@@ -1,3 +1,2 @@
export * from "./notification-card/root";
export * from "./list-root";
export * from "./notification-card/content";
@@ -1,19 +0,0 @@
import { replaceUnderscoreIfSnakeCase } from "@plane/utils";
export const renderAdditionalAction = (notificationField: string, verb: string | undefined) => {
const baseAction = !["comment", "archived_at"].includes(notificationField) ? verb : "";
return `${baseAction} ${replaceUnderscoreIfSnakeCase(notificationField)}`;
};
export const renderAdditionalValue = (
notificationField: string | undefined,
newValue: string | undefined,
oldValue: string | undefined
) => newValue;
export const shouldShowConnector = (notificationField: string | undefined) =>
!["comment", "archived_at", "None", "assignees", "labels", "start_date", "target_date", "parent"].includes(
notificationField || ""
);
export const shouldRender = (notificationField: string | undefined, verb: string | undefined) => verb !== "deleted";
+1 -5
View File
@@ -4,7 +4,6 @@ import {
MODULE_TRACKER_ELEMENTS,
PROJECT_PAGE_TRACKER_ELEMENTS,
PROJECT_TRACKER_ELEMENTS,
PROJECT_VIEW_TRACKER_ELEMENTS,
WORK_ITEM_TRACKER_ELEMENTS,
} from "@plane/constants";
import { TCommandPaletteActionList, TCommandPaletteShortcut, TCommandPaletteShortcutList } from "@plane/types";
@@ -79,10 +78,7 @@ export const getProjectShortcutsList: () => TCommandPaletteActionList = () => {
v: {
title: "Create a new view",
description: "Create a new view in the current project",
action: () => {
toggleCreateViewModal(true);
captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM });
},
action: () => toggleCreateViewModal(true),
},
backspace: {
title: "Bulk delete work items",
@@ -3,12 +3,7 @@
import { Command } from "cmdk";
import { ContrastIcon, FileText, Layers } from "lucide-react";
// hooks
import {
CYCLE_TRACKER_ELEMENTS,
MODULE_TRACKER_ELEMENTS,
PROJECT_PAGE_TRACKER_ELEMENTS,
PROJECT_VIEW_TRACKER_ELEMENTS,
} from "@plane/constants";
import { CYCLE_TRACKER_ELEMENTS, MODULE_TRACKER_ELEMENTS, PROJECT_PAGE_TRACKER_ELEMENTS } from "@plane/constants";
import { DiceIcon } from "@plane/ui";
// hooks
import { useCommandPalette } from "@/hooks/store";
@@ -60,7 +55,6 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
</Command.Group>
<Command.Group heading="View">
<Command.Item
data-ph-element={PROJECT_VIEW_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM}
onSelect={() => {
closePalette();
toggleCreateViewModal(true);
@@ -3,12 +3,12 @@ import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Globe2, Lock } from "lucide-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import type { EditorReadOnlyRefApi } from "@plane/editor";
import { useHashScroll } from "@plane/hooks";
import { EIssueCommentAccessSpecifier, type TCommentsOperations, type TIssueComment } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor/lite-text";
import { LiteTextReadOnlyEditor } from "@/components/editor";
// local imports
import { CommentReactions } from "../comment-reaction";
@@ -17,7 +17,7 @@ type Props = {
comment: TIssueComment;
disabled: boolean;
projectId?: string;
readOnlyEditorRef: React.RefObject<EditorRefApi>;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
showAccessSpecifier: boolean;
workspaceId: string;
workspaceSlug: string;
@@ -67,8 +67,7 @@ export const CommentCardDisplay: React.FC<Props> = observer((props) => {
)}
</div>
)}
<LiteTextEditor
editable={false}
<LiteTextReadOnlyEditor
ref={readOnlyEditorRef}
id={comment.id}
initialValue={comment.comment_html ?? ""}
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
import { useForm } from "react-hook-form";
import { Check, X } from "lucide-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import type { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import type { TCommentsOperations, TIssueComment } from "@plane/types";
import { isCommentEmpty } from "@plane/utils";
// components
@@ -14,7 +14,7 @@ type Props = {
comment: TIssueComment;
isEditing: boolean;
projectId?: string;
readOnlyEditorRef: EditorRefApi | null;
readOnlyEditorRef: EditorReadOnlyRefApi | null;
setIsEditing: (isEditing: boolean) => void;
workspaceId: string;
workspaceSlug: string;
@@ -75,7 +75,6 @@ export const CommentCardEditForm: React.FC<Props> = observer((props) => {
}}
>
<LiteTextEditor
editable
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
ref={editorRef}
@@ -3,7 +3,7 @@
import { FC, useRef, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import type { EditorReadOnlyRefApi } from "@plane/editor";
import type { TIssueComment, TCommentsOperations } from "@plane/types";
// plane web imports
import { CommentBlock } from "@/plane-web/components/comments";
@@ -34,10 +34,9 @@ export const CommentCard: FC<TCommentCard> = observer((props) => {
disabled = false,
projectId,
} = props;
const readOnlyEditorRef = useRef<EditorReadOnlyRefApi>(null);
// states
const [isEditing, setIsEditing] = useState(false);
// refs
const readOnlyEditorRef = useRef<EditorRefApi>(null);
// derived values
const workspaceId = comment?.workspace;
@@ -113,7 +113,6 @@ export const CommentCreate: FC<TCommentCreate> = observer((props) => {
control={control}
render={({ field: { value, onChange } }) => (
<LiteTextEditor
editable
workspaceId={workspaceId}
id={"add_comment_" + entityId}
value={"<p></p>"}
@@ -8,18 +8,11 @@ type TSwitcherIconProps = {
logo_url?: string;
LabelIcon: FC<ISvgIcons>;
size?: number;
type?: "lucide" | "material";
};
export const SwitcherIcon: FC<TSwitcherIconProps> = ({
logo_props,
logo_url,
LabelIcon,
size = 12,
type = "lucide",
}) => {
export const SwitcherIcon: FC<TSwitcherIconProps> = ({ logo_props, logo_url, LabelIcon, size = 12 }) => {
if (logo_props?.in_use) {
return <Logo logo={logo_props} size={size} type={type} />;
return <Logo logo={logo_props} size={size} type="lucide" />;
}
if (logo_url) {
@@ -40,14 +33,13 @@ type TSwitcherLabelProps = {
logo_url?: string;
name?: string;
LabelIcon: FC<ISvgIcons>;
type?: "lucide" | "material";
};
export const SwitcherLabel: FC<TSwitcherLabelProps> = (props) => {
const { logo_props, name, LabelIcon, logo_url, type = "lucide" } = props;
const { logo_props, name, LabelIcon, logo_url } = props;
return (
<div className="flex items-center gap-1 text-custom-text-200">
<SwitcherIcon logo_props={logo_props} logo_url={logo_url} LabelIcon={LabelIcon} type={type} />
<SwitcherIcon logo_props={logo_props} logo_url={logo_url} LabelIcon={LabelIcon} />
{truncateText(name ?? "", 40)}
</div>
);
@@ -1,14 +1,18 @@
import React, { useState } from "react";
// plane imports
// plane constants
import { EIssueCommentAccessSpecifier } from "@plane/constants";
import { type EditorRefApi, type ILiteTextEditorProps, LiteTextEditorWithRef, type TFileHandler } from "@plane/editor";
// plane editor
import { EditorRefApi, ILiteTextEditorProps, LiteTextEditorWithRef, TFileHandler } from "@plane/editor";
// i18n
import { useTranslation } from "@plane/i18n";
import type { MakeOptional } from "@plane/types";
import { cn, isCommentEmpty } from "@plane/utils";
// components
import { MakeOptional } from "@plane/types";
import { cn, isCommentEmpty } from "@plane/utils";
import { EditorMentionsRoot, IssueCommentToolbar } from "@/components/editor";
// helpers
// hooks
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
// store hooks
import { useMember } from "@/hooks/store";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
@@ -16,10 +20,11 @@ import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { WorkspaceService } from "@/plane-web/services";
const workspaceService = new WorkspaceService();
type LiteTextEditorWrapperProps = MakeOptional<
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler">,
"disabledExtensions" | "flaggedExtensions"
> & {
interface LiteTextEditorWrapperProps
extends MakeOptional<
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler">,
"disabledExtensions" | "flaggedExtensions"
> {
workspaceSlug: string;
workspaceId: string;
projectId?: string;
@@ -30,23 +35,15 @@ type LiteTextEditorWrapperProps = MakeOptional<
isSubmitting?: boolean;
showToolbarInitially?: boolean;
showToolbar?: boolean;
uploadFile: TFileHandler["upload"];
issue_id?: string;
parentClassName?: string;
} & (
| {
editable: false;
}
| {
editable: true;
uploadFile: TFileHandler["upload"];
}
);
}
export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapperProps>((props, ref) => {
const { t } = useTranslation();
const {
containerClassName,
editable,
workspaceSlug,
workspaceId,
projectId,
@@ -60,6 +57,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
showToolbar = true,
parentClassName = "",
placeholder = t("issue.comments.placeholder"),
uploadFile,
disabledExtensions: additionalDisabledExtensions = [],
...rest
} = props;
@@ -72,10 +70,10 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
// use editor mention
const { fetchMentions } = useEditorMention({
searchEntity: async (payload) =>
await workspaceService.searchEntity(workspaceSlug, {
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId,
issue_id,
project_id: projectId?.toString() ?? "",
issue_id: issue_id,
}),
});
// editor config
@@ -89,24 +87,17 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
return (
<div
className={cn(
"relative border border-custom-border-200 rounded",
{
"p-3": editable,
},
parentClassName
)}
className={cn("relative border border-custom-border-200 rounded p-3", parentClassName)}
onFocus={() => !showToolbarInitially && setIsFocused(true)}
onBlur={() => !showToolbarInitially && setIsFocused(false)}
>
<LiteTextEditorWithRef
ref={ref}
disabledExtensions={[...liteTextEditorExtensions.disabled, ...additionalDisabledExtensions]}
editable={editable}
flaggedExtensions={liteTextEditorExtensions.flagged}
fileHandler={getEditorFileHandlers({
projectId,
uploadFile: editable ? props.uploadFile : async () => "",
uploadFile,
workspaceId,
workspaceSlug,
})}
@@ -116,18 +107,14 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
if (!res) throw new Error("Failed in fetching mentions");
return res;
},
renderComponent: EditorMentionsRoot,
getMentionedEntityDetails: (id) => ({
display_name: getUserDetails(id)?.display_name ?? "",
}),
renderComponent: (props) => <EditorMentionsRoot {...props} />,
getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }),
}}
placeholder={placeholder}
containerClassName={cn(containerClassName, "relative", {
"p-2": !editable,
})}
containerClassName={cn(containerClassName, "relative")}
{...rest}
/>
{showToolbar && editable && (
{showToolbar && (
<div
className={cn(
"transition-all duration-300 ease-out origin-top overflow-hidden",
@@ -1,2 +1,3 @@
export * from "./editor";
export * from "./read-only-editor";
export * from "./toolbar";
@@ -0,0 +1,57 @@
import React from "react";
// plane imports
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps, LiteTextReadOnlyEditorWithRef } from "@plane/editor";
import { MakeOptional } from "@plane/types";
// components
import { cn } from "@plane/utils";
import { EditorMentionsRoot } from "@/components/editor";
// helpers
// hooks
import { useEditorConfig } from "@/hooks/editor";
// store hooks
import { useMember } from "@/hooks/store";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
type LiteTextReadOnlyEditorWrapperProps = MakeOptional<
Omit<ILiteTextReadOnlyEditorProps, "fileHandler" | "mentionHandler">,
"disabledExtensions" | "flaggedExtensions"
> & {
workspaceId: string;
workspaceSlug: string;
projectId?: string;
};
export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, LiteTextReadOnlyEditorWrapperProps>(
({ workspaceId, workspaceSlug, projectId, disabledExtensions: additionalDisabledExtensions, ...props }, ref) => {
// store hooks
const { getUserDetails } = useMember();
// editor flaggings
const { liteText: liteTextEditorExtensions } = useEditorFlagging(workspaceSlug?.toString());
// editor config
const { getReadOnlyEditorFileHandlers } = useEditorConfig();
return (
<LiteTextReadOnlyEditorWithRef
ref={ref}
disabledExtensions={[...liteTextEditorExtensions.disabled, ...(additionalDisabledExtensions ?? [])]}
flaggedExtensions={liteTextEditorExtensions.flagged}
fileHandler={getReadOnlyEditorFileHandlers({
projectId,
workspaceId,
workspaceSlug,
})}
mentionHandler={{
renderComponent: (props) => <EditorMentionsRoot {...props} />,
getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }),
}}
{...props}
// overriding the containerClassName to add relative class passed
containerClassName={cn(props.containerClassName, "relative p-2")}
/>
);
}
);
LiteTextReadOnlyEditor.displayName = "LiteTextReadOnlyEditor";
@@ -1,12 +1,14 @@
import React, { forwardRef } from "react";
// plane imports
import { type EditorRefApi, type IRichTextEditorProps, RichTextEditorWithRef, type TFileHandler } from "@plane/editor";
import type { MakeOptional, TSearchEntityRequestPayload, TSearchResponse } from "@plane/types";
import { cn } from "@plane/utils";
import { EditorRefApi, IRichTextEditorProps, RichTextEditorWithRef, TFileHandler } from "@plane/editor";
import { MakeOptional, TSearchEntityRequestPayload, TSearchResponse } from "@plane/types";
// components
import { cn } from "@plane/utils";
import { EditorMentionsRoot } from "@/components/editor";
// helpers
// hooks
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
// store hooks
import { useMember } from "@/hooks/store";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
@@ -36,7 +38,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
workspaceSlug,
workspaceId,
projectId,
disabledExtensions: additionalDisabledExtensions = [],
disabledExtensions: additionalDisabledExtensions,
...rest
} = props;
// store hooks
@@ -68,10 +70,8 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
if (!res) throw new Error("Failed in fetching mentions");
return res;
},
renderComponent: EditorMentionsRoot,
getMentionedEntityDetails: (id) => ({
display_name: getUserDetails(id)?.display_name ?? "",
}),
renderComponent: (props) => <EditorMentionsRoot {...props} />,
getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }),
}}
{...rest}
containerClassName={cn("relative pl-3 pb-3", containerClassName)}
@@ -14,10 +14,7 @@ import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { StickyEditorToolbar } from "./toolbar";
interface StickyEditorWrapperProps
extends Omit<
ILiteTextEditorProps,
"disabledExtensions" | "editable" | "flaggedExtensions" | "fileHandler" | "mentionHandler"
> {
extends Omit<ILiteTextEditorProps, "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler"> {
workspaceSlug: string;
workspaceId: string;
projectId?: string;
@@ -70,7 +67,6 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
ref={ref}
disabledExtensions={[...liteTextEditorExtensions.disabled, "enter-key"]}
flaggedExtensions={liteTextEditorExtensions.flagged}
editable
fileHandler={getEditorFileHandlers({
projectId,
uploadFile,
@@ -1,5 +1,4 @@
import Image from "next/image";
import { USER_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { getButtonStyling } from "@plane/ui";
@@ -24,7 +23,6 @@ export const ProductUpdatesFooter = () => {
<circle cx={1} cy={1} r={1} />
</svg>
<a
data-ph-element={USER_TRACKER_ELEMENTS.CHANGELOG_REDIRECTED}
href="https://go.plane.so/p-changelog"
target="_blank"
className="text-sm text-custom-text-200 hover:text-custom-text-100 hover:underline underline-offset-1 outline-none"
@@ -1,13 +1,10 @@
import { FC, useEffect } from "react";
import { FC } from "react";
import { observer } from "mobx-react";
import { USER_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// components
import { ProductUpdatesFooter } from "@/components/global";
// helpers
import { captureView } from "@/helpers/event-tracker.helper";
// hooks
import { useInstance } from "@/hooks/store";
// plane web components
@@ -23,12 +20,6 @@ export const ProductUpdatesModal: FC<ProductUpdatesModalProps> = observer((props
const { t } = useTranslation();
const { config } = useInstance();
useEffect(() => {
if (isOpen) {
captureView({ elementName: USER_TRACKER_ELEMENTS.PRODUCT_CHANGELOG_MODAL });
}
}, [isOpen]);
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXXXL}>
<ProductUpdatesHeader />
@@ -41,7 +32,6 @@ export const ProductUpdatesModal: FC<ProductUpdatesModalProps> = observer((props
<div className="text-sm text-custom-text-200">
{t("please_visit")}
<a
data-ph-element={USER_TRACKER_ELEMENTS.CHANGELOG_REDIRECTED}
href="https://go.plane.so/p-changelog"
target="_blank"
className="text-sm text-custom-primary-100 font-medium hover:text-custom-primary-200 underline underline-offset-1 outline-none"
@@ -5,11 +5,7 @@ import { getValidKeysFromObject } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store";
// plane web components
import {
IssueTypeActivity,
AdditionalActivityRoot,
IssuePageActivity,
} from "@/plane-web/components/issues/issue-details";
import { IssueTypeActivity, AdditionalActivityRoot } from "@/plane-web/components/issues/issue-details";
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
// local components
import {
@@ -93,8 +89,6 @@ export const IssueActivityItem: FC<TIssueActivityItem> = observer((props) => {
return <IssueInboxActivity {...componentDefaultProps} />;
case "type":
return <IssueTypeActivity {...componentDefaultProps} />;
case "page":
return <IssuePageActivity {...componentDefaultProps} />;
default:
return <AdditionalActivityRoot {...componentDefaultProps} field={activityField} />;
}
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EIssueFilterType, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EIssueFilterType } from "@plane/constants";
import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types";
// hooks
import { Header, EHeaderVariant } from "@plane/ui";
@@ -95,7 +95,6 @@ export const CycleAppliedFiltersRoot: React.FC = observer(() => {
display_filters: issueFilters?.displayFilters,
display_properties: issueFilters?.displayProperties,
}}
trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.CYCLE_HEADER_SAVE_AS_VIEW_BUTTON}
/>
</Header>
);
@@ -11,7 +11,6 @@ import {
EIssueFilterType,
EUserPermissions,
EUserPermissionsLevel,
GLOBAL_VIEW_TRACKER_ELEMENTS,
GLOBAL_VIEW_TRACKER_EVENTS,
} from "@plane/constants";
import { EIssuesStoreType, EViewAccess, IIssueFilterOptions, TStaticViewTypes } from "@plane/types";
@@ -190,7 +189,6 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
isAuthorizedUser={isAuthorizedUser}
setIsModalOpen={setIsModalOpen}
handleUpdateView={handleUpdateView}
trackerElement={GLOBAL_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON}
/>
) : (
<></>
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EIssueFilterType, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EIssueFilterType } from "@plane/constants";
import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types";
// hooks
import { Header, EHeaderVariant } from "@plane/ui";
@@ -94,7 +94,6 @@ export const ModuleAppliedFiltersRoot: React.FC = observer(() => {
display_filters: issueFilters?.displayFilters,
display_properties: issueFilters?.displayProperties,
}}
trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.MODULE_HEADER_SAVE_AS_VIEW_BUTTON}
/>
</Header>
);
@@ -1,12 +1,7 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
import {
EIssueFilterType,
EUserPermissions,
EUserPermissionsLevel,
PROJECT_VIEW_TRACKER_ELEMENTS,
} from "@plane/constants";
import { EIssueFilterType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types";
// ui
import { Header, EHeaderVariant } from "@plane/ui";
@@ -99,7 +94,6 @@ export const ProjectAppliedFiltersRoot: React.FC<TProjectAppliedFiltersRootProps
display_filters: issueFilters?.displayFilters,
display_properties: issueFilters?.displayProperties,
}}
trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.PROJECT_HEADER_SAVE_AS_VIEW_BUTTON}
/>
)}
</Header.RightItem>
@@ -6,12 +6,7 @@ import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
import {
EIssueFilterType,
EUserPermissions,
EUserPermissionsLevel,
PROJECT_VIEW_TRACKER_ELEMENTS,
} from "@plane/constants";
import { EIssueFilterType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { EIssuesStoreType, EViewAccess, IIssueFilterOptions } from "@plane/types";
// components
import { Header, EHeaderVariant } from "@plane/ui";
@@ -150,7 +145,6 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
isAuthorizedUser={isAuthorizedUser}
setIsModalOpen={setIsModalOpen}
handleUpdateView={handleUpdateView}
trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON}
/>
</Header.RightItem>
</Header>
@@ -14,11 +14,10 @@ interface ISaveFilterView {
display_filters?: IIssueDisplayFilterOptions;
display_properties?: IIssueDisplayProperties;
};
trackerElement: string;
}
export const SaveFilterView: FC<ISaveFilterView> = (props) => {
const { workspaceSlug, projectId, filterParams, trackerElement } = props;
const { workspaceSlug, projectId, filterParams } = props;
const [viewModal, setViewModal] = useState<boolean>(false);
@@ -32,7 +31,7 @@ export const SaveFilterView: FC<ISaveFilterView> = (props) => {
onClose={() => setViewModal(false)}
/>
<Button size="sm" onClick={() => setViewModal(true)} data-ph-element={trackerElement}>
<Button size="sm" onClick={() => setViewModal(true)}>
Save View
</Button>
</div>
@@ -6,12 +6,7 @@ import Image from "next/image";
import { useTheme } from "next-themes";
import { Controller, useForm } from "react-hook-form";
import { Eye, EyeOff } from "lucide-react";
import {
AUTH_TRACKER_EVENTS,
E_PASSWORD_STRENGTH,
ONBOARDING_TRACKER_ELEMENTS,
USER_TRACKER_EVENTS,
} from "@plane/constants";
import { E_PASSWORD_STRENGTH, ONBOARDING_TRACKER_ELEMENTS, USER_TRACKER_EVENTS } from "@plane/constants";
// types
import { useTranslation } from "@plane/i18n";
import { IUser, TUserProfile, TOnboardingSteps } from "@plane/types";
@@ -128,18 +123,7 @@ export const ProfileSetup: React.FC<Props> = observer((props) => {
const handleSetPassword = async (password: string) => {
const token = await authService.requestCSRFToken().then((data) => data?.csrf_token);
await authService
.setPassword(token, { password })
.then(() => {
captureSuccess({
eventName: AUTH_TRACKER_EVENTS.password_created,
});
})
.catch(() => {
captureError({
eventName: AUTH_TRACKER_EVENTS.password_created,
});
});
await authService.setPassword(token, { password });
};
const handleSubmitProfileSetup = async (formData: TProfileSetupFormValues) => {
@@ -196,18 +180,7 @@ export const ProfileSetup: React.FC<Props> = observer((props) => {
await Promise.all([
updateCurrentUser(userDetailsPayload),
formData.password && handleSetPassword(formData.password),
]).then(() => {
if (formData.password) {
captureView({
elementName: ONBOARDING_TRACKER_ELEMENTS.PASSWORD_CREATION_SELECTED,
});
} else {
captureView({
elementName: ONBOARDING_TRACKER_ELEMENTS.PASSWORD_CREATION_SKIPPED,
});
}
setProfileSetupStep(EProfileSetupSteps.USER_PERSONALIZATION);
});
]).then(() => setProfileSetupStep(EProfileSetupSteps.USER_PERSONALIZATION));
} catch {
captureError({
eventName: USER_TRACKER_EVENTS.add_details,
@@ -4,12 +4,9 @@ import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
// types
import { PROJECT_VIEW_TRACKER_EVENTS } from "@plane/constants";
import { IProjectView } from "@plane/types";
// ui
import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui";
// helpers
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useProjectView } from "@/hooks/store";
@@ -48,26 +45,14 @@ export const DeleteProjectViewModal: React.FC<Props> = observer((props) => {
title: "Success!",
message: "View deleted successfully.",
});
captureSuccess({
eventName: PROJECT_VIEW_TRACKER_EVENTS.delete,
payload: {
view_id: data.id,
},
});
})
.catch(() => {
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "View could not be deleted. Please try again.",
});
captureError({
eventName: PROJECT_VIEW_TRACKER_EVENTS.delete,
payload: {
view_id: data.id,
},
});
})
})
)
.finally(() => {
setIsDeleteLoading(false);
});
+7 -32
View File
@@ -3,14 +3,12 @@
import { FC } from "react";
import { observer } from "mobx-react";
// types
import { PROJECT_VIEW_TRACKER_EVENTS } from "@plane/constants";
import { IProjectView } from "@plane/types";
// ui
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ProjectViewForm } from "@/components/views";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useProjectView } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import useKeypress from "@/hooks/use-keypress";
@@ -45,49 +43,26 @@ export const CreateUpdateProjectViewModal: FC<Props> = observer((props) => {
title: "Success!",
message: "View created successfully.",
});
captureSuccess({
eventName: PROJECT_VIEW_TRACKER_EVENTS.create,
payload: {
view_id: res.id,
},
});
})
.catch(() => {
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again.",
});
captureError({
eventName: PROJECT_VIEW_TRACKER_EVENTS.create,
});
});
})
);
};
const handleUpdateView = async (payload: IProjectView) => {
await updateView(workspaceSlug, projectId, data?.id as string, payload)
.then(() => {
handleClose();
captureSuccess({
eventName: PROJECT_VIEW_TRACKER_EVENTS.update,
payload: {
view_id: data?.id,
},
});
})
.catch((err) => {
.then(() => handleClose())
.catch((err) =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err?.detail ?? "Something went wrong. Please try again.",
});
captureError({
eventName: PROJECT_VIEW_TRACKER_EVENTS.update,
payload: {
view_id: data?.id,
},
});
});
})
);
};
const handleFormSubmit = async (formData: IProjectView) => {
@@ -4,7 +4,7 @@ import { useState } from "react";
import { observer } from "mobx-react";
import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
// types
import { EUserPermissions, EUserPermissionsLevel, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { IProjectView } from "@plane/types";
// ui
import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
@@ -12,7 +12,6 @@ import { copyUrlToClipboard, cn } from "@plane/utils";
// components
import { CreateUpdateProjectViewModal, DeleteProjectViewModal } from "@/components/views";
// helpers
import { captureClick } from "@/helpers/event-tracker.helper";
// hooks
import { useUser, useUserPermissions } from "@/hooks/store";
import { PublishViewModal, useViewPublish } from "@/plane-web/components/views/publish";
@@ -84,14 +83,6 @@ export const ViewQuickActions: React.FC<Props> = observer((props) => {
if (publishContextMenu) MENU_ITEMS.splice(2, 0, publishContextMenu);
const CONTEXT_MENU_ITEMS = MENU_ITEMS.map((item) => ({
...item,
action: () => {
captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.LIST_ITEM_CONTEXT_MENU });
item.action();
},
}));
return (
<>
<CreateUpdateProjectViewModal
@@ -103,7 +94,7 @@ export const ViewQuickActions: React.FC<Props> = observer((props) => {
/>
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
<PublishViewModal isOpen={isPublishModalOpen} onClose={() => setPublishModalOpen(false)} view={view} />
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
<CustomMenu ellipsis placement="bottom-end" closeOnSelect buttonClassName={customClassName}>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
@@ -113,7 +104,6 @@ export const ViewQuickActions: React.FC<Props> = observer((props) => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.QUICK_ACTIONS });
item.action();
}}
className={cn(
@@ -1,4 +1,5 @@
import { SetStateAction, useEffect, useState } from "react";
import { GLOBAL_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { Button } from "@plane/ui";
import { LockedComponent } from "../icons/locked-component";
@@ -10,7 +11,6 @@ type Props = {
setIsModalOpen: (value: SetStateAction<boolean>) => void;
handleUpdateView: () => void;
lockedTooltipContent?: string;
trackerElement: string;
};
export const UpdateViewComponent = (props: Props) => {
@@ -22,7 +22,6 @@ export const UpdateViewComponent = (props: Props) => {
setIsModalOpen,
handleUpdateView,
lockedTooltipContent,
trackerElement,
} = props;
const [isUpdating, setIsUpdating] = useState(false);
@@ -64,7 +63,7 @@ export const UpdateViewComponent = (props: Props) => {
variant="outline-primary"
size="md"
className="flex-shrink-0"
data-ph-element={trackerElement}
data-ph-element={GLOBAL_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON}
onClick={() => setIsModalOpen(true)}
>
Save as
@@ -1,7 +1,7 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissionsLevel, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EUserProjectRoles } from "@plane/types";
// components
@@ -10,7 +10,6 @@ import { ComicBoxButton, DetailedEmptyState, SimpleEmptyState } from "@/componen
import { ViewListLoader } from "@/components/ui";
import { ProjectViewListItem } from "@/components/views";
// hooks
import { captureClick } from "@/helpers/event-tracker.helper";
import { useCommandPalette, useProjectView, useUserPermissions } from "@/hooks/store";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
@@ -72,10 +71,7 @@ export const ProjectViewsList = observer(() => {
label={t("project_views.empty_state.general.primary_button.text")}
title={t("project_views.empty_state.general.primary_button.comic.title")}
description={t("project_views.empty_state.general.primary_button.comic.description")}
onClick={() => {
toggleCreateViewModal(true);
captureClick({ elementName: PROJECT_VIEW_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_BUTTON });
}}
onClick={() => toggleCreateViewModal(true)}
disabled={!canPerformEmptyStateActions}
/>
}
@@ -1,5 +1,4 @@
import { FC } from "react";
import { TNotification } from "@plane/types";
import {
convertMinutesToHoursMinutesString,
@@ -9,13 +8,8 @@ import {
stripAndTruncateHTML,
} from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor/lite-text";
import {
renderAdditionalAction,
renderAdditionalValue,
shouldShowConnector,
shouldRender,
} from "@/plane-web/components/workspace-notifications";
// helpers
import { LiteTextReadOnlyEditor } from "@/components/editor";
export const NotificationContent: FC<{
notification: TNotification;
@@ -64,7 +58,8 @@ export const NotificationContent: FC<{
}
if (notificationField === "None") return null;
return renderAdditionalAction(notificationField, verb);
const baseAction = !["comment", "archived_at"].includes(notificationField) ? verb : "";
return `${baseAction} ${replaceUnderscoreIfSnakeCase(notificationField)}`;
};
const renderValue = () => {
@@ -81,21 +76,31 @@ export const NotificationContent: FC<{
return newValue !== ""
? convertMinutesToHoursMinutesString(Number(newValue))
: convertMinutesToHoursMinutesString(Number(oldValue));
return renderAdditionalValue(notificationField, newValue, oldValue);
return newValue;
};
const shouldShowConnector = ![
"comment",
"archived_at",
"None",
"assignees",
"labels",
"start_date",
"target_date",
"parent",
].includes(notificationField || "");
return (
<>
{renderTriggerName()}
<span className="text-custom-text-300">{renderAction()} </span>
{shouldRender(notificationField, verb) && (
{verb !== "deleted" && (
<>
{shouldShowConnector(notificationField) && <span className="text-custom-text-300">to </span>}
{shouldShowConnector && <span className="text-custom-text-300">to </span>}
<span className="text-custom-text-100 font-medium">{renderValue()}</span>
{notificationField === "comment" && renderCommentBox && (
<div className="scale-75 origin-left">
<LiteTextEditor
editable={false}
<LiteTextReadOnlyEditor
id=""
initialValue={newValue ?? ""}
workspaceId={workspaceId}
+38 -21
View File
@@ -1,6 +1,7 @@
import { useCallback } from "react";
// plane imports
import type { TFileHandler } from "@plane/editor";
// plane editor
import { TFileHandler, TReadOnlyFileHandler } from "@plane/editor";
// helpers
import { getEditorAssetDownloadSrc, getEditorAssetSrc } from "@plane/utils";
// hooks
import { useEditorAsset } from "@/hooks/store";
@@ -23,30 +24,15 @@ export const useEditorConfig = () => {
// file size
const { maxFileSize } = useFileSize();
const getEditorFileHandlers = useCallback(
(args: TArgs): TFileHandler => {
const { projectId, uploadFile, workspaceId, workspaceSlug } = args;
const getReadOnlyEditorFileHandlers = useCallback(
(args: Pick<TArgs, "projectId" | "workspaceId" | "workspaceSlug">): TReadOnlyFileHandler => {
const { projectId, workspaceId, workspaceSlug } = args;
return {
assetsUploadStatus: assetsUploadPercentage,
cancel: fileService.cancelUpload,
checkIfAssetExists: async (assetId: string) => {
const res = await fileService.checkIfAssetExists(workspaceSlug, assetId);
return res?.exists ?? false;
},
delete: async (src: string) => {
if (src?.startsWith("http")) {
await fileService.deleteOldWorkspaceAsset(workspaceId, src);
} else {
await fileService.deleteNewAsset(
getEditorAssetSrc({
assetId: src,
projectId,
workspaceSlug,
}) ?? ""
);
}
},
getAssetDownloadSrc: async (path) => {
if (!path) return "";
if (path?.startsWith("http")) {
@@ -82,16 +68,47 @@ export const useEditorConfig = () => {
await fileService.restoreNewAsset(workspaceSlug, src);
}
},
};
},
[]
);
const getEditorFileHandlers = useCallback(
(args: TArgs): TFileHandler => {
const { projectId, uploadFile, workspaceId, workspaceSlug } = args;
return {
...getReadOnlyEditorFileHandlers({
projectId,
workspaceId,
workspaceSlug,
}),
assetsUploadStatus: assetsUploadPercentage,
upload: uploadFile,
delete: async (src: string) => {
if (src?.startsWith("http")) {
await fileService.deleteOldWorkspaceAsset(workspaceId, src);
} else {
await fileService.deleteNewAsset(
getEditorAssetSrc({
assetId: src,
projectId,
workspaceSlug,
}) ?? ""
);
}
},
cancel: fileService.cancelUpload,
validation: {
maxFileSize,
},
};
},
[assetsUploadPercentage, maxFileSize]
[assetsUploadPercentage, getReadOnlyEditorFileHandlers, maxFileSize]
);
return {
getEditorFileHandlers,
getReadOnlyEditorFileHandlers,
};
};
@@ -1 +1 @@
export * from "ce/components/common/extended-app-header";
export * from "./extended-app-header";
@@ -4,4 +4,3 @@ export * from "./issue-type-switcher";
export * from "./issue-type-activity";
export * from "./parent-select-root";
export * from "./issue-creator";
export * from "./page";
@@ -1 +0,0 @@
export * from "ce/components/issues/issue-details/page";
+5 -37
View File
@@ -365,49 +365,17 @@ function startServices() {
fi
local api_container_id=$(docker container ls -q -f "name=$SERVICE_FOLDER-api")
# Verify container exists
if [ -z "$api_container_id" ]; then
echo " Error: API container not found. Please check if services are running."
exit 1
fi
local idx2=0
local api_ready=true # assume success, flip on timeout
local max_wait_time=300 # 5 minutes timeout
local start_time=$(date +%s)
echo " Waiting for API Service to be ready..."
while ! docker exec "$api_container_id" python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/', timeout=3)" > /dev/null 2>&1; do
local current_time=$(date +%s)
local elapsed_time=$((current_time - start_time))
if [ $elapsed_time -gt $max_wait_time ]; then
echo ""
echo " API Service health check timed out after 5 minutes"
echo " Checking if API container is still running..."
if docker ps | grep -q "$SERVICE_FOLDER-api"; then
echo " API container is running but did not pass the health-check. Continuing without marking it ready."
api_ready=false
break
else
echo " API container is not running. Please check logs."
exit 1
fi
fi
local message=">> Waiting for API Service to Start (${elapsed_time}s)"
local dots=$(printf '%*s' $idx2 | tr ' ' '.')
while ! docker exec $api_container_id python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/')" > /dev/null 2>&1;
do
local message=">> Waiting for API Service to Start"
local dots=$(printf '%*s' $idx2 | tr ' ' '.')
echo -ne "\r$message$dots"
((idx2++))
sleep 1
done
printf "\r\033[K"
if [ "$api_ready" = true ]; then
echo " API Service started successfully ✅"
else
echo " ⚠️ API Service did not respond to health-check please verify manually."
fi
echo " API Service started successfully ✅"
source "${DOCKER_ENV_PATH}"
echo " Plane Server started successfully ✅"
echo ""
@@ -268,7 +268,6 @@ export const AUTH_TRACKER_EVENTS = {
sign_in_with_password: "sign_in_with_password",
forgot_password: "forgot_password_clicked",
new_code_requested: "new_code_requested",
password_created: "password_created",
};
export const AUTH_TRACKER_ELEMENTS = {
@@ -279,7 +278,6 @@ export const AUTH_TRACKER_ELEMENTS = {
SIGN_IN_WITH_UNIQUE_CODE: "sign_in_with_unique_code",
REQUEST_NEW_CODE: "request_new_code",
VERIFY_CODE: "verify_code",
SET_PASSWORD_FORM: "set_password_form",
};
/**
@@ -301,29 +299,6 @@ export const GLOBAL_VIEW_TRACKER_ELEMENTS = {
LIST_ITEM: "global_view_list_item",
};
/**
* ===========================================================================
* Project View Events and Elements
* ===========================================================================
*/
export const PROJECT_VIEW_TRACKER_EVENTS = {
create: "project_view_created",
update: "project_view_updated",
delete: "project_view_deleted",
};
export const PROJECT_VIEW_TRACKER_ELEMENTS = {
RIGHT_HEADER_ADD_BUTTON: "project_view_right_header_add_button",
COMMAND_PALETTE_ADD_ITEM: "command_palette_add_project_view_item",
EMPTY_STATE_CREATE_BUTTON: "project_view_empty_state_create_button",
HEADER_SAVE_VIEW_BUTTON: "project_view_header_save_view_button",
PROJECT_HEADER_SAVE_AS_VIEW_BUTTON: "project_view_header_save_as_view_button",
CYCLE_HEADER_SAVE_AS_VIEW_BUTTON: "cycle_header_save_as_view_button",
MODULE_HEADER_SAVE_AS_VIEW_BUTTON: "module_header_save_as_view_button",
QUICK_ACTIONS: "project_view_quick_actions",
LIST_ITEM_CONTEXT_MENU: "project_view_list_item_context_menu",
};
/**
* ===========================================================================
* Product Tour Events and Elements
@@ -368,11 +343,6 @@ export const USER_TRACKER_EVENTS = {
onboarding_complete: "user_onboarding_completed",
};
export const USER_TRACKER_ELEMENTS = {
PRODUCT_CHANGELOG_MODAL: "product_changelog_modal",
CHANGELOG_REDIRECTED: "changelog_redirected",
};
/**
* ===========================================================================
* Onboarding Events and Elements
@@ -380,8 +350,6 @@ export const USER_TRACKER_ELEMENTS = {
*/
export const ONBOARDING_TRACKER_ELEMENTS = {
PROFILE_SETUP_FORM: "onboarding_profile_setup_form",
PASSWORD_CREATION_SELECTED: "onboarding_password_creation_selected",
PASSWORD_CREATION_SKIPPED: "onboarding_password_creation_skipped",
};
/**
+1 -1
View File
@@ -66,7 +66,7 @@
"@tiptap/suggestion": "^2.22.3",
"highlight.js": "^11.8.0",
"jsx-dom-cjs": "^8.0.3",
"linkifyjs": "^4.3.2",
"linkifyjs": "^4.1.3",
"lowlight": "^3.0.0",
"lucide-react": "^0.469.0",
"prosemirror-codemark": "^0.4.2",
@@ -1 +1,2 @@
export * from "./extensions";
export * from "./read-only-extensions";
@@ -0,0 +1,15 @@
import type { Extensions } from "@tiptap/core";
// types
import type { IReadOnlyEditorProps } from "@/types";
export type TCoreReadOnlyEditorAdditionalExtensionsProps = Pick<
IReadOnlyEditorProps,
"disabledExtensions" | "flaggedExtensions"
>;
export const CoreReadOnlyEditorAdditionalExtensions = (
props: TCoreReadOnlyEditorAdditionalExtensionsProps
): Extensions => {
const {} = props;
return [];
};
@@ -0,0 +1,31 @@
import { AnyExtension, Extensions } from "@tiptap/core";
// types
import { IReadOnlyEditorProps, TExtensions } from "@/types";
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = Pick<
IReadOnlyEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
/**
* Registry entry configuration for extensions
*/
export type TRichTextReadOnlyEditorAdditionalExtensionsRegistry = {
/** Determines if the extension should be enabled based on disabled extensions */
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
/** Returns the extension instance(s) when enabled */
getExtension: (props: TRichTextReadOnlyEditorAdditionalExtensionsProps) => AnyExtension | undefined;
};
const extensionRegistry: TRichTextReadOnlyEditorAdditionalExtensionsRegistry[] = [];
export const RichTextReadOnlyEditorAdditionalExtensions = (props: TRichTextReadOnlyEditorAdditionalExtensionsProps) => {
const { disabledExtensions } = props;
const extensions: Extensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(props))
.filter((extension): extension is AnyExtension => extension !== undefined);
return extensions;
};
@@ -4,3 +4,4 @@ export * from "./rich-text";
export * from "./editor-container";
export * from "./editor-content";
export * from "./editor-wrapper";
export * from "./read-only-editor-wrapper";
@@ -19,7 +19,7 @@ const LiteTextEditor: React.FC<ILiteTextEditorProps> = (props) => {
return resolvedExtensions;
}, [externalExtensions, disabledExtensions, onEnterKeyPress]);
return <EditorWrapper {...props} extensions={extensions} />;
return <EditorWrapper {...props} editable extensions={extensions} />;
};
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditorProps>((props, ref) => (
@@ -1 +1,2 @@
export * from "./editor";
export * from "./read-only-editor";
@@ -0,0 +1,13 @@
import { forwardRef } from "react";
// components
import { ReadOnlyEditorWrapper } from "@/components/editors";
// types
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps } from "@/types";
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps>((props, ref) => (
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
));
LiteTextReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef";
export { LiteTextReadOnlyEditorWithRef };
@@ -0,0 +1,56 @@
// components
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
import { IReadOnlyEditorProps } from "@/types";
export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
const {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
id,
initialValue,
mentionHandler,
} = props;
const editor = useReadOnlyEditor({
disabledExtensions,
editorClassName,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
initialValue,
mentionHandler,
});
const editorContainerClassName = getEditorClassNames({
containerClassName,
});
if (!editor) return null;
return (
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
>
<div className="flex flex-col">
<EditorContentWrapper editor={editor} id={id} />
</div>
</EditorContainer>
);
};
@@ -5,7 +5,7 @@ import { EditorBubbleMenu } from "@/components/menus";
// extensions
import { SideMenuExtension } from "@/extensions";
// plane editor imports
import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text-extensions";
import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/extensions";
// types
import { EditorRefApi, IRichTextEditorProps } from "@/types";
@@ -1,6 +1,6 @@
import { findParentNodeClosestToPos, Predicate, ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock, CustomCalloutNodeViewProps } from "@/extensions/callout/block";
import { CustomCalloutBlock, CustomCalloutNodeViewProps } from "@/extensions/callout";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// config
@@ -1 +1,3 @@
export * from "./block";
export * from "./extension";
export * from "./read-only-extension";
@@ -0,0 +1,16 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock, CustomCalloutNodeViewProps } from "@/extensions/callout";
// config
import { CustomCalloutExtensionConfig } from "./extension-config";
export const CustomCalloutReadOnlyExtension = CustomCalloutExtensionConfig.extend({
selectable: false,
draggable: false,
addNodeView() {
return ReactNodeViewRenderer((props) => (
<CustomCalloutBlock {...props} node={props.node as CustomCalloutNodeViewProps["node"]} />
));
},
});
@@ -280,7 +280,6 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
{showImageToolbar && (
<ImageToolbarRoot
alignment={nodeAlignment ?? "left"}
editor={editor}
width={size.width}
height={size.height}
aspectRatio={size.aspectRatio === null ? 1 : size.aspectRatio}
@@ -191,7 +191,7 @@ const ImageFullScreenModalWithoutPortal = (props: Props) => {
return (
<div
className={cn("fixed inset-0 size-full z-50 bg-black/90 opacity-0 pointer-events-none transition-opacity", {
className={cn("fixed inset-0 size-full z-30 bg-black/90 opacity-0 pointer-events-none transition-opacity", {
"opacity-100 pointer-events-auto editor-image-full-screen-modal": isFullScreenEnabled,
"cursor-default": !isDragging,
"cursor-grabbing": isDragging,
@@ -1,4 +1,3 @@
import type { Editor } from "@tiptap/core";
import { useState } from "react";
// plane imports
import { cn } from "@plane/utils";
@@ -10,7 +9,6 @@ import { ImageFullScreenActionRoot } from "./full-screen";
type Props = {
alignment: TCustomImageAlignment;
editor: Editor;
width: string;
height: string;
aspectRatio: number;
@@ -20,11 +18,9 @@ type Props = {
};
export const ImageToolbarRoot: React.FC<Props> = (props) => {
const { alignment, editor, downloadSrc, handleAlignmentChange } = props;
const { alignment, downloadSrc, handleAlignmentChange } = props;
// states
const [shouldShowToolbar, setShouldShowToolbar] = useState(false);
// derived values
const isEditable = editor.isEditable;
return (
<>
@@ -37,13 +33,11 @@ export const ImageToolbarRoot: React.FC<Props> = (props) => {
)}
>
<ImageDownloadAction src={downloadSrc} />
{isEditable && (
<ImageAlignmentAction
activeAlignment={alignment}
handleChange={handleAlignmentChange}
toggleToolbarViewStatus={setShouldShowToolbar}
/>
)}
<ImageAlignmentAction
activeAlignment={alignment}
handleChange={handleAlignmentChange}
toggleToolbarViewStatus={setShouldShowToolbar}
/>
<ImageFullScreenActionRoot image={props} toggleToolbarViewStatus={setShouldShowToolbar} />
</div>
</>
@@ -6,14 +6,14 @@ import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
import { isFileValid } from "@/helpers/file";
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// types
import type { TFileHandler } from "@/types";
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { CustomImageNodeView, CustomImageNodeViewProps } from "./components/node-view";
import { CustomImageExtensionConfig } from "./extension-config";
import { getImageComponentImageFileMap } from "./utils";
type Props = {
fileHandler: TFileHandler;
fileHandler: TFileHandler | TReadOnlyFileHandler;
isEditable: boolean;
};
@@ -2,7 +2,7 @@ import { ReactNodeViewRenderer } from "@tiptap/react";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// types
import type { TFileHandler } from "@/types";
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { CustomImageNodeView, CustomImageNodeViewProps } from "../custom-image/components/node-view";
import { ImageExtensionConfig } from "./extension-config";
@@ -12,7 +12,7 @@ export type ImageExtensionStorage = {
};
type Props = {
fileHandler: TFileHandler;
fileHandler: TFileHandler | TReadOnlyFileHandler;
};
export const ImageExtension = (props: Props) => {
@@ -17,6 +17,7 @@ export * from "./headings-list";
export * from "./horizontal-rule";
export * from "./keymap";
export * from "./quote";
export * from "./read-only-extensions";
export * from "./side-menu";
export * from "./text-align";
export * from "./utility";
@@ -0,0 +1,102 @@
import { Extensions } from "@tiptap/core";
import CharacterCount from "@tiptap/extension-character-count";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
import TiptapUnderline from "@tiptap/extension-underline";
import { Markdown } from "tiptap-markdown";
// extensions
import {
CustomQuoteExtension,
CustomHorizontalRule,
CustomLinkExtension,
CustomTypographyExtension,
CustomCodeBlockExtension,
CustomCodeInlineExtension,
TableHeader,
TableCell,
TableRow,
Table,
CustomMentionExtension,
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
CustomColorExtension,
UtilityExtension,
ImageExtension,
} from "@/extensions";
// plane editor extensions
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import type { IReadOnlyEditorProps } from "@/types";
// local imports
import { CustomImageExtension } from "./custom-image/extension";
import { EmojiExtension } from "./emoji/extension";
import { CustomStarterKitExtension } from "./starter-kit";
type Props = Pick<IReadOnlyEditorProps, "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler">;
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
const { disabledExtensions, fileHandler, flaggedExtensions, mentionHandler } = props;
const extensions = [
CustomStarterKitExtension({
enableHistory: false,
}),
EmojiExtension,
CustomQuoteExtension,
CustomHorizontalRule,
CustomLinkExtension,
CustomTypographyExtension,
TiptapUnderline,
TextStyle,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2 space-y-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "relative pointer-events-none",
},
nested: true,
}),
CustomCodeBlockExtension,
CustomCodeInlineExtension,
Markdown.configure({
html: true,
transformCopiedText: false,
}),
Table,
TableHeader,
TableCell,
TableRow,
CustomMentionExtension(mentionHandler),
CharacterCount,
CustomColorExtension,
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
UtilityExtension({
disabledExtensions,
fileHandler,
isEditable: false,
}),
...CoreReadOnlyEditorAdditionalExtensions({
disabledExtensions,
flaggedExtensions,
}),
];
if (!disabledExtensions.includes("image")) {
extensions.push(
ImageExtension({
fileHandler,
}),
CustomImageExtension({
fileHandler,
isEditable: false,
})
);
}
return extensions;
};
@@ -10,7 +10,7 @@ import { FilePlugins } from "@/plugins/file/root";
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
// types
import type { IEditorProps, TEditorAsset, TFileHandler } from "@/types";
import type { IEditorProps, TEditorAsset, TFileHandler, TReadOnlyFileHandler } from "@/types";
type TActiveDropbarExtensions = CORE_EXTENSIONS.MENTION | CORE_EXTENSIONS.EMOJI | TAdditionalActiveDropbarExtensions;
declare module "@tiptap/core" {
@@ -38,7 +38,7 @@ export interface UtilityExtensionStorage {
}
type Props = Pick<IEditorProps, "disabledExtensions"> & {
fileHandler: TFileHandler;
fileHandler: TFileHandler | TReadOnlyFileHandler;
isEditable: boolean;
};
+3 -149
View File
@@ -1,26 +1,22 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { Editor } from "@tiptap/core";
import { DOMSerializer } from "@tiptap/pm/model";
import * as Y from "yjs";
// components
import { getEditorMenuItems } from "@/components/menus";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import { CORE_EDITOR_META } from "@/constants/meta";
// types
import type { EditorRefApi, TEditorCommands } from "@/types";
import { EditorReadOnlyRefApi } from "@/types";
// 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";
import { scrollSummary } from "./scroll-to-node";
type TArgs = {
editor: Editor | null;
provider: HocuspocusProvider | undefined;
};
export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
export const getEditorRefHelpers = (args: TArgs): EditorReadOnlyRefApi => {
const { editor, provider } = args;
return {
@@ -55,147 +51,5 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
setEditorValue: (content, emitUpdate = false) => {
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });
},
blur: () => editor?.commands.blur(),
emitRealTimeUpdate: (message) => provider?.sendStateless(message),
executeMenuItemCommand: (props) => {
const { itemKey } = props;
const editorItems = getEditorMenuItems(editor);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
const item = getEditorMenuItem(itemKey);
if (item) {
item.command(props);
} else {
console.warn(`No command found for item: ${itemKey}`);
}
},
getCurrentCursorPosition: () => editor?.state.selection.from,
getSelectedText: () => {
if (!editor) return null;
const { state } = editor;
const { from, to, empty } = state.selection;
if (empty) return null;
const nodesArray: string[] = [];
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
if (parent === state.doc && editor) {
const serializer = DOMSerializer.fromSchema(editor.schema);
const dom = serializer.serializeNode(node);
const tempDiv = document.createElement("div");
tempDiv.appendChild(dom);
nodesArray.push(tempDiv.innerHTML);
}
});
const selection = nodesArray.join("");
return selection;
},
insertText: (contentHTML, insertOnNextLine) => {
if (!editor) return;
const { from, to, empty } = editor.state.selection;
if (empty) return;
if (insertOnNextLine) {
// move cursor to the end of the selection and insert a new line
editor.chain().focus().setTextSelection(to).insertContent("<br />").insertContent(contentHTML).run();
} else {
// replace selected text with the content provided
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
}
},
isEditorReadyToDiscard: () =>
!!editor && getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress === false,
isMenuItemActive: (props) => {
const { itemKey } = props;
const editorItems = getEditorMenuItems(editor);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
const item = getEditorMenuItem(itemKey);
if (!item) return false;
return item.isActive(props);
},
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
onDocumentInfoChange: (callback) => {
const handleDocumentInfoChange = () => {
if (!editor) return;
callback({
characters: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.characters?.() : 0,
paragraphs: getParagraphCount(editor?.state),
words: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.words?.() : 0,
});
};
// Subscribe to update event emitted from character count extension
editor?.on("update", handleDocumentInfoChange);
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editor?.off("update", handleDocumentInfoChange);
};
},
onHeadingChange: (callback) => {
const handleHeadingChange = () => {
if (!editor) return;
const headings = getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings;
if (headings) {
callback(headings);
}
};
// Subscribe to update event emitted from headers extension
editor?.on("update", handleHeadingChange);
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editor?.off("update", handleHeadingChange);
};
},
onStateChange: (callback) => {
// Subscribe to editor state changes
editor?.on("transaction", callback);
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editor?.off("transaction", callback);
};
},
scrollToNodeViaDOMCoordinates(behavior, pos) {
const resolvedPos = pos ?? editor?.state.selection.from;
if (!editor || !resolvedPos) return;
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
},
setEditorValueAtCursorPosition: (content) => {
if (editor?.state.selection) {
insertContentAtSavedSelection(editor, content);
}
},
setFocusAtPosition: (position) => {
if (!editor || editor.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
return;
}
try {
const docSize = editor.state.doc.content.size;
const safePosition = Math.max(0, Math.min(position, docSize));
editor
.chain()
.insertContentAt(safePosition, [{ type: CORE_EXTENSIONS.PARAGRAPH }])
.focus()
.run();
} catch (error) {
console.error("An error occurred while setting focus at position:", error);
}
},
setProviderDocument: (value) => {
const document = provider?.document;
if (!document) return;
Y.applyUpdate(document, value);
},
};
};
+157 -2
View File
@@ -1,16 +1,23 @@
import { DOMSerializer } from "@tiptap/pm/model";
import { useEditorState, useEditor as useTiptapEditor } from "@tiptap/react";
import { useImperativeHandle, useEffect } from "react";
import * as Y from "yjs";
// components
import { getEditorMenuItems } from "@/components/menus";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CoreEditorExtensions } from "@/extensions";
// helpers
import { getParagraphCount } from "@/helpers/common";
import { getEditorRefHelpers } from "@/helpers/editor-ref";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
import { insertContentAtSavedSelection } from "@/helpers/insert-content-at-cursor-position";
import { scrollToNodeViaDOMCoordinates } from "@/helpers/scroll-to-node";
// props
import { CoreEditorProps } from "@/props";
// types
import type { TEditorHookProps } from "@/types";
import type { TEditorCommands, TEditorHookProps } from "@/types";
export const useEditor = (props: TEditorHookProps) => {
const {
@@ -117,7 +124,155 @@ export const useEditor = (props: TEditorHookProps) => {
onAssetChange(assets);
}, [assetsList?.assets, onAssetChange]);
useImperativeHandle(forwardedRef, () => getEditorRefHelpers({ editor, provider }), [editor, provider]);
useImperativeHandle(
forwardedRef,
() => ({
...getEditorRefHelpers({ editor, provider }),
blur: () => editor?.commands.blur(),
emitRealTimeUpdate: (message) => provider?.sendStateless(message),
executeMenuItemCommand: (props) => {
const { itemKey } = props;
const editorItems = getEditorMenuItems(editor);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
const item = getEditorMenuItem(itemKey);
if (item) {
item.command(props);
} else {
console.warn(`No command found for item: ${itemKey}`);
}
},
getCurrentCursorPosition: () => editor?.state.selection.from,
getSelectedText: () => {
if (!editor) return null;
const { state } = editor;
const { from, to, empty } = state.selection;
if (empty) return null;
const nodesArray: string[] = [];
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
if (parent === state.doc && editor) {
const serializer = DOMSerializer.fromSchema(editor.schema);
const dom = serializer.serializeNode(node);
const tempDiv = document.createElement("div");
tempDiv.appendChild(dom);
nodesArray.push(tempDiv.innerHTML);
}
});
const selection = nodesArray.join("");
return selection;
},
insertText: (contentHTML, insertOnNextLine) => {
if (!editor) return;
const { from, to, empty } = editor.state.selection;
if (empty) return;
if (insertOnNextLine) {
// move cursor to the end of the selection and insert a new line
editor.chain().focus().setTextSelection(to).insertContent("<br />").insertContent(contentHTML).run();
} else {
// replace selected text with the content provided
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
}
},
isEditorReadyToDiscard: () =>
!!editor && getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress === false,
isMenuItemActive: (props) => {
const { itemKey } = props;
const editorItems = getEditorMenuItems(editor);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
const item = getEditorMenuItem(itemKey);
if (!item) return false;
return item.isActive(props);
},
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
onDocumentInfoChange: (callback) => {
const handleDocumentInfoChange = () => {
if (!editor) return;
callback({
characters: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.characters?.() : 0,
paragraphs: getParagraphCount(editor?.state),
words: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.words?.() : 0,
});
};
// Subscribe to update event emitted from character count extension
editor?.on("update", handleDocumentInfoChange);
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editor?.off("update", handleDocumentInfoChange);
};
},
onHeadingChange: (callback) => {
const handleHeadingChange = () => {
if (!editor) return;
const headings = getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings;
if (headings) {
callback(headings);
}
};
// Subscribe to update event emitted from headers extension
editor?.on("update", handleHeadingChange);
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editor?.off("update", handleHeadingChange);
};
},
onStateChange: (callback) => {
// Subscribe to editor state changes
editor?.on("transaction", callback);
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editor?.off("transaction", callback);
};
},
scrollToNodeViaDOMCoordinates(behavior, pos) {
const resolvedPos = pos ?? editor?.state.selection.from;
if (!editor || !resolvedPos) return;
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
},
setEditorValueAtCursorPosition: (content) => {
if (editor?.state.selection) {
insertContentAtSavedSelection(editor, content);
}
},
setFocusAtPosition: (position) => {
if (!editor || editor.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
return;
}
try {
const docSize = editor.state.doc.content.size;
const safePosition = Math.max(0, Math.min(position, docSize));
editor
.chain()
.insertContentAt(safePosition, [{ type: CORE_EXTENSIONS.PARAGRAPH }])
.focus()
.run();
} catch (error) {
console.error("An error occurred while setting focus at position:", error);
}
},
setProviderDocument: (value) => {
const document = provider?.document;
if (!document) return;
Y.applyUpdate(document, value);
},
}),
[editor, provider]
);
if (!editor) {
return null;
@@ -0,0 +1,69 @@
import { useEditor as useTiptapEditor } from "@tiptap/react";
import { useImperativeHandle, useEffect } from "react";
// extensions
import { CoreReadOnlyEditorExtensions } from "@/extensions";
// helpers
import { getEditorRefHelpers } from "@/helpers/editor-ref";
// props
import { CoreReadOnlyEditorProps } from "@/props";
// types
import type { TReadOnlyEditorHookProps } from "@/types";
export const useReadOnlyEditor = (props: TReadOnlyEditorHookProps) => {
const {
disabledExtensions,
editorClassName = "",
editorProps = {},
extensions = [],
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
initialValue,
mentionHandler,
provider,
} = props;
const editor = useTiptapEditor({
editable: false,
immediatelyRender: false,
shouldRerenderOnTransaction: false,
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
parseOptions: { preserveWhitespace: true },
editorProps: {
...CoreReadOnlyEditorProps({
editorClassName,
}),
...editorProps,
},
onCreate: async () => {
handleEditorReady?.(true);
},
extensions: [
...CoreReadOnlyEditorExtensions({
disabledExtensions,
fileHandler,
flaggedExtensions,
mentionHandler,
}),
...extensions,
],
onDestroy: () => {
handleEditorReady?.(false);
},
});
// for syncing swr data on tab refocus etc
useEffect(() => {
if (initialValue === null || initialValue === undefined) return;
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: true });
}, [editor, initialValue]);
useImperativeHandle(forwardedRef, () => getEditorRefHelpers({ editor, provider }));
if (!editor) {
return null;
}
return editor;
};
@@ -1,14 +1,14 @@
import { Editor } from "@tiptap/core";
import { Plugin } from "@tiptap/pm/state";
// types
import { TFileHandler } from "@/types";
import { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { TrackFileDeletionPlugin } from "./delete";
import { TrackFileRestorationPlugin } from "./restore";
type TArgs = {
editor: Editor;
fileHandler: TFileHandler;
fileHandler: TFileHandler | TReadOnlyFileHandler;
isEditable: boolean;
};
+2
View File
@@ -0,0 +1,2 @@
export * from "./props";
export * from "./read-only";
@@ -2,11 +2,11 @@ import { EditorProps } from "@tiptap/pm/view";
// plane utils
import { cn } from "@plane/utils";
type TArgs = {
export type TCoreEditorProps = {
editorClassName: string;
};
export const CoreEditorProps = (props: TArgs): EditorProps => {
export const CoreEditorProps = (props: TCoreEditorProps): EditorProps => {
const { editorClassName } = props;
return {
@@ -0,0 +1,18 @@
import { EditorProps } from "@tiptap/pm/view";
// plane utils
import { cn } from "@plane/utils";
// props
import { TCoreEditorProps } from "@/props";
export const CoreReadOnlyEditorProps = (props: TCoreEditorProps): EditorProps => {
const { editorClassName } = props;
return {
attributes: {
class: cn(
"prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none",
editorClassName
),
},
};
};
+7 -4
View File
@@ -1,14 +1,17 @@
// plane imports
import { TWebhookConnectionQueryParams } from "@plane/types";
export type TFileHandler = {
assetsUploadStatus: Record<string, number>; // blockId => progress percentage
cancel: () => void;
export type TReadOnlyFileHandler = {
checkIfAssetExists: (assetId: string) => Promise<boolean>;
delete: (assetSrc: string) => Promise<void>;
getAssetDownloadSrc: (path: string) => Promise<string>;
getAssetSrc: (path: string) => Promise<string>;
restore: (assetSrc: string) => Promise<void>;
};
export type TFileHandler = TReadOnlyFileHandler & {
assetsUploadStatus: Record<string, number>; // blockId => progress percentage
cancel: () => void;
delete: (assetSrc: string) => Promise<void>;
upload: (blockId: string, file: File) => Promise<string>;
validation: {
/**
+38 -9
View File
@@ -15,6 +15,8 @@ import type {
TExtensions,
TFileHandler,
TMentionHandler,
TReadOnlyFileHandler,
TReadOnlyMentionHandler,
TRealtimeConfig,
TServerHandler,
TUserDetails,
@@ -81,12 +83,9 @@ export type TDocumentInfo = {
words: number;
};
export type EditorRefApi = {
blur: () => void;
// editor refs
export type EditorReadOnlyRefApi = {
clearEditor: (emitUpdate?: boolean) => void;
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
executeMenuItemCommand: <T extends TEditorCommands>(props: TCommandWithPropsWithItemKey<T>) => void;
getCurrentCursorPosition: () => number | undefined;
getDocument: () => {
binary: Uint8Array | null;
html: string;
@@ -95,6 +94,15 @@ export type EditorRefApi = {
getDocumentInfo: () => TDocumentInfo;
getHeadings: () => IMarking[];
getMarkDown: () => string;
scrollSummary: (marking: IMarking) => void;
setEditorValue: (content: string, emitUpdate?: boolean) => void;
};
export interface EditorRefApi extends EditorReadOnlyRefApi {
blur: () => void;
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
executeMenuItemCommand: <T extends TEditorCommands>(props: TCommandWithPropsWithItemKey<T>) => void;
getCurrentCursorPosition: () => number | undefined;
getSelectedText: () => string | null;
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
isEditorReadyToDiscard: () => boolean;
@@ -103,14 +111,12 @@ export type EditorRefApi = {
onDocumentInfoChange: (callback: (documentInfo: TDocumentInfo) => void) => () => void;
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
onStateChange: (callback: () => void) => () => void;
scrollSummary: (marking: IMarking) => void;
// eslint-disable-next-line no-undef
scrollToNodeViaDOMCoordinates: (behavior?: ScrollBehavior, position?: number) => void;
setEditorValue: (content: string, emitUpdate?: boolean) => void;
setEditorValueAtCursorPosition: (content: string) => void;
setFocusAtPosition: (position: number) => void;
setProviderDocument: (value: Uint8Array) => void;
};
}
// editor props
export interface IEditorProps {
@@ -119,7 +125,6 @@ export interface IEditorProps {
containerClassName?: string;
displayConfig?: TDisplayConfig;
disabledExtensions: TExtensions[];
editable: boolean;
editorClassName?: string;
extensions?: Extensions;
flaggedExtensions: TExtensions[];
@@ -142,11 +147,13 @@ export type ILiteTextEditorProps = IEditorProps;
export type IRichTextEditorProps = IEditorProps & {
dragDropEnabled?: boolean;
editable: boolean;
};
export interface ICollaborativeDocumentEditorProps
extends Omit<IEditorProps, "extensions" | "initialValue" | "onEnterKeyPress" | "value"> {
aiHandler?: TAIHandler;
editable: boolean;
embedHandler: TEmbedConfig;
realtimeConfig: TRealtimeConfig;
serverHandler?: TServerHandler;
@@ -155,11 +162,33 @@ export interface ICollaborativeDocumentEditorProps
export interface IDocumentEditorProps extends Omit<IEditorProps, "initialValue" | "onEnterKeyPress" | "value"> {
aiHandler?: TAIHandler;
editable: boolean;
embedHandler: TEmbedConfig;
user?: TUserDetails;
value: Content;
}
// read only editor props
export interface IReadOnlyEditorProps
extends Pick<
IEditorProps,
| "containerClassName"
| "disabledExtensions"
| "flaggedExtensions"
| "displayConfig"
| "editorClassName"
| "extensions"
| "handleEditorReady"
| "id"
| "initialValue"
> {
fileHandler: TReadOnlyFileHandler;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
mentionHandler: TReadOnlyMentionHandler;
}
export type ILiteTextReadOnlyEditorProps = IReadOnlyEditorProps;
export interface EditorEvents {
beforeCreate: never;
create: never;
+5 -1
View File
@@ -2,7 +2,7 @@ import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Content } from "@tiptap/core";
import type { EditorProps } from "@tiptap/pm/view";
// local imports
import type { ICollaborativeDocumentEditorProps, IEditorProps } from "./editor";
import type { ICollaborativeDocumentEditorProps, IEditorProps, IReadOnlyEditorProps } from "./editor";
type TCoreHookProps = Pick<
IEditorProps,
@@ -47,3 +47,7 @@ export type TCollaborativeEditorHookProps = TCoreHookProps &
| "tabIndex"
> &
Pick<ICollaborativeDocumentEditorProps, "embedHandler" | "realtimeConfig" | "serverHandler" | "user">;
export type TReadOnlyEditorHookProps = TCoreHookProps &
Pick<TEditorHookProps, "initialValue" | "provider"> &
Pick<IReadOnlyEditorProps, "fileHandler" | "forwardedRef" | "mentionHandler">;
+5 -2
View File
@@ -18,8 +18,11 @@ export type TMentionSection = {
export type TMentionComponentProps = Pick<TMentionSuggestion, "entity_identifier" | "entity_name">;
export type TMentionHandler = {
getMentionedEntityDetails?: (entity_identifier: string) => { display_name: string } | undefined;
export type TReadOnlyMentionHandler = {
renderComponent: (props: TMentionComponentProps) => React.ReactNode;
getMentionedEntityDetails?: (entity_identifier: string) => { display_name: string } | undefined;
};
export type TMentionHandler = TReadOnlyMentionHandler & {
searchCallback?: (query: string) => Promise<TMentionSection[]>;
};
+1
View File
@@ -11,6 +11,7 @@ export {
CollaborativeDocumentEditorWithRef,
DocumentEditorWithRef,
LiteTextEditorWithRef,
LiteTextReadOnlyEditorWithRef,
RichTextEditorWithRef,
} from "@/components/editors";
+1 -1
View File
@@ -27,4 +27,4 @@ export type TProjectBaseActivity<K extends string = string, V extends string = s
project: string;
};
export type TBaseActivityVerbs = "created" | "updated" | "deleted" | "added";
export type TBaseActivityVerbs = "created" | "updated" | "deleted";
@@ -28,7 +28,7 @@ export type TNotificationData = {
actor: string | undefined;
field: string | undefined;
issue_comment: string | undefined;
verb: "created" | "updated" | "deleted" | "added";
verb: "created" | "updated" | "deleted";
new_value: string | undefined;
old_value: string | undefined;
};
+5 -13
View File
@@ -194,7 +194,7 @@
"@babel/types" "^7.28.0"
debug "^4.3.1"
"@babel/types@^7.0.0", "@babel/types@^7.18.9", "@babel/types@^7.20.7", "@babel/types@^7.27.1", "@babel/types@^7.28.0":
"@babel/types@^7.0.0", "@babel/types@^7.18.9", "@babel/types@^7.20.7", "@babel/types@^7.26.10", "@babel/types@^7.27.1", "@babel/types@^7.28.0":
version "7.28.1"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.1.tgz#2aaf3c10b31ba03a77ac84f52b3912a0edef4cf9"
integrity sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==
@@ -202,14 +202,6 @@
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.27.1"
"@babel/types@^7.26.10":
version "7.28.2"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.2.tgz#da9db0856a9a88e0a13b019881d7513588cf712b"
integrity sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==
dependencies:
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.27.1"
"@blueprintjs/colors@^4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@blueprintjs/colors/-/colors-4.2.1.tgz#603b2512caee84feddcb3dbd536534c140b9a1f3"
@@ -6785,10 +6777,10 @@ linkify-it@^5.0.0:
dependencies:
uc.micro "^2.0.0"
linkifyjs@^4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.2.tgz#d97eb45419aabf97ceb4b05a7adeb7b8c8ade2b1"
integrity sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==
linkifyjs@^4.1.3:
version "4.3.1"
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.1.tgz#1f246ebf4be040002accd1f4535b6af7c7e37898"
integrity sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg==
load-tsconfig@^0.2.3:
version "0.2.5"