Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46d1d9f823 |
@@ -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__"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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", []):
|
||||
|
||||
@@ -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", []):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
+2
-7
@@ -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";
|
||||
@@ -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"
|
||||
|
||||
+1
-7
@@ -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
-2
@@ -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>
|
||||
);
|
||||
|
||||
-2
@@ -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
-2
@@ -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
-7
@@ -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>
|
||||
|
||||
+1
-7
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
|
||||
+19
-14
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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: {
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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[]>;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
CollaborativeDocumentEditorWithRef,
|
||||
DocumentEditorWithRef,
|
||||
LiteTextEditorWithRef,
|
||||
LiteTextReadOnlyEditorWithRef,
|
||||
RichTextEditorWithRef,
|
||||
} from "@/components/editors";
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user