Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b45d6d695d |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Admin UI for Plane",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"description": "API server powering Plane's backend"
|
||||
|
||||
@@ -12,7 +12,13 @@ from django.db.models import (
|
||||
OuterRef,
|
||||
Q,
|
||||
Sum,
|
||||
FloatField,
|
||||
Case,
|
||||
When,
|
||||
Value,
|
||||
)
|
||||
from django.db.models.functions import Cast, Concat
|
||||
from django.db import models
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -41,7 +47,7 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
UserFavorite,
|
||||
)
|
||||
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.utils.host import base_host
|
||||
from .base import BaseAPIView
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
@@ -195,9 +201,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
|
||||
# Current Cycle
|
||||
if cycle_view == "current":
|
||||
queryset = queryset.filter(
|
||||
start_date__lte=timezone.now(), end_date__gte=timezone.now()
|
||||
)
|
||||
queryset = queryset.filter(start_date__lte=timezone.now(), end_date__gte=timezone.now())
|
||||
data = CycleSerializer(
|
||||
queryset,
|
||||
many=True,
|
||||
@@ -254,9 +258,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
|
||||
# Incomplete Cycles
|
||||
if cycle_view == "incomplete":
|
||||
queryset = queryset.filter(
|
||||
Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True)
|
||||
)
|
||||
queryset = queryset.filter(Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True))
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(queryset),
|
||||
@@ -302,17 +304,11 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
Create a new development cycle with specified name, description, and date range.
|
||||
Supports external ID tracking for integration purposes.
|
||||
"""
|
||||
if (
|
||||
request.data.get("start_date", None) is None
|
||||
and request.data.get("end_date", None) is None
|
||||
) or (
|
||||
request.data.get("start_date", None) is not None
|
||||
and request.data.get("end_date", None) is not None
|
||||
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
|
||||
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
|
||||
):
|
||||
|
||||
serializer = CycleCreateSerializer(
|
||||
data=request.data, context={"request": request}
|
||||
)
|
||||
serializer = CycleCreateSerializer(data=request.data, context={"request": request})
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
@@ -355,9 +351,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "Both start date and end date are either required or are to be null"
|
||||
},
|
||||
{"error": "Both start date and end date are either required or are to be null"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -505,9 +499,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
|
||||
current_instance = json.dumps(
|
||||
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
|
||||
|
||||
if cycle.archived_at:
|
||||
return Response(
|
||||
@@ -520,20 +512,14 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
if "sort_order" in request_data:
|
||||
# Can only change sort order
|
||||
request_data = {
|
||||
"sort_order": request_data.get("sort_order", cycle.sort_order)
|
||||
}
|
||||
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "The Cycle has already been completed so it cannot be edited"
|
||||
},
|
||||
{"error": "The Cycle has already been completed so it cannot be edited"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = CycleUpdateSerializer(
|
||||
cycle, data=request.data, partial=True, context={"request": request}
|
||||
)
|
||||
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request})
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
@@ -541,9 +527,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
and Cycle.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
external_source=request.data.get(
|
||||
"external_source", cycle.external_source
|
||||
),
|
||||
external_source=request.data.get("external_source", cycle.external_source),
|
||||
external_id=request.data.get("external_id"),
|
||||
).exists()
|
||||
):
|
||||
@@ -600,11 +584,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
|
||||
"issue", flat=True
|
||||
)
|
||||
)
|
||||
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
|
||||
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
@@ -624,9 +604,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
|
||||
# Delete the cycle
|
||||
cycle.delete()
|
||||
# Delete the user favorite cycle
|
||||
UserFavorite.objects.filter(
|
||||
entity_type="cycle", entity_identifier=pk, project_id=project_id
|
||||
).delete()
|
||||
UserFavorite.objects.filter(entity_type="cycle", entity_identifier=pk, project_id=project_id).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -764,9 +742,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(self.get_queryset()),
|
||||
on_results=lambda cycles: CycleSerializer(
|
||||
cycles, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda cycles: CycleSerializer(cycles, many=True, fields=self.fields, expand=self.expand).data,
|
||||
)
|
||||
|
||||
@cycle_docs(
|
||||
@@ -785,9 +761,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
Move a completed cycle to archived status for historical tracking.
|
||||
Only cycles that have ended can be archived.
|
||||
"""
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
|
||||
if cycle.end_date >= timezone.now():
|
||||
return Response(
|
||||
{"error": "Only completed cycles can be archived"},
|
||||
@@ -818,9 +792,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
Restore an archived cycle to active status, making it available for regular use.
|
||||
The cycle will reappear in active cycle lists.
|
||||
"""
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
|
||||
cycle.archived_at = None
|
||||
cycle.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -883,9 +855,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
# List
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
issues = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
|
||||
)
|
||||
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -922,9 +892,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(issues),
|
||||
on_results=lambda issues: IssueSerializer(
|
||||
issues, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
|
||||
)
|
||||
|
||||
@cycle_docs(
|
||||
@@ -954,13 +922,10 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
|
||||
if not issues:
|
||||
return Response(
|
||||
{"error": "Work items are required", "code": "MISSING_WORK_ITEMS"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
{"error": "Work items are required", "code": "MISSING_WORK_ITEMS"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
cycle = Cycle.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
)
|
||||
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=cycle_id)
|
||||
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
return Response(
|
||||
@@ -972,13 +937,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
# Get all CycleWorkItems already created
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)
|
||||
)
|
||||
cycle_issues = list(CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues))
|
||||
existing_issues = [
|
||||
str(cycle_issue.issue_id)
|
||||
for cycle_issue in cycle_issues
|
||||
if str(cycle_issue.issue_id) in issues
|
||||
str(cycle_issue.issue_id) for cycle_issue in cycle_issues if str(cycle_issue.issue_id) in issues
|
||||
]
|
||||
new_issues = list(set(issues) - set(existing_issues))
|
||||
|
||||
@@ -1029,9 +990,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": serializers.serialize(
|
||||
"json", created_records
|
||||
),
|
||||
"created_cycle_issues": serializers.serialize("json", created_records),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
@@ -1107,9 +1066,7 @@ class CycleIssueDetailAPIEndpoint(BaseAPIView):
|
||||
cycle_id=cycle_id,
|
||||
issue_id=issue_id,
|
||||
)
|
||||
serializer = CycleIssueSerializer(
|
||||
cycle_issue, fields=self.fields, expand=self.expand
|
||||
)
|
||||
serializer = CycleIssueSerializer(cycle_issue, fields=self.fields, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@cycle_docs(
|
||||
@@ -1214,34 +1171,406 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
{"error": "New Cycle Id is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
old_cycle = Cycle.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
pk=cycle_id,
|
||||
|
||||
new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first()
|
||||
|
||||
old_cycle = (
|
||||
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
# transfer work items only when cycle is completed (passed the end data)
|
||||
if old_cycle.end_date is not None and old_cycle.end_date < timezone.now():
|
||||
return Response(
|
||||
{"error": "The old cycle is not completed yet"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
old_cycle = old_cycle.first()
|
||||
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
if estimate_type:
|
||||
assignee_estimate_data = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar", "avatar_url")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
# assignee distribution serialization
|
||||
assignee_estimate_distribution = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"avatar": item.get("avatar", None),
|
||||
"avatar_url": item.get("avatar_url", None),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
}
|
||||
for item in assignee_estimate_data
|
||||
]
|
||||
|
||||
label_distribution_data = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
# Call the utility function to handle the transfer
|
||||
result = transfer_cycle_issues(
|
||||
estimate_completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="points",
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
# Label distribution serialization
|
||||
label_estimate_distribution = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": (str(item["label_id"]) if item["label_id"] else None),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
}
|
||||
for item in label_distribution_data
|
||||
]
|
||||
|
||||
# Get the assignee distribution
|
||||
assignee_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
# assignee distribution serialized
|
||||
assignee_distribution_data = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"avatar": item.get("avatar", None),
|
||||
"avatar_url": item.get("avatar_url", None),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in assignee_distribution
|
||||
]
|
||||
|
||||
# Get the label distribution
|
||||
label_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
# Label distribution serilization
|
||||
label_distribution_data = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": (str(item["label_id"]) if item["label_id"] else None),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in label_distribution
|
||||
]
|
||||
|
||||
# Pass the new_cycle queryset to burndown_plot
|
||||
completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="issues",
|
||||
cycle_id=cycle_id,
|
||||
new_cycle_id=new_cycle_id,
|
||||
request=request,
|
||||
user_id=self.request.user.id,
|
||||
)
|
||||
|
||||
# Handle the result
|
||||
if result.get("success"):
|
||||
return Response({"message": "Success"}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first()
|
||||
|
||||
current_cycle.progress_snapshot = {
|
||||
"total_issues": old_cycle.total_issues,
|
||||
"completed_issues": old_cycle.completed_issues,
|
||||
"cancelled_issues": old_cycle.cancelled_issues,
|
||||
"started_issues": old_cycle.started_issues,
|
||||
"unstarted_issues": old_cycle.unstarted_issues,
|
||||
"backlog_issues": old_cycle.backlog_issues,
|
||||
"distribution": {
|
||||
"labels": label_distribution_data,
|
||||
"assignees": assignee_distribution_data,
|
||||
"completion_chart": completion_chart,
|
||||
},
|
||||
"estimate_distribution": (
|
||||
{}
|
||||
if not estimate_type
|
||||
else {
|
||||
"labels": label_estimate_distribution,
|
||||
"assignees": assignee_estimate_distribution,
|
||||
"completion_chart": estimate_completion_chart,
|
||||
}
|
||||
),
|
||||
}
|
||||
current_cycle.save(update_fields=["progress_snapshot"])
|
||||
|
||||
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
|
||||
return Response(
|
||||
{"error": result.get("error")},
|
||||
{"error": "The cycle where the issues are transferred is already completed"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
cycle_id=cycle_id,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
issue__state__group__in=["backlog", "unstarted", "started"],
|
||||
)
|
||||
|
||||
updated_cycles = []
|
||||
update_cycle_issue_activity = []
|
||||
for cycle_issue in cycle_issues:
|
||||
cycle_issue.cycle_id = new_cycle_id
|
||||
updated_cycles.append(cycle_issue)
|
||||
update_cycle_issue_activity.append(
|
||||
{
|
||||
"old_cycle_id": str(cycle_id),
|
||||
"new_cycle_id": str(new_cycle_id),
|
||||
"issue_id": str(cycle_issue.issue_id),
|
||||
}
|
||||
)
|
||||
|
||||
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.created",
|
||||
requested_data=json.dumps({"cycles_list": []}),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=None,
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": "[]",
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
return Response({"message": "Success"}, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -17,7 +17,6 @@ from .views import urlpatterns as view_urls
|
||||
from .webhook import urlpatterns as webhook_urls
|
||||
from .workspace import urlpatterns as workspace_urls
|
||||
from .timezone import urlpatterns as timezone_urls
|
||||
from .exporter import urlpatterns as exporter_urls
|
||||
|
||||
urlpatterns = [
|
||||
*analytic_urls,
|
||||
@@ -39,5 +38,4 @@ urlpatterns = [
|
||||
*api_urls,
|
||||
*webhook_urls,
|
||||
*timezone_urls,
|
||||
*exporter_urls,
|
||||
]
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from plane.app.views import ExportIssuesEndpoint
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/export-issues/",
|
||||
ExportIssuesEndpoint.as_view(),
|
||||
name="export-issues",
|
||||
),
|
||||
]
|
||||
@@ -7,6 +7,7 @@ from plane.app.views import (
|
||||
IssueLinkViewSet,
|
||||
IssueAttachmentEndpoint,
|
||||
CommentReactionViewSet,
|
||||
ExportIssuesEndpoint,
|
||||
IssueActivityEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueCommentViewSet,
|
||||
@@ -140,6 +141,12 @@ urlpatterns = [
|
||||
IssueAttachmentV2Endpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
## Export Issues
|
||||
path(
|
||||
"workspaces/<str:slug>/export-issues/",
|
||||
ExportIssuesEndpoint.as_view(),
|
||||
name="export-issues",
|
||||
),
|
||||
## End Issues
|
||||
## Issue Activity
|
||||
path(
|
||||
|
||||
@@ -51,7 +51,6 @@ from plane.db.models import (
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
|
||||
from .. import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter
|
||||
@@ -97,9 +96,7 @@ class CycleViewSet(BaseViewSet):
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_cycle__issue__assignees",
|
||||
queryset=User.objects.only(
|
||||
"avatar_asset", "first_name", "id"
|
||||
).distinct(),
|
||||
queryset=User.objects.only("avatar_asset", "first_name", "id").distinct(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
@@ -150,8 +147,7 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=current_time_in_utc)
|
||||
& Q(end_date__gte=current_time_in_utc),
|
||||
Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")),
|
||||
@@ -170,11 +166,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"issue_cycle__issue__assignees__id",
|
||||
distinct=True,
|
||||
filter=~Q(issue_cycle__issue__assignees__id__isnull=True)
|
||||
& (
|
||||
Q(
|
||||
issue_cycle__issue__issue_assignee__deleted_at__isnull=True
|
||||
)
|
||||
),
|
||||
& (Q(issue_cycle__issue__issue_assignee__deleted_at__isnull=True)),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
)
|
||||
@@ -205,9 +197,7 @@ class CycleViewSet(BaseViewSet):
|
||||
|
||||
# Current Cycle
|
||||
if cycle_view == "current":
|
||||
queryset = queryset.filter(
|
||||
start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc
|
||||
)
|
||||
queryset = queryset.filter(start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc)
|
||||
|
||||
data = queryset.values(
|
||||
# necessary fields
|
||||
@@ -274,16 +264,10 @@ class CycleViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def create(self, request, slug, project_id):
|
||||
if (
|
||||
request.data.get("start_date", None) is None
|
||||
and request.data.get("end_date", None) is None
|
||||
) or (
|
||||
request.data.get("start_date", None) is not None
|
||||
and request.data.get("end_date", None) is not None
|
||||
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
|
||||
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
|
||||
):
|
||||
serializer = CycleWriteSerializer(
|
||||
data=request.data, context={"project_id": project_id}
|
||||
)
|
||||
serializer = CycleWriteSerializer(data=request.data, context={"project_id": project_id})
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, owned_by=request.user)
|
||||
cycle = (
|
||||
@@ -323,9 +307,7 @@ class CycleViewSet(BaseViewSet):
|
||||
project_timezone = project.timezone
|
||||
|
||||
datetime_fields = ["start_date", "end_date"]
|
||||
cycle = user_timezone_converter(
|
||||
cycle, datetime_fields, project_timezone
|
||||
)
|
||||
cycle = user_timezone_converter(cycle, datetime_fields, project_timezone)
|
||||
|
||||
# Send the model activity
|
||||
model_activity.delay(
|
||||
@@ -341,17 +323,13 @@ class CycleViewSet(BaseViewSet):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "Both start date and end date are either required or are to be null"
|
||||
},
|
||||
{"error": "Both start date and end date are either required or are to be null"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
queryset = self.get_queryset().filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
queryset = self.get_queryset().filter(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
cycle = queryset.first()
|
||||
if cycle.archived_at:
|
||||
return Response(
|
||||
@@ -359,29 +337,21 @@ class CycleViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
current_instance = json.dumps(
|
||||
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
|
||||
|
||||
request_data = request.data
|
||||
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
if "sort_order" in request_data:
|
||||
# Can only change sort order for a completed cycle``
|
||||
request_data = {
|
||||
"sort_order": request_data.get("sort_order", cycle.sort_order)
|
||||
}
|
||||
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"error": "The Cycle has already been completed so it cannot be edited"
|
||||
},
|
||||
{"error": "The Cycle has already been completed so it cannot be edited"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = CycleWriteSerializer(
|
||||
cycle, data=request.data, partial=True, context={"project_id": project_id}
|
||||
)
|
||||
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True, context={"project_id": project_id})
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
cycle = queryset.values(
|
||||
@@ -481,9 +451,7 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if data is None:
|
||||
return Response(
|
||||
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
queryset = queryset.first()
|
||||
# Fetch the project timezone
|
||||
@@ -505,11 +473,7 @@ class CycleViewSet(BaseViewSet):
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
|
||||
cycle_issues = list(
|
||||
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
|
||||
"issue", flat=True
|
||||
)
|
||||
)
|
||||
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
|
||||
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
@@ -560,9 +524,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
start_date = convert_to_utc(
|
||||
date=str(start_date), project_id=project_id, is_start_date=True
|
||||
)
|
||||
start_date = convert_to_utc(date=str(start_date), project_id=project_id, is_start_date=True)
|
||||
end_date = convert_to_utc(
|
||||
date=str(end_date),
|
||||
project_id=project_id,
|
||||
@@ -635,23 +597,409 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Transfer cycle issues and create progress snapshot
|
||||
result = transfer_cycle_issues(
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
cycle_id=cycle_id,
|
||||
new_cycle_id=new_cycle_id,
|
||||
request=request,
|
||||
user_id=request.user.id,
|
||||
new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first()
|
||||
|
||||
old_cycle = (
|
||||
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
old_cycle = old_cycle.first()
|
||||
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
if estimate_type:
|
||||
assignee_estimate_data = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
# assignee distribution serialization
|
||||
assignee_estimate_distribution = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"avatar": item.get("avatar"),
|
||||
"avatar_url": item.get("avatar_url"),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
}
|
||||
for item in assignee_estimate_data
|
||||
]
|
||||
|
||||
label_distribution_data = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
estimate_completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="points",
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
# Label distribution serialization
|
||||
label_estimate_distribution = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": (str(item["label_id"]) if item["label_id"] else None),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
}
|
||||
for item in label_distribution_data
|
||||
]
|
||||
|
||||
# Get the assignee distribution
|
||||
assignee_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
# assignee distribution serialized
|
||||
assignee_distribution_data = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"avatar": item.get("avatar"),
|
||||
"avatar_url": item.get("avatar_url"),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in assignee_distribution
|
||||
]
|
||||
|
||||
# Get the label distribution
|
||||
label_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
# Handle error response
|
||||
if result.get("error"):
|
||||
# Label distribution serilization
|
||||
label_distribution_data = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": (str(item["label_id"]) if item["label_id"] else None),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in label_distribution
|
||||
]
|
||||
|
||||
# Pass the new_cycle queryset to burndown_plot
|
||||
completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="issues",
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
|
||||
current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first()
|
||||
|
||||
current_cycle.progress_snapshot = {
|
||||
"total_issues": old_cycle.total_issues,
|
||||
"completed_issues": old_cycle.completed_issues,
|
||||
"cancelled_issues": old_cycle.cancelled_issues,
|
||||
"started_issues": old_cycle.started_issues,
|
||||
"unstarted_issues": old_cycle.unstarted_issues,
|
||||
"backlog_issues": old_cycle.backlog_issues,
|
||||
"distribution": {
|
||||
"labels": label_distribution_data,
|
||||
"assignees": assignee_distribution_data,
|
||||
"completion_chart": completion_chart,
|
||||
},
|
||||
"estimate_distribution": (
|
||||
{}
|
||||
if not estimate_type
|
||||
else {
|
||||
"labels": label_estimate_distribution,
|
||||
"assignees": assignee_estimate_distribution,
|
||||
"completion_chart": estimate_completion_chart,
|
||||
}
|
||||
),
|
||||
}
|
||||
current_cycle.save(update_fields=["progress_snapshot"])
|
||||
|
||||
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
|
||||
return Response(
|
||||
{"error": result["error"]},
|
||||
{"error": "The cycle where the issues are transferred is already completed"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
cycle_id=cycle_id,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
issue__state__group__in=["backlog", "unstarted", "started"],
|
||||
)
|
||||
|
||||
updated_cycles = []
|
||||
update_cycle_issue_activity = []
|
||||
for cycle_issue in cycle_issues:
|
||||
cycle_issue.cycle_id = new_cycle_id
|
||||
updated_cycles.append(cycle_issue)
|
||||
update_cycle_issue_activity.append(
|
||||
{
|
||||
"old_cycle_id": str(cycle_id),
|
||||
"new_cycle_id": str(new_cycle_id),
|
||||
"issue_id": str(cycle_issue.issue_id),
|
||||
}
|
||||
)
|
||||
|
||||
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.created",
|
||||
requested_data=json.dumps({"cycles_list": []}),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=None,
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": "[]",
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
return Response({"message": "Success"}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@@ -666,12 +1014,8 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
cycle_properties.filters = request.data.get("filters", cycle_properties.filters)
|
||||
cycle_properties.rich_filters = request.data.get(
|
||||
"rich_filters", cycle_properties.rich_filters
|
||||
)
|
||||
cycle_properties.display_filters = request.data.get(
|
||||
"display_filters", cycle_properties.display_filters
|
||||
)
|
||||
cycle_properties.rich_filters = request.data.get("rich_filters", cycle_properties.rich_filters)
|
||||
cycle_properties.display_filters = request.data.get("display_filters", cycle_properties.display_filters)
|
||||
cycle_properties.display_properties = request.data.get(
|
||||
"display_properties", cycle_properties.display_properties
|
||||
)
|
||||
@@ -695,13 +1039,9 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
|
||||
class CycleProgressEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, cycle_id):
|
||||
cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, id=cycle_id
|
||||
).first()
|
||||
cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id).first()
|
||||
if not cycle:
|
||||
return Response(
|
||||
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
aggregate_estimates = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
@@ -747,9 +1087,7 @@ class CycleProgressEndpoint(BaseAPIView):
|
||||
output_field=FloatField(),
|
||||
)
|
||||
),
|
||||
total_estimate_points=Sum(
|
||||
"value_as_float", default=Value(0), output_field=FloatField()
|
||||
),
|
||||
total_estimate_points=Sum("value_as_float", default=Value(0), output_field=FloatField()),
|
||||
)
|
||||
)
|
||||
if cycle.progress_snapshot:
|
||||
@@ -809,22 +1147,11 @@ class CycleProgressEndpoint(BaseAPIView):
|
||||
|
||||
return Response(
|
||||
{
|
||||
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"]
|
||||
or 0,
|
||||
"unstarted_estimate_points": aggregate_estimates[
|
||||
"unstarted_estimate_point"
|
||||
]
|
||||
or 0,
|
||||
"started_estimate_points": aggregate_estimates["started_estimate_point"]
|
||||
or 0,
|
||||
"cancelled_estimate_points": aggregate_estimates[
|
||||
"cancelled_estimate_point"
|
||||
]
|
||||
or 0,
|
||||
"completed_estimate_points": aggregate_estimates[
|
||||
"completed_estimate_points"
|
||||
]
|
||||
or 0,
|
||||
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] or 0,
|
||||
"unstarted_estimate_points": aggregate_estimates["unstarted_estimate_point"] or 0,
|
||||
"started_estimate_points": aggregate_estimates["started_estimate_point"] or 0,
|
||||
"cancelled_estimate_points": aggregate_estimates["cancelled_estimate_point"] or 0,
|
||||
"completed_estimate_points": aggregate_estimates["completed_estimate_points"] or 0,
|
||||
"total_estimate_points": aggregate_estimates["total_estimate_points"],
|
||||
"backlog_issues": backlog_issues,
|
||||
"total_issues": total_issues,
|
||||
@@ -842,9 +1169,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id, cycle_id):
|
||||
analytic_type = request.GET.get("type", "issues")
|
||||
cycle = (
|
||||
Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, id=cycle_id
|
||||
)
|
||||
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
@@ -927,9 +1252,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(
|
||||
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
|
||||
)
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
@@ -964,9 +1287,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
|
||||
)
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
@@ -1068,11 +1389,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"label_id", filter=Q(archived_at__isnull=True, is_draft=False)
|
||||
)
|
||||
)
|
||||
.annotate(total_issues=Count("label_id", filter=Q(archived_at__isnull=True, is_draft=False)))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
|
||||
@@ -137,11 +137,7 @@ class PageViewSet(BaseViewSet):
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# capture the page transaction
|
||||
page_transaction.delay(
|
||||
new_description_html=request.data.get("description_html", "<p></p>"),
|
||||
old_description_html=None,
|
||||
page_id=serializer.data["id"],
|
||||
)
|
||||
page_transaction.delay(request.data, None, serializer.data["id"])
|
||||
page = self.get_queryset().get(pk=serializer.data["id"])
|
||||
serializer = PageDetailSerializer(page)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
@@ -172,8 +168,11 @@ class PageViewSet(BaseViewSet):
|
||||
# capture the page transaction
|
||||
if request.data.get("description_html"):
|
||||
page_transaction.delay(
|
||||
new_description_html=request.data.get("description_html", "<p></p>"),
|
||||
old_description_html=page_description,
|
||||
new_value=request.data,
|
||||
old_value=json.dumps(
|
||||
{"description_html": page_description},
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
page_id=page_id,
|
||||
)
|
||||
|
||||
@@ -505,11 +504,7 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
if serializer.is_valid():
|
||||
# Capture the page transaction
|
||||
if request.data.get("description_html"):
|
||||
page_transaction.delay(
|
||||
new_description_html=request.data.get("description_html", "<p></p>"),
|
||||
old_description_html=page.description_html,
|
||||
page_id=page_id,
|
||||
)
|
||||
page_transaction.delay(new_value=request.data, old_value=existing_instance, page_id=page_id)
|
||||
|
||||
# Update the page using serializer
|
||||
updated_page = serializer.save()
|
||||
@@ -555,11 +550,7 @@ class PageDuplicateEndpoint(BaseAPIView):
|
||||
updated_by_id=page.updated_by_id,
|
||||
)
|
||||
|
||||
page_transaction.delay(
|
||||
new_description_html=page.description_html,
|
||||
old_description_html=None,
|
||||
page_id=page.id,
|
||||
)
|
||||
page_transaction.delay({"description_html": page.description_html}, None, page.id)
|
||||
|
||||
# Copy the s3 objects uploaded in the page
|
||||
copy_s3_objects_of_description_and_assets.delay(
|
||||
|
||||
@@ -1154,7 +1154,10 @@ def create_comment_reaction_activity(
|
||||
.values_list("id", "comment__id")
|
||||
.first()
|
||||
)
|
||||
comment = IssueComment.objects.get(pk=comment_id, project_id=project_id)
|
||||
comment = IssueComment.objects.filter(pk=comment_id, project_id=project_id).first()
|
||||
if comment is None:
|
||||
return
|
||||
|
||||
if comment is not None and comment_reaction_id is not None and comment_id is not None:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Python imports
|
||||
import logging
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
@@ -7,134 +7,72 @@ from django.utils import timezone
|
||||
# Third-party imports
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# App imports
|
||||
from celery import shared_task
|
||||
# Module imports
|
||||
from plane.db.models import Page, PageLog
|
||||
from celery import shared_task
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
|
||||
COMPONENT_MAP = {
|
||||
"mention-component": {
|
||||
"attributes": ["id", "entity_identifier", "entity_name", "entity_type"],
|
||||
"extract": lambda m: {
|
||||
"entity_name": m.get("entity_name"),
|
||||
"entity_type": None,
|
||||
"entity_identifier": m.get("entity_identifier"),
|
||||
},
|
||||
},
|
||||
"image-component": {
|
||||
"attributes": ["id", "src"],
|
||||
"extract": lambda m: {
|
||||
"entity_name": "image",
|
||||
"entity_type": None,
|
||||
"entity_identifier": m.get("src"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
component_map = {
|
||||
**COMPONENT_MAP,
|
||||
}
|
||||
|
||||
|
||||
def extract_all_components(description_html):
|
||||
"""
|
||||
Extracts all component types from the HTML value in a single pass.
|
||||
Returns a dict mapping component_type -> list of extracted entities.
|
||||
"""
|
||||
def extract_components(value, tag):
|
||||
try:
|
||||
if not description_html:
|
||||
return {component: [] for component in component_map.keys()}
|
||||
mentions = []
|
||||
html = value.get("description_html")
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
mention_tags = soup.find_all(tag)
|
||||
|
||||
soup = BeautifulSoup(description_html, "html.parser")
|
||||
results = {}
|
||||
|
||||
for component, config in component_map.items():
|
||||
attributes = config.get("attributes", ["id"])
|
||||
component_tags = soup.find_all(component)
|
||||
|
||||
entities = []
|
||||
for tag in component_tags:
|
||||
entity = {attr: tag.get(attr) for attr in attributes}
|
||||
entities.append(entity)
|
||||
|
||||
results[component] = entities
|
||||
|
||||
return results
|
||||
for mention_tag in mention_tags:
|
||||
mention = {
|
||||
"id": mention_tag.get("id"),
|
||||
"entity_identifier": mention_tag.get("entity_identifier"),
|
||||
"entity_name": mention_tag.get("entity_name"),
|
||||
}
|
||||
mentions.append(mention)
|
||||
|
||||
return mentions
|
||||
except Exception:
|
||||
return {component: [] for component in component_map.keys()}
|
||||
|
||||
|
||||
def get_entity_details(component: str, mention: dict):
|
||||
"""
|
||||
Normalizes mention attributes into entity_name, entity_type, entity_identifier.
|
||||
"""
|
||||
config = component_map.get(component)
|
||||
if not config:
|
||||
return {"entity_name": None, "entity_type": None, "entity_identifier": None}
|
||||
return config["extract"](mention)
|
||||
return []
|
||||
|
||||
|
||||
@shared_task
|
||||
def page_transaction(new_description_html, old_description_html, page_id):
|
||||
"""
|
||||
Tracks changes in page content (mentions, embeds, etc.)
|
||||
and logs them in PageLog for audit and reference.
|
||||
"""
|
||||
def page_transaction(new_value, old_value, page_id):
|
||||
try:
|
||||
page = Page.objects.get(pk=page_id)
|
||||
new_page_mention = PageLog.objects.filter(page_id=page_id).exists()
|
||||
|
||||
has_existing_logs = PageLog.objects.filter(page_id=page_id).exists()
|
||||
|
||||
|
||||
# Extract all components in a single pass (optimized)
|
||||
old_components = extract_all_components(old_description_html)
|
||||
new_components = extract_all_components(new_description_html)
|
||||
old_value = json.loads(old_value) if old_value else {}
|
||||
|
||||
new_transactions = []
|
||||
deleted_transaction_ids = set()
|
||||
|
||||
for component in component_map.keys():
|
||||
old_entities = old_components[component]
|
||||
new_entities = new_components[component]
|
||||
# TODO - Add "issue-embed-component", "img", "todo" components
|
||||
components = ["mention-component"]
|
||||
for component in components:
|
||||
old_mentions = extract_components(old_value, component)
|
||||
new_mentions = extract_components(new_value, component)
|
||||
|
||||
old_ids = {m.get("id") for m in old_entities if m.get("id")}
|
||||
new_ids = {m.get("id") for m in new_entities if m.get("id")}
|
||||
deleted_transaction_ids.update(old_ids - new_ids)
|
||||
new_mentions_ids = {mention["id"] for mention in new_mentions}
|
||||
old_mention_ids = {mention["id"] for mention in old_mentions}
|
||||
deleted_transaction_ids.update(old_mention_ids - new_mentions_ids)
|
||||
|
||||
for mention in new_entities:
|
||||
mention_id = mention.get("id")
|
||||
if not mention_id or (mention_id in old_ids and has_existing_logs):
|
||||
continue
|
||||
|
||||
details = get_entity_details(component, mention)
|
||||
current_time = timezone.now()
|
||||
|
||||
new_transactions.append(
|
||||
PageLog(
|
||||
transaction=mention_id,
|
||||
page_id=page_id,
|
||||
entity_identifier=details["entity_identifier"],
|
||||
entity_name=details["entity_name"],
|
||||
entity_type=details["entity_type"],
|
||||
workspace_id=page.workspace_id,
|
||||
created_at=current_time,
|
||||
updated_at=current_time,
|
||||
)
|
||||
new_transactions.extend(
|
||||
PageLog(
|
||||
transaction=mention["id"],
|
||||
page_id=page_id,
|
||||
entity_identifier=mention["entity_identifier"],
|
||||
entity_name=mention["entity_name"],
|
||||
workspace_id=page.workspace_id,
|
||||
created_at=timezone.now(),
|
||||
updated_at=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
# Bulk insert and cleanup
|
||||
if new_transactions:
|
||||
PageLog.objects.bulk_create(
|
||||
new_transactions, batch_size=50, ignore_conflicts=True
|
||||
for mention in new_mentions
|
||||
if mention["id"] not in old_mention_ids or not new_page_mention
|
||||
)
|
||||
|
||||
if deleted_transaction_ids:
|
||||
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
|
||||
# Create new PageLog objects for new transactions
|
||||
PageLog.objects.bulk_create(new_transactions, batch_size=10, ignore_conflicts=True)
|
||||
|
||||
# Delete the removed transactions
|
||||
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
|
||||
except Page.DoesNotExist:
|
||||
return
|
||||
except Exception as e:
|
||||
|
||||
@@ -300,14 +300,14 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
|
||||
SESSION_COOKIE_SECURE = secure_origins
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_ENGINE = "plane.db.models.session"
|
||||
SESSION_COOKIE_AGE = int(os.environ.get("SESSION_COOKIE_AGE", 604800))
|
||||
SESSION_COOKIE_AGE = os.environ.get("SESSION_COOKIE_AGE", 604800)
|
||||
SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "session-id")
|
||||
SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None)
|
||||
SESSION_SAVE_EVERY_REQUEST = os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1"
|
||||
|
||||
# Admin Cookie
|
||||
ADMIN_SESSION_COOKIE_NAME = "admin-session-id"
|
||||
ADMIN_SESSION_COOKIE_AGE = int(os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600))
|
||||
ADMIN_SESSION_COOKIE_AGE = os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600)
|
||||
|
||||
# CSRF cookies
|
||||
CSRF_COOKIE_SECURE = secure_origins
|
||||
|
||||
@@ -4,9 +4,7 @@ import nh3
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from bs4 import BeautifulSoup
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("plane.api")
|
||||
|
||||
# Maximum allowed size for binary data (10MB)
|
||||
MAX_SIZE = 10 * 1024 * 1024
|
||||
@@ -56,9 +54,7 @@ def validate_binary_data(data):
|
||||
# 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
|
||||
):
|
||||
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
|
||||
@@ -236,9 +232,8 @@ def validate_html_content(html_content: str):
|
||||
summary = json.dumps(diff)
|
||||
except Exception:
|
||||
summary = str(diff)
|
||||
logger.warning(f"HTML sanitization removals: {summary}")
|
||||
log_exception(
|
||||
ValueError(f"HTML sanitization removals: {summary}"),
|
||||
f"HTML sanitization removals: {summary}",
|
||||
warning=True,
|
||||
)
|
||||
return True, None, clean_html
|
||||
|
||||
@@ -1,486 +0,0 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Case,
|
||||
Count,
|
||||
F,
|
||||
Q,
|
||||
Sum,
|
||||
FloatField,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db import models
|
||||
from django.db.models.functions import Cast, Concat
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
Issue,
|
||||
Project,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
def transfer_cycle_issues(
|
||||
slug,
|
||||
project_id,
|
||||
cycle_id,
|
||||
new_cycle_id,
|
||||
request,
|
||||
user_id,
|
||||
):
|
||||
"""
|
||||
Transfer incomplete issues from one cycle to another and create progress snapshot.
|
||||
|
||||
Args:
|
||||
slug: Workspace slug
|
||||
project_id: Project ID
|
||||
cycle_id: Source cycle ID
|
||||
new_cycle_id: Destination cycle ID
|
||||
request: HTTP request object
|
||||
user_id: User ID performing the transfer
|
||||
|
||||
Returns:
|
||||
dict: Response data with success or error message
|
||||
"""
|
||||
# Get the new cycle
|
||||
new_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
|
||||
).first()
|
||||
|
||||
# Check if new cycle is already completed
|
||||
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "The cycle where the issues are transferred is already completed",
|
||||
}
|
||||
|
||||
# Get the old cycle with issue counts
|
||||
old_cycle = (
|
||||
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__issue__deleted_at__isnull=True,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
old_cycle = old_cycle.first()
|
||||
|
||||
if old_cycle is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Source cycle not found",
|
||||
}
|
||||
|
||||
# Check if project uses estimates
|
||||
estimate_type = Project.objects.filter(
|
||||
workspace__slug=slug,
|
||||
pk=project_id,
|
||||
estimate__isnull=False,
|
||||
estimate__type="points",
|
||||
).exists()
|
||||
|
||||
# Initialize estimate distribution variables
|
||||
assignee_estimate_distribution = []
|
||||
label_estimate_distribution = []
|
||||
estimate_completion_chart = {}
|
||||
|
||||
if estimate_type:
|
||||
assignee_estimate_data = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset",
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True,
|
||||
then="assignees__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
# Assignee estimate distribution serialization
|
||||
assignee_estimate_distribution = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (
|
||||
str(item["assignee_id"]) if item["assignee_id"] else None
|
||||
),
|
||||
"avatar_url": item.get("avatar_url"),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
}
|
||||
for item in assignee_estimate_data
|
||||
]
|
||||
|
||||
label_distribution_data = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_estimates=Sum(
|
||||
Cast("estimate_point__value", FloatField()),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
estimate_completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="points",
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
# Label estimate distribution serialization
|
||||
label_estimate_distribution = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": (str(item["label_id"]) if item["label_id"] else None),
|
||||
"total_estimates": item["total_estimates"],
|
||||
"completed_estimates": item["completed_estimates"],
|
||||
"pending_estimates": item["pending_estimates"],
|
||||
}
|
||||
for item in label_distribution_data
|
||||
]
|
||||
|
||||
# Get the assignee distribution
|
||||
assignee_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset",
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(
|
||||
total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
# Assignee distribution serialized
|
||||
assignee_distribution_data = [
|
||||
{
|
||||
"display_name": item["display_name"],
|
||||
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
|
||||
"avatar_url": item.get("avatar_url"),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in assignee_distribution
|
||||
]
|
||||
|
||||
# Get the label distribution
|
||||
label_distribution = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(label_name=F("labels__name"))
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(
|
||||
total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"id",
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
)
|
||||
|
||||
# Label distribution serialization
|
||||
label_distribution_data = [
|
||||
{
|
||||
"label_name": item["label_name"],
|
||||
"color": item["color"],
|
||||
"label_id": (str(item["label_id"]) if item["label_id"] else None),
|
||||
"total_issues": item["total_issues"],
|
||||
"completed_issues": item["completed_issues"],
|
||||
"pending_issues": item["pending_issues"],
|
||||
}
|
||||
for item in label_distribution
|
||||
]
|
||||
|
||||
# Generate completion chart
|
||||
completion_chart = burndown_plot(
|
||||
queryset=old_cycle,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
plot_type="issues",
|
||||
cycle_id=cycle_id,
|
||||
)
|
||||
|
||||
# Get the current cycle and save progress snapshot
|
||||
current_cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=cycle_id
|
||||
).first()
|
||||
|
||||
current_cycle.progress_snapshot = {
|
||||
"total_issues": old_cycle.total_issues,
|
||||
"completed_issues": old_cycle.completed_issues,
|
||||
"cancelled_issues": old_cycle.cancelled_issues,
|
||||
"started_issues": old_cycle.started_issues,
|
||||
"unstarted_issues": old_cycle.unstarted_issues,
|
||||
"backlog_issues": old_cycle.backlog_issues,
|
||||
"distribution": {
|
||||
"labels": label_distribution_data,
|
||||
"assignees": assignee_distribution_data,
|
||||
"completion_chart": completion_chart,
|
||||
},
|
||||
"estimate_distribution": (
|
||||
{}
|
||||
if not estimate_type
|
||||
else {
|
||||
"labels": label_estimate_distribution,
|
||||
"assignees": assignee_estimate_distribution,
|
||||
"completion_chart": estimate_completion_chart,
|
||||
}
|
||||
),
|
||||
}
|
||||
current_cycle.save(update_fields=["progress_snapshot"])
|
||||
|
||||
# Get issues to transfer (only incomplete issues)
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
cycle_id=cycle_id,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
issue__archived_at__isnull=True,
|
||||
issue__is_draft=False,
|
||||
issue__state__group__in=["backlog", "unstarted", "started"],
|
||||
)
|
||||
|
||||
updated_cycles = []
|
||||
update_cycle_issue_activity = []
|
||||
for cycle_issue in cycle_issues:
|
||||
cycle_issue.cycle_id = new_cycle_id
|
||||
updated_cycles.append(cycle_issue)
|
||||
update_cycle_issue_activity.append(
|
||||
{
|
||||
"old_cycle_id": str(cycle_id),
|
||||
"new_cycle_id": str(new_cycle_id),
|
||||
"issue_id": str(cycle_issue.issue_id),
|
||||
}
|
||||
)
|
||||
|
||||
# Bulk update cycle issues
|
||||
cycle_issues = CycleIssue.objects.bulk_update(
|
||||
updated_cycles, ["cycle_id"], batch_size=100
|
||||
)
|
||||
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.created",
|
||||
requested_data=json.dumps({"cycles_list": []}),
|
||||
actor_id=str(user_id),
|
||||
issue_id=None,
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": update_cycle_issue_activity,
|
||||
"created_cycle_issues": [],
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
return {"success": True}
|
||||
@@ -173,6 +173,7 @@ class IssueExportSchema(ExportSchema):
|
||||
return self._format_date(last_cycle.cycle.start_date)
|
||||
return ""
|
||||
|
||||
|
||||
def prepare_cycle_end_date(self, i):
|
||||
cycles_dict = self.context.get("cycles_dict") or {}
|
||||
last_cycle = cycles_dict.get(i.id)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "A realtime collaborative server powers Plane's rich text editor",
|
||||
"main": "./dist/start.js",
|
||||
@@ -20,11 +20,11 @@
|
||||
"author": "Plane Software Inc.",
|
||||
"dependencies": {
|
||||
"@dotenvx/dotenvx": "^1.49.0",
|
||||
"@hocuspocus/extension-database": "2.15.2",
|
||||
"@hocuspocus/extension-logger": "2.15.2",
|
||||
"@hocuspocus/extension-redis": "2.15.2",
|
||||
"@hocuspocus/server": "2.15.2",
|
||||
"@hocuspocus/transformer": "2.15.2",
|
||||
"@hocuspocus/extension-database": "3.2.5",
|
||||
"@hocuspocus/extension-logger": "3.2.5",
|
||||
"@hocuspocus/extension-redis": "3.2.5",
|
||||
"@hocuspocus/server": "3.2.5",
|
||||
"@hocuspocus/transformer": "3.2.5",
|
||||
"@plane/decorators": "workspace:*",
|
||||
"@plane/editor": "workspace:*",
|
||||
"@plane/logger": "workspace:*",
|
||||
|
||||
@@ -6,17 +6,22 @@ import {
|
||||
} from "@plane/editor";
|
||||
// logger
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
// services
|
||||
// lib
|
||||
import { getPageService } from "@/services/page/handler";
|
||||
// type
|
||||
import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types";
|
||||
import { ForceCloseReason, CloseCode } from "@/types/admin-commands";
|
||||
import { broadcastError } from "@/utils/broadcast-error";
|
||||
// force close utility
|
||||
import { forceCloseDocumentAcrossServers } from "./force-close-handler";
|
||||
|
||||
const fetchDocument = async ({ context, documentName: pageId, instance }: FetchPayloadWithContext) => {
|
||||
const normalizeToError = (error: unknown, fallbackMessage: string) => {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const message = typeof error === "string" && error.trim().length > 0 ? error : fallbackMessage;
|
||||
|
||||
return new Error(message);
|
||||
};
|
||||
|
||||
const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWithContext) => {
|
||||
try {
|
||||
const service = getPageService(context.documentType, context);
|
||||
// fetch details
|
||||
@@ -33,22 +38,12 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP
|
||||
// return binary data
|
||||
return binaryData;
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, { context: { pageId } });
|
||||
logger.error("Error in fetching document", appError);
|
||||
|
||||
// Broadcast error to frontend for user document types
|
||||
await broadcastError(instance, pageId, "Unable to load the page. Please try refreshing.", "fetch", context);
|
||||
|
||||
throw appError;
|
||||
logger.error("DATABASE_EXTENSION: Error in fetching document", error);
|
||||
throw normalizeToError(error, `Failed to fetch document: ${pageId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const storeDocument = async ({
|
||||
context,
|
||||
state: pageBinaryData,
|
||||
documentName: pageId,
|
||||
instance,
|
||||
}: StorePayloadWithContext) => {
|
||||
const storeDocument = async ({ context, state: pageBinaryData, documentName: pageId }: StorePayloadWithContext) => {
|
||||
try {
|
||||
const service = getPageService(context.documentType, context);
|
||||
// convert binary data to all formats
|
||||
@@ -62,46 +57,8 @@ const storeDocument = async ({
|
||||
};
|
||||
await service.updateDescriptionBinary(pageId, payload);
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, { context: { pageId } });
|
||||
logger.error("Error in updating document:", appError);
|
||||
|
||||
// Check error types
|
||||
const isContentTooLarge = appError.statusCode === 413;
|
||||
|
||||
// Determine if we should disconnect and unload
|
||||
const shouldDisconnect = isContentTooLarge;
|
||||
|
||||
// Determine error message and code
|
||||
let errorMessage: string;
|
||||
let errorCode: "content_too_large" | "page_locked" | "page_archived" | undefined;
|
||||
|
||||
if (isContentTooLarge) {
|
||||
errorMessage = "Document is too large to save. Please reduce the content size.";
|
||||
errorCode = "content_too_large";
|
||||
} else {
|
||||
errorMessage = "Unable to save the page. Please try again.";
|
||||
}
|
||||
|
||||
// Broadcast error to frontend for user document types
|
||||
await broadcastError(instance, pageId, errorMessage, "store", context, errorCode, shouldDisconnect);
|
||||
|
||||
// If we should disconnect, close connections and unload document
|
||||
if (shouldDisconnect) {
|
||||
// Map error code to ForceCloseReason with proper types
|
||||
const reason =
|
||||
errorCode === "content_too_large" ? ForceCloseReason.DOCUMENT_TOO_LARGE : ForceCloseReason.CRITICAL_ERROR;
|
||||
|
||||
const closeCode = errorCode === "content_too_large" ? CloseCode.DOCUMENT_TOO_LARGE : CloseCode.FORCE_CLOSE;
|
||||
|
||||
// force close connections and unload document
|
||||
await forceCloseDocumentAcrossServers(instance, pageId, reason, closeCode);
|
||||
|
||||
// Don't throw after force close - document is already unloaded
|
||||
// Throwing would cause hocuspocus's finally block to access the null document
|
||||
return;
|
||||
}
|
||||
|
||||
throw appError;
|
||||
logger.error("DATABASE_EXTENSION: Error in updating document:", error);
|
||||
throw normalizeToError(error, `Failed to update document: ${pageId}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import type { Connection, Extension, Hocuspocus, onConfigurePayload } from "@hocuspocus/server";
|
||||
import { logger } from "@plane/logger";
|
||||
import { Redis } from "@/extensions/redis";
|
||||
import {
|
||||
AdminCommand,
|
||||
CloseCode,
|
||||
ForceCloseReason,
|
||||
getForceCloseMessage,
|
||||
isForceCloseCommand,
|
||||
type ClientForceCloseMessage,
|
||||
type ForceCloseCommandData,
|
||||
} from "@/types/admin-commands";
|
||||
|
||||
/**
|
||||
* Extension to handle force close commands from other servers via Redis admin channel
|
||||
*/
|
||||
export class ForceCloseHandler implements Extension {
|
||||
name = "ForceCloseHandler";
|
||||
priority = 999;
|
||||
|
||||
async onConfigure({ instance }: onConfigurePayload) {
|
||||
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
|
||||
|
||||
if (!redisExt) {
|
||||
logger.warn("[FORCE_CLOSE_HANDLER] Redis extension not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Register handler for force_close admin command
|
||||
redisExt.onAdminCommand<ForceCloseCommandData>(AdminCommand.FORCE_CLOSE, async (data) => {
|
||||
// Type guard for safety
|
||||
if (!isForceCloseCommand(data)) {
|
||||
logger.error("[FORCE_CLOSE_HANDLER] Received invalid force close command");
|
||||
return;
|
||||
}
|
||||
|
||||
const { docId, reason, code } = data;
|
||||
|
||||
const document = instance.documents.get(docId);
|
||||
if (!document) {
|
||||
// Not our document, ignore
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionCount = document.getConnectionsCount();
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Sending force close message to ${connectionCount} clients...`);
|
||||
|
||||
// Step 1: Send force close message to ALL clients first
|
||||
const forceCloseMessage: ClientForceCloseMessage = {
|
||||
type: "force_close",
|
||||
reason,
|
||||
code,
|
||||
message: getForceCloseMessage(reason),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let messageSent = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.sendStateless(JSON.stringify(forceCloseMessage));
|
||||
messageSent++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE_HANDLER] Failed to send message:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Sent force close message to ${messageSent}/${connectionCount} clients`);
|
||||
|
||||
// Wait a moment for messages to be delivered
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Step 2: Close connections
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Closing ${connectionCount} connections...`);
|
||||
|
||||
let closed = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.close({ code, reason });
|
||||
closed++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE_HANDLER] Failed to close connection:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE_HANDLER] Closed ${closed}/${connectionCount} connections for ${docId}`);
|
||||
});
|
||||
|
||||
logger.info("[FORCE_CLOSE_HANDLER] Registered with Redis extension");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force close all connections to a document across all servers and unload it from memory.
|
||||
* Used for critical errors or admin operations.
|
||||
*
|
||||
* @param instance - The Hocuspocus server instance
|
||||
* @param pageId - The document ID to force close
|
||||
* @param reason - The reason for force closing
|
||||
* @param code - Optional WebSocket close code (defaults to FORCE_CLOSE)
|
||||
* @returns Promise that resolves when document is closed and unloaded
|
||||
* @throws Error if document not found in memory
|
||||
*/
|
||||
export const forceCloseDocumentAcrossServers = async (
|
||||
instance: Hocuspocus,
|
||||
pageId: string,
|
||||
reason: ForceCloseReason,
|
||||
code: CloseCode = CloseCode.FORCE_CLOSE
|
||||
): Promise<void> => {
|
||||
// STEP 1: VERIFY DOCUMENT EXISTS
|
||||
const document = instance.documents.get(pageId);
|
||||
|
||||
if (!document) {
|
||||
logger.info(`[FORCE_CLOSE] Document ${pageId} already unloaded - no action needed`);
|
||||
return; // Document already cleaned up, nothing to do
|
||||
}
|
||||
|
||||
const connectionsBefore = document.getConnectionsCount();
|
||||
logger.info(`[FORCE_CLOSE] Sending force close message to ${connectionsBefore} local clients...`);
|
||||
|
||||
const forceCloseMessage: ClientForceCloseMessage = {
|
||||
type: "force_close",
|
||||
reason,
|
||||
code,
|
||||
message: getForceCloseMessage(reason),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let messageSentCount = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.sendStateless(JSON.stringify(forceCloseMessage));
|
||||
messageSentCount++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE] Failed to send message to client:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE] Sent force close message to ${messageSentCount}/${connectionsBefore} clients`);
|
||||
|
||||
// Wait a moment for messages to be delivered
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// STEP 3: CLOSE LOCAL CONNECTIONS
|
||||
logger.info(`[FORCE_CLOSE] Closing ${connectionsBefore} local connections...`);
|
||||
|
||||
let closedCount = 0;
|
||||
document.connections.forEach(({ connection }: { connection: Connection }) => {
|
||||
try {
|
||||
connection.close({ code, reason });
|
||||
closedCount++;
|
||||
} catch (error) {
|
||||
logger.error("[FORCE_CLOSE] Failed to close local connection:", error);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`[FORCE_CLOSE] Closed ${closedCount}/${connectionsBefore} local connections`);
|
||||
|
||||
// STEP 4: BROADCAST TO OTHER SERVERS
|
||||
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
|
||||
|
||||
if (redisExt) {
|
||||
const commandData: ForceCloseCommandData = {
|
||||
command: AdminCommand.FORCE_CLOSE,
|
||||
docId: pageId,
|
||||
reason,
|
||||
code,
|
||||
originServer: instance.configuration.name || "unknown",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const receivers = await redisExt.publishAdminCommand(commandData);
|
||||
logger.info(`[FORCE_CLOSE] Notified ${receivers} other server(s)`);
|
||||
} else {
|
||||
logger.warn("[FORCE_CLOSE] Redis extension not found, cannot notify other servers");
|
||||
}
|
||||
|
||||
// STEP 5: WAIT FOR OTHER SERVERS
|
||||
const waitTime = 800;
|
||||
logger.info(`[FORCE_CLOSE] Waiting ${waitTime}ms for other servers to close connections...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
||||
|
||||
// STEP 6: UNLOAD DOCUMENT after closing all the connections
|
||||
logger.info(`[FORCE_CLOSE] Unloading document from memory...`);
|
||||
|
||||
try {
|
||||
await instance.unloadDocument(document);
|
||||
logger.info(`[FORCE_CLOSE] Document unloaded successfully ✅`);
|
||||
} catch (unloadError: unknown) {
|
||||
logger.error("[FORCE_CLOSE] UNLOAD FAILED:", unloadError);
|
||||
logger.error(` Error: ${unloadError instanceof Error ? unloadError.message : "unknown"}`);
|
||||
}
|
||||
|
||||
// STEP 7: VERIFY UNLOAD
|
||||
const documentAfterUnload = instance.documents.get(pageId);
|
||||
|
||||
if (documentAfterUnload) {
|
||||
logger.error(
|
||||
`❌ [FORCE_CLOSE] Document still in memory!, Document ID: ${pageId}, Connections: ${documentAfterUnload.getConnectionsCount()}`
|
||||
);
|
||||
} else {
|
||||
logger.info(`✅ [FORCE_CLOSE] COMPLETE, Document: ${pageId}, Status: Successfully closed and unloaded`);
|
||||
}
|
||||
};
|
||||
@@ -1,134 +1,30 @@
|
||||
import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis";
|
||||
import { OutgoingMessage, type onConfigurePayload } from "@hocuspocus/server";
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { OutgoingMessage } from "@hocuspocus/server";
|
||||
// redis
|
||||
import { redisManager } from "@/redis";
|
||||
import { AdminCommand } from "@/types/admin-commands";
|
||||
import type { AdminCommandData, AdminCommandHandler } from "@/types/admin-commands";
|
||||
|
||||
const getRedisClient = () => {
|
||||
const redisClient = redisManager.getClient();
|
||||
if (!redisClient) {
|
||||
throw new AppError("Redis client not initialized");
|
||||
throw new Error("Redis client not initialized");
|
||||
}
|
||||
return redisClient;
|
||||
};
|
||||
|
||||
export class Redis extends HocuspocusRedis {
|
||||
private adminHandlers = new Map<AdminCommand, AdminCommandHandler>();
|
||||
private readonly ADMIN_CHANNEL = "hocuspocus:admin";
|
||||
|
||||
constructor() {
|
||||
super({ redis: getRedisClient() });
|
||||
}
|
||||
|
||||
async onConfigure(payload: onConfigurePayload) {
|
||||
await super.onConfigure(payload);
|
||||
|
||||
// Subscribe to admin channel
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.sub.subscribe(this.ADMIN_CHANNEL, (error: Error) => {
|
||||
if (error) {
|
||||
logger.error(`[Redis] Failed to subscribe to admin channel:`, error);
|
||||
reject(error);
|
||||
} else {
|
||||
logger.info(`[Redis] Subscribed to admin channel: ${this.ADMIN_CHANNEL}`);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for admin messages
|
||||
this.sub.on("message", this.handleAdminMessage);
|
||||
logger.info(`[Redis] Attached admin message listener`);
|
||||
}
|
||||
|
||||
private handleAdminMessage = async (channel: string, message: string) => {
|
||||
if (channel !== this.ADMIN_CHANNEL) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(message) as AdminCommandData;
|
||||
|
||||
// Validate command
|
||||
if (!data.command || !Object.values(AdminCommand).includes(data.command as AdminCommand)) {
|
||||
logger.warn(`[Redis] Invalid admin command received: ${data.command}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = this.adminHandlers.get(data.command);
|
||||
|
||||
if (handler) {
|
||||
await handler(data);
|
||||
} else {
|
||||
logger.warn(`[Redis] No handler registered for admin command: ${data.command}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[Redis] Error handling admin message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register handler for an admin command
|
||||
*/
|
||||
public onAdminCommand<T extends AdminCommandData = AdminCommandData>(
|
||||
command: AdminCommand,
|
||||
handler: AdminCommandHandler<T>
|
||||
) {
|
||||
this.adminHandlers.set(command, handler as AdminCommandHandler);
|
||||
logger.info(`[Redis] Registered admin command: ${command}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish admin command to global channel
|
||||
*/
|
||||
public async publishAdminCommand<T extends AdminCommandData>(data: T): Promise<number> {
|
||||
// Validate command data
|
||||
if (!data.command || !Object.values(AdminCommand).includes(data.command)) {
|
||||
throw new AppError(`Invalid admin command: ${data.command}`);
|
||||
}
|
||||
|
||||
const message = JSON.stringify(data);
|
||||
const receivers = await this.pub.publish(this.ADMIN_CHANNEL, message);
|
||||
|
||||
logger.info(`[Redis] Published "${data.command}" command, received by ${receivers} server(s)`);
|
||||
return receivers;
|
||||
}
|
||||
|
||||
async onDestroy() {
|
||||
// Unsubscribe from admin channel
|
||||
await new Promise<void>((resolve) => {
|
||||
this.sub.unsubscribe(this.ADMIN_CHANNEL, (error: Error) => {
|
||||
if (error) {
|
||||
logger.error(`[Redis] Error unsubscribing from admin channel:`, error);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Remove the message listener to prevent memory leaks
|
||||
this.sub.removeListener("message", this.handleAdminMessage);
|
||||
logger.info(`[Redis] Removed admin message listener`);
|
||||
|
||||
await super.onDestroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a message to a document across all servers via Redis.
|
||||
* Uses empty identifier so ALL servers process the message.
|
||||
*/
|
||||
public async broadcastToDocument(documentName: string, payload: unknown): Promise<number> {
|
||||
public broadcastToDocument(documentName: string, payload: any): Promise<number> {
|
||||
const stringPayload = typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||
|
||||
const message = new OutgoingMessage(documentName).writeBroadcastStateless(stringPayload);
|
||||
|
||||
const emptyPrefix = Buffer.concat([Buffer.from([0])]);
|
||||
const channel = this["pubKey"](documentName);
|
||||
const encodedMessage = Buffer.concat([emptyPrefix, Buffer.from(message.toUint8Array())]);
|
||||
|
||||
const result = await this.pub.publishBuffer(channel, encodedMessage);
|
||||
|
||||
logger.info(`REDIS_EXTENSION: Published to ${documentName}, ${result} subscribers`);
|
||||
|
||||
return result;
|
||||
return this.pub.publish(
|
||||
// we're accessing the private method of the hocuspocus redis extension
|
||||
this["pubKey"](documentName),
|
||||
// we're accessing the private method of the hocuspocus redis extension to encode the message
|
||||
this["encodeMessage"](message.toUint8Array())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { logger } from "@plane/logger";
|
||||
import { env } from "@/env";
|
||||
|
||||
/**
|
||||
* Express middleware to verify secret key authentication for protected endpoints
|
||||
*
|
||||
* Checks for secret key in headers:
|
||||
* - x-admin-secret-key (preferred for admin endpoints)
|
||||
* - live-server-secret-key (for backward compatibility)
|
||||
*
|
||||
* @param req - Express request object
|
||||
* @param res - Express response object
|
||||
* @param next - Express next function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Middleware } from "@plane/decorators";
|
||||
* import { requireSecretKey } from "@/lib/auth-middleware";
|
||||
*
|
||||
* @Get("/protected")
|
||||
* @Middleware(requireSecretKey)
|
||||
* async protectedEndpoint(req: Request, res: Response) {
|
||||
* // This will only execute if secret key is valid
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
// TODO - Move to hmac
|
||||
export const requireSecretKey = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const secretKey = req.headers["live-server-secret-key"];
|
||||
|
||||
if (!secretKey || secretKey !== env.LIVE_SERVER_SECRET_KEY) {
|
||||
logger.warn(`
|
||||
⚠️ [AUTH] Unauthorized access attempt
|
||||
Endpoint: ${req.path}
|
||||
Method: ${req.method}
|
||||
IP: ${req.ip}
|
||||
User-Agent: ${req.headers["user-agent"]}
|
||||
`);
|
||||
|
||||
res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
status: 401,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Secret key is valid, proceed to the route handler
|
||||
next();
|
||||
};
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { IncomingHttpHeaders } from "http";
|
||||
import type { TUserDetails } from "@plane/editor";
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
// types
|
||||
@@ -36,10 +35,8 @@ export const onAuthenticate = async ({
|
||||
userId = parsedToken.id;
|
||||
cookie = parsedToken.cookie;
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "onAuthenticate" },
|
||||
});
|
||||
logger.error("Token parsing failed, using request headers", appError);
|
||||
// If token parsing fails, fallback to request headers
|
||||
logger.error("AUTH: Token parsing failed, using request headers:", error);
|
||||
} finally {
|
||||
// If cookie is still not found, fallback to request headers
|
||||
if (!cookie) {
|
||||
@@ -48,9 +45,7 @@ export const onAuthenticate = async ({
|
||||
}
|
||||
|
||||
if (!cookie || !userId) {
|
||||
const appError = new AppError("Credentials not provided", { code: "AUTH_MISSING_CREDENTIALS" });
|
||||
logger.error("Credentials not provided", appError);
|
||||
throw appError;
|
||||
throw new Error("Credentials not provided");
|
||||
}
|
||||
|
||||
// set cookie in context, so it can be used throughout the ws connection
|
||||
@@ -72,7 +67,7 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string;
|
||||
const userService = new UserService();
|
||||
const user = await userService.currentUser(cookie);
|
||||
if (user.id !== userId) {
|
||||
throw new AppError("Authentication unsuccessful: User ID mismatch", { code: "AUTH_USER_MISMATCH" });
|
||||
throw new Error("Authentication unsuccessful!");
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -82,10 +77,7 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string;
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "handleAuthentication" },
|
||||
});
|
||||
logger.error("Authentication failed", appError);
|
||||
throw new AppError("Authentication unsuccessful", { code: appError.code });
|
||||
logger.error("AUTH: Token parsing failed, using request headers:", error);
|
||||
throw Error("Authentication unsuccessful!");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
/**
|
||||
* Application error class that sanitizes and standardizes errors across the app.
|
||||
* Extracts only essential information from AxiosError to prevent massive log bloat
|
||||
* and sensitive data leaks (cookies, tokens, etc).
|
||||
*
|
||||
* Usage:
|
||||
* new AppError("Simple error message")
|
||||
* new AppError("Custom error", { code: "MY_CODE", statusCode: 400 })
|
||||
* new AppError(axiosError) // Auto-extracts essential info
|
||||
* new AppError(anyError) // Works with any error type
|
||||
*/
|
||||
export class AppError extends Error {
|
||||
statusCode?: number;
|
||||
method?: string;
|
||||
url?: string;
|
||||
code?: string;
|
||||
context?: Record<string, any>;
|
||||
|
||||
constructor(messageOrError: string | unknown, data?: Partial<Omit<AppError, "name" | "message">>) {
|
||||
// Handle error objects - extract essential info
|
||||
const error = messageOrError;
|
||||
|
||||
// Already AppError - return immediately for performance (no need to re-process)
|
||||
if (error instanceof AppError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Handle string message (simple case like regular Error)
|
||||
if (typeof messageOrError === "string") {
|
||||
super(messageOrError);
|
||||
this.name = "AppError";
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// AxiosError - extract ONLY essential info (no config, no headers, no cookies)
|
||||
if (error && typeof error === "object" && "isAxiosError" in error) {
|
||||
const axiosError = error as AxiosError;
|
||||
const responseData = axiosError.response?.data as any;
|
||||
super(responseData?.message || axiosError.message);
|
||||
this.name = "AppError";
|
||||
this.statusCode = axiosError.response?.status;
|
||||
this.method = axiosError.config?.method?.toUpperCase();
|
||||
this.url = axiosError.config?.url;
|
||||
this.code = axiosError.code;
|
||||
return;
|
||||
}
|
||||
|
||||
// DOMException (AbortError from cancelled requests)
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
super(error.message);
|
||||
this.name = "AppError";
|
||||
this.code = "ABORT_ERROR";
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard Error objects
|
||||
if (error instanceof Error) {
|
||||
super(error.message);
|
||||
this.name = "AppError";
|
||||
this.code = error.name;
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown error types - safe fallback
|
||||
super("Unknown error occurred");
|
||||
this.name = "AppError";
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { DocumentCollaborativeEvents, type TDocumentEventsServer } from "@plane/
|
||||
* @param param0
|
||||
*/
|
||||
export const onStateless = async ({ payload, document }: onStatelessPayload) => {
|
||||
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer]?.client;
|
||||
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
|
||||
if (response) {
|
||||
document.broadcastStateless(response);
|
||||
}
|
||||
|
||||
+7
-10
@@ -59,20 +59,13 @@ export class RedisManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Configuration optimized for BOTH regular operations AND pub/sub
|
||||
// HocuspocusRedis uses .duplicate() which inherits these settings
|
||||
this.redisClient = new Redis(redisUrl, {
|
||||
lazyConnect: false, // Connect immediately for reliability (duplicates inherit this)
|
||||
lazyConnect: true,
|
||||
keepAlive: 30000,
|
||||
connectTimeout: 10000,
|
||||
commandTimeout: 5000,
|
||||
// enableOfflineQueue: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
enableOfflineQueue: true, // Keep commands queued during reconnection
|
||||
retryStrategy: (times: number) => {
|
||||
// Exponential backoff with max 2 seconds
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
logger.info(`REDIS_MANAGER: Reconnection attempt ${times}, delay: ${delay}ms`);
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
|
||||
// Set up event listeners
|
||||
@@ -101,6 +94,10 @@ export class RedisManager {
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
// Connect to Redis
|
||||
await this.redisClient.connect();
|
||||
|
||||
// Test the connection
|
||||
await this.redisClient.ping();
|
||||
logger.info("REDIS_MANAGER: Redis connection test successful");
|
||||
} catch (error) {
|
||||
|
||||
@@ -39,6 +39,7 @@ export class Server {
|
||||
const manager = HocusPocusServerManager.getInstance();
|
||||
this.hocuspocusServer = await manager.initialize();
|
||||
logger.info("SERVER: HocusPocus setup completed");
|
||||
|
||||
this.setupRoutes(this.hocuspocusServer);
|
||||
this.setupNotFoundHandler();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { logger } from "@plane/logger";
|
||||
import { env } from "@/env";
|
||||
import { AppError } from "@/lib/errors";
|
||||
|
||||
export abstract class APIService {
|
||||
protected baseURL: string;
|
||||
@@ -21,7 +21,8 @@ export abstract class APIService {
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
return Promise.reject(new AppError(error));
|
||||
logger.error("AXIOS_ERROR:", error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { logger } from "@plane/logger";
|
||||
import { TPage } from "@plane/types";
|
||||
// services
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
export type TPageDescriptionPayload = {
|
||||
@@ -23,11 +21,7 @@ export abstract class PageCoreService extends APIService {
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "fetchDetails", pageId },
|
||||
});
|
||||
logger.error("Failed to fetch page details", appError);
|
||||
throw appError;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,11 +35,7 @@ export abstract class PageCoreService extends APIService {
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "fetchDescriptionBinary", pageId },
|
||||
});
|
||||
logger.error("Failed to fetch page description binary", appError);
|
||||
throw appError;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,7 +50,7 @@ export abstract class PageCoreService extends APIService {
|
||||
|
||||
// Early abort check
|
||||
if (abortSignal?.aborted) {
|
||||
throw new AppError(new DOMException("Aborted", "AbortError"));
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
|
||||
// Create an abort listener that will reject the pending promise
|
||||
@@ -68,7 +58,7 @@ export abstract class PageCoreService extends APIService {
|
||||
const abortPromise = new Promise((_, reject) => {
|
||||
if (abortSignal) {
|
||||
abortListener = () => {
|
||||
reject(new AppError(new DOMException("Aborted", "AbortError")));
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
abortSignal.addEventListener("abort", abortListener);
|
||||
}
|
||||
@@ -76,22 +66,16 @@ export abstract class PageCoreService extends APIService {
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
this.patch(`${this.basePath}/pages/${pageId}/`, data, {
|
||||
this.patch(`${this.basePath}/pages/${pageId}`, data, {
|
||||
headers: this.getHeader(),
|
||||
signal: abortSignal,
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "updatePageProperties", pageId },
|
||||
});
|
||||
|
||||
if (appError.code === "ABORT_ERROR") {
|
||||
throw appError;
|
||||
if (error.name === "AbortError") {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
|
||||
logger.error("Failed to update page properties", appError);
|
||||
throw appError;
|
||||
throw error;
|
||||
}),
|
||||
abortPromise,
|
||||
]);
|
||||
@@ -109,11 +93,7 @@ export abstract class PageCoreService extends APIService {
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "updateDescriptionBinary", pageId },
|
||||
});
|
||||
logger.error("Failed to update page description binary", appError);
|
||||
throw appError;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AppError } from "@/lib/errors";
|
||||
import type { HocusPocusServerContext, TDocumentTypes } from "@/types";
|
||||
// services
|
||||
import { ProjectPageService } from "./project-page.service";
|
||||
@@ -12,5 +11,5 @@ export const getPageService = (documentType: TDocumentTypes, context: HocusPocus
|
||||
});
|
||||
}
|
||||
|
||||
throw new AppError(`Invalid document type ${documentType} provided.`);
|
||||
throw new Error(`Invalid document type ${documentType} provided.`);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { PageService } from "./extended.service";
|
||||
|
||||
interface ProjectPageServiceParams {
|
||||
@@ -14,9 +13,9 @@ export class ProjectPageService extends PageService {
|
||||
constructor(params: ProjectPageServiceParams) {
|
||||
super();
|
||||
const { workspaceSlug, projectId } = params;
|
||||
if (!workspaceSlug || !projectId) throw new AppError("Missing required fields.");
|
||||
if (!workspaceSlug || !projectId) throw new Error("Missing required fields.");
|
||||
// validate cookie
|
||||
if (!params.cookie) throw new AppError("Cookie is required.");
|
||||
if (!params.cookie) throw new Error("Cookie is required.");
|
||||
// set cookie
|
||||
this.setHeader("Cookie", params.cookie);
|
||||
// set base path
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// types
|
||||
import { logger } from "@plane/logger";
|
||||
import type { IUser } from "@plane/types";
|
||||
// services
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export class UserService extends APIService {
|
||||
@@ -24,11 +22,7 @@ export class UserService extends APIService {
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "currentUser" },
|
||||
});
|
||||
logger.error("Failed to fetch current user", appError);
|
||||
throw appError;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+19
-33
@@ -1,9 +1,8 @@
|
||||
// eslint-disable-next-line import/order
|
||||
import { setupSentry } from "./instrument";
|
||||
setupSentry();
|
||||
|
||||
// eslint-disable-next-line import/order
|
||||
import { logger } from "@plane/logger";
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { Server } from "./server";
|
||||
|
||||
let server: Server;
|
||||
@@ -21,41 +20,28 @@ async function startServer() {
|
||||
|
||||
startServer();
|
||||
|
||||
// Handle process signals
|
||||
process.on("SIGTERM", async () => {
|
||||
logger.info("Received SIGTERM signal. Initiating graceful shutdown...");
|
||||
// Graceful shutdown on unhandled rejection
|
||||
process.on("unhandledRejection", async (err: Error) => {
|
||||
logger.error(`UNHANDLED REJECTION!`, err);
|
||||
try {
|
||||
if (server) {
|
||||
await server.destroy();
|
||||
}
|
||||
logger.info("Server shut down gracefully");
|
||||
} catch (error) {
|
||||
logger.error("Error during graceful shutdown:", error);
|
||||
process.exit(1);
|
||||
// if (server) {
|
||||
// await server.destroy();
|
||||
// }
|
||||
} finally {
|
||||
// logger.info("Exiting process...");
|
||||
// process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
logger.info("Received SIGINT signal. Killing node process...");
|
||||
// Graceful shutdown on uncaught exception
|
||||
process.on("uncaughtException", async (err: Error) => {
|
||||
logger.error(`UNCAUGHT EXCEPTION!`, err);
|
||||
try {
|
||||
if (server) {
|
||||
await server.destroy();
|
||||
}
|
||||
logger.info("Server shut down gracefully");
|
||||
} catch (error) {
|
||||
logger.error("Error during graceful shutdown:", error);
|
||||
process.exit(1);
|
||||
// if (server) {
|
||||
// await server.destroy();
|
||||
// }
|
||||
} finally {
|
||||
// logger.info("Exiting process...");
|
||||
// process.exit(1);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (err: Error) => {
|
||||
const error = new AppError(err);
|
||||
logger.error(`[UNHANDLED_REJECTION]`, error);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (err: Error) => {
|
||||
const error = new AppError(err);
|
||||
logger.error(`[UNCAUGHT_EXCEPTION]`, error);
|
||||
});
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Type-safe admin commands for server-to-server communication
|
||||
*/
|
||||
|
||||
/**
|
||||
* Force close error codes - reasons why a document is being force closed
|
||||
*/
|
||||
export enum ForceCloseReason {
|
||||
CRITICAL_ERROR = "critical_error",
|
||||
MEMORY_LEAK = "memory_leak",
|
||||
DOCUMENT_TOO_LARGE = "document_too_large",
|
||||
ADMIN_REQUEST = "admin_request",
|
||||
SERVER_SHUTDOWN = "server_shutdown",
|
||||
SECURITY_VIOLATION = "security_violation",
|
||||
CORRUPTION_DETECTED = "corruption_detected",
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket close codes
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
|
||||
*/
|
||||
export enum CloseCode {
|
||||
/** Normal closure; the connection successfully completed */
|
||||
NORMAL = 1000,
|
||||
/** The endpoint is going away (server shutdown or browser navigating away) */
|
||||
GOING_AWAY = 1001,
|
||||
/** Protocol error */
|
||||
PROTOCOL_ERROR = 1002,
|
||||
/** Unsupported data */
|
||||
UNSUPPORTED_DATA = 1003,
|
||||
/** Reserved (no status code was present) */
|
||||
NO_STATUS = 1005,
|
||||
/** Abnormal closure */
|
||||
ABNORMAL = 1006,
|
||||
/** Invalid frame payload data */
|
||||
INVALID_DATA = 1007,
|
||||
/** Policy violation */
|
||||
POLICY_VIOLATION = 1008,
|
||||
/** Message too big */
|
||||
MESSAGE_TOO_BIG = 1009,
|
||||
/** Client expected extension not negotiated */
|
||||
MANDATORY_EXTENSION = 1010,
|
||||
/** Server encountered unexpected condition */
|
||||
INTERNAL_ERROR = 1011,
|
||||
/** Custom: Force close requested */
|
||||
FORCE_CLOSE = 4000,
|
||||
/** Custom: Document too large */
|
||||
DOCUMENT_TOO_LARGE = 4001,
|
||||
/** Custom: Memory pressure */
|
||||
MEMORY_PRESSURE = 4002,
|
||||
/** Custom: Security violation */
|
||||
SECURITY_VIOLATION = 4003,
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin command types
|
||||
*/
|
||||
export enum AdminCommand {
|
||||
FORCE_CLOSE = "force_close",
|
||||
HEALTH_CHECK = "health_check",
|
||||
RESTART_DOCUMENT = "restart_document",
|
||||
}
|
||||
|
||||
/**
|
||||
* Force close command data structure
|
||||
*/
|
||||
export interface ForceCloseCommandData {
|
||||
command: AdminCommand.FORCE_CLOSE;
|
||||
docId: string;
|
||||
reason: ForceCloseReason;
|
||||
code: CloseCode;
|
||||
originServer: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check command data structure
|
||||
*/
|
||||
export interface HealthCheckCommandData {
|
||||
command: AdminCommand.HEALTH_CHECK;
|
||||
originServer: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all admin commands
|
||||
*/
|
||||
export type AdminCommandData = ForceCloseCommandData | HealthCheckCommandData;
|
||||
|
||||
/**
|
||||
* Client force close message structure (sent to clients via sendStateless)
|
||||
*/
|
||||
export interface ClientForceCloseMessage {
|
||||
type: "force_close";
|
||||
reason: ForceCloseReason;
|
||||
code: CloseCode;
|
||||
message?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin command handler function type
|
||||
*/
|
||||
export type AdminCommandHandler<T extends AdminCommandData = AdminCommandData> = (data: T) => Promise<void> | void;
|
||||
|
||||
/**
|
||||
* Type guard to check if data is a ForceCloseCommandData
|
||||
*/
|
||||
export function isForceCloseCommand(data: AdminCommandData): data is ForceCloseCommandData {
|
||||
return data.command === AdminCommand.FORCE_CLOSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if data is a HealthCheckCommandData
|
||||
*/
|
||||
export function isHealthCheckCommand(data: AdminCommandData): data is HealthCheckCommandData {
|
||||
return data.command === AdminCommand.HEALTH_CHECK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate force close reason
|
||||
*/
|
||||
export function isValidForceCloseReason(reason: string): reason is ForceCloseReason {
|
||||
return Object.values(ForceCloseReason).includes(reason as ForceCloseReason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable message for force close reason
|
||||
*/
|
||||
export function getForceCloseMessage(reason: ForceCloseReason): string {
|
||||
const messages: Record<ForceCloseReason, string> = {
|
||||
[ForceCloseReason.CRITICAL_ERROR]: "A critical error occurred. Please refresh the page.",
|
||||
[ForceCloseReason.MEMORY_LEAK]: "Memory limit exceeded. Please refresh the page.",
|
||||
[ForceCloseReason.DOCUMENT_TOO_LARGE]:
|
||||
"Content limit reached and live sync is off. Create a new page or use nested pages to continue syncing.",
|
||||
[ForceCloseReason.ADMIN_REQUEST]: "Connection closed by administrator. Please try again later.",
|
||||
[ForceCloseReason.SERVER_SHUTDOWN]: "Server is shutting down. Please reconnect in a moment.",
|
||||
[ForceCloseReason.SECURITY_VIOLATION]: "Security violation detected. Connection terminated.",
|
||||
[ForceCloseReason.CORRUPTION_DETECTED]: "Data corruption detected. Please refresh the page.",
|
||||
};
|
||||
|
||||
return messages[reason] || "Connection closed. Please refresh the page.";
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { type Hocuspocus } from "@hocuspocus/server";
|
||||
import { createRealtimeEvent } from "@plane/editor";
|
||||
import { logger } from "@plane/logger";
|
||||
import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types";
|
||||
import { broadcastMessageToPage } from "./broadcast-message";
|
||||
|
||||
// Helper to broadcast error to frontend
|
||||
export const broadcastError = async (
|
||||
hocuspocusServerInstance: Hocuspocus,
|
||||
pageId: string,
|
||||
errorMessage: string,
|
||||
errorType: "fetch" | "store",
|
||||
context: FetchPayloadWithContext["context"] | StorePayloadWithContext["context"],
|
||||
errorCode?: "content_too_large" | "page_locked" | "page_archived",
|
||||
shouldDisconnect?: boolean
|
||||
) => {
|
||||
try {
|
||||
const errorEvent = createRealtimeEvent({
|
||||
action: "error",
|
||||
page_id: pageId,
|
||||
parent_id: undefined,
|
||||
descendants_ids: [],
|
||||
data: {
|
||||
error_message: errorMessage,
|
||||
error_type: errorType,
|
||||
error_code: errorCode,
|
||||
should_disconnect: shouldDisconnect,
|
||||
user_id: context.userId || "",
|
||||
},
|
||||
workspace_slug: context.workspaceSlug || "",
|
||||
user_id: context.userId || "",
|
||||
});
|
||||
|
||||
await broadcastMessageToPage(hocuspocusServerInstance, pageId, errorEvent);
|
||||
} catch (broadcastError) {
|
||||
logger.error("Error broadcasting error message to frontend:", broadcastError);
|
||||
}
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Hocuspocus } from "@hocuspocus/server";
|
||||
import { BroadcastedEvent } from "@plane/editor";
|
||||
import { logger } from "@plane/logger";
|
||||
import { Redis } from "@/extensions/redis";
|
||||
import { AppError } from "@/lib/errors";
|
||||
|
||||
export const broadcastMessageToPage = async (
|
||||
hocuspocusServerInstance: Hocuspocus,
|
||||
documentName: string,
|
||||
eventData: BroadcastedEvent
|
||||
): Promise<boolean> => {
|
||||
if (!hocuspocusServerInstance || !hocuspocusServerInstance.documents) {
|
||||
const appError = new AppError("HocusPocus server not available or initialized", {
|
||||
context: { operation: "broadcastMessageToPage", documentName },
|
||||
});
|
||||
logger.error("Error while broadcasting message:", appError);
|
||||
return false;
|
||||
}
|
||||
|
||||
const redisExtension = hocuspocusServerInstance.configuration.extensions.find((ext) => ext instanceof Redis);
|
||||
|
||||
if (!redisExtension) {
|
||||
logger.error("BROADCAST_MESSAGE_TO_PAGE: Redis extension not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await redisExtension.broadcastToDocument(documentName, eventData);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`BROADCAST_MESSAGE_TO_PAGE: Error broadcasting to ${documentName}:`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,21 +1,83 @@
|
||||
export const generateTitleProsemirrorJson = (text: string) => {
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
// plane editor
|
||||
import {
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData,
|
||||
getAllDocumentFormatsFromRichTextEditorBinaryData,
|
||||
getBinaryDataFromDocumentEditorHTMLString,
|
||||
getBinaryDataFromRichTextEditorHTMLString,
|
||||
} from "@plane/editor";
|
||||
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
|
||||
// plane types
|
||||
import { TDocumentPayload } from "@plane/types";
|
||||
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
type TArgs = {
|
||||
document_html: string;
|
||||
variant: "rich" | "document";
|
||||
};
|
||||
|
||||
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
|
||||
const { document_html, variant } = args;
|
||||
|
||||
if (variant === "rich") {
|
||||
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
|
||||
return {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
description_binary: contentBinaryEncoded,
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === "document") {
|
||||
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
|
||||
return {
|
||||
description: contentJSON,
|
||||
description_html: contentHTML,
|
||||
description_binary: contentBinaryEncoded,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Invalid variant provided: ${variant}`);
|
||||
};
|
||||
|
||||
export const getAllDocumentFormatsFromBinaryData = (
|
||||
description: Uint8Array
|
||||
): {
|
||||
contentBinaryEncoded: string;
|
||||
contentJSON: object;
|
||||
contentHTML: string;
|
||||
} => {
|
||||
// encode binary description data
|
||||
const base64Data = Buffer.from(description).toString("base64");
|
||||
const yDoc = new Y.Doc();
|
||||
Y.applyUpdate(yDoc, description);
|
||||
// convert to JSON
|
||||
const type = yDoc.getXmlFragment("default");
|
||||
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
|
||||
// convert to HTML
|
||||
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
...(text
|
||||
? {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
contentBinaryEncoded: base64Data,
|
||||
contentJSON,
|
||||
contentHTML,
|
||||
};
|
||||
};
|
||||
|
||||
export const getBinaryDataFromHTMLString = (descriptionHTML: string): Uint8Array => {
|
||||
// convert HTML to JSON
|
||||
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
|
||||
// convert JSON to Y.Doc format
|
||||
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
|
||||
// convert Y.Doc to Uint8Array format
|
||||
return Y.encodeStateAsUpdate(transformedData);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2");
|
||||
|
||||
const fallbackCopyTextToClipboard = (text: string) => {
|
||||
@@ -48,6 +50,13 @@ export const checkEmailValidity = (email: string): boolean => {
|
||||
return isEmailValid;
|
||||
};
|
||||
|
||||
export const isEmptyHtmlString = (htmlString: string, allowedHTMLTags: string[] = []) => {
|
||||
// Remove HTML tags using regex
|
||||
const cleanText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: allowedHTMLTags });
|
||||
// Trim the string and check if it's empty
|
||||
return cleanText.trim() === "";
|
||||
};
|
||||
|
||||
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
|
||||
|
||||
export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@@ -31,6 +31,7 @@
|
||||
"axios": "catalog:",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.0.11",
|
||||
"dotenv": "^16.3.1",
|
||||
"lodash-es": "catalog:",
|
||||
"lowlight": "^2.9.0",
|
||||
|
||||
+1
-1
@@ -6,11 +6,11 @@ import { useParams } from "next/navigation";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProjectFeaturesList } from "@/components/project/settings/features-list";
|
||||
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { ProjectFeaturesList } from "@/plane-web/components/projects/settings/features-list";
|
||||
|
||||
const FeaturesSettingsPage = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { ProjectFeaturesList } from "@/components/project/settings/features-list";
|
||||
@@ -13,7 +13,6 @@ export type TProperties = {
|
||||
isPro: boolean;
|
||||
isEnabled: boolean;
|
||||
renderChildren?: (currentProjectDetails: IProject, workspaceSlug: string) => ReactNode;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
type TProjectBaseFeatureKeys = "cycles" | "modules" | "views" | "pages" | "inbox";
|
||||
|
||||
@@ -33,7 +33,7 @@ type Props = {
|
||||
handleOnSubmit: (data: ISearchIssueResponse[]) => Promise<void>;
|
||||
workspaceLevelToggle?: boolean;
|
||||
shouldHideIssue?: (issue: ISearchIssueResponse) => boolean;
|
||||
selectedWorkItemIds?: string[];
|
||||
selectedWorkItems?: ISearchIssueResponse[];
|
||||
workItemSearchServiceCallback?: (params: TProjectIssuesSearchParams) => Promise<ISearchIssueResponse[]>;
|
||||
};
|
||||
|
||||
@@ -51,7 +51,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
||||
handleOnSubmit,
|
||||
workspaceLevelToggle = false,
|
||||
shouldHideIssue,
|
||||
selectedWorkItemIds,
|
||||
selectedWorkItems,
|
||||
workItemSearchServiceCallback,
|
||||
} = props;
|
||||
// states
|
||||
@@ -117,10 +117,10 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedWorkItemIds) {
|
||||
setSelectedIssues(issues.filter((issue) => selectedWorkItemIds.includes(issue.id)));
|
||||
if (selectedWorkItems) {
|
||||
setSelectedIssues(selectedWorkItems);
|
||||
}
|
||||
}, [isOpen, selectedWorkItemIds]);
|
||||
}, [isOpen, selectedWorkItems]);
|
||||
|
||||
useEffect(() => {
|
||||
handleSearch();
|
||||
|
||||
@@ -177,7 +177,6 @@ export const MemberDropdownBase: React.FC<TMemberDropdownBaseProps> = observer((
|
||||
optionsClassName={optionsClassName}
|
||||
placement={placement}
|
||||
referenceElement={referenceElement}
|
||||
selectedMemberIds={Array.isArray(value) ? value : value ? [value] : []}
|
||||
/>
|
||||
)}
|
||||
</ComboDropDown>
|
||||
|
||||
@@ -29,7 +29,6 @@ interface Props {
|
||||
optionsClassName?: string;
|
||||
placement: Placement | undefined;
|
||||
referenceElement: HTMLButtonElement | null;
|
||||
selectedMemberIds?: string[];
|
||||
}
|
||||
|
||||
export const MemberOptions: React.FC<Props> = observer((props: Props) => {
|
||||
@@ -41,7 +40,6 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
|
||||
optionsClassName = "",
|
||||
placement,
|
||||
referenceElement,
|
||||
selectedMemberIds = [],
|
||||
} = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
@@ -87,14 +85,8 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const selectedMemberIdsSet = new Set(selectedMemberIds);
|
||||
|
||||
const options = memberIds
|
||||
?.map((userId) => {
|
||||
const isSuspended = isUserSuspended(userId, workspaceSlug?.toString());
|
||||
|
||||
if (isSuspended && !selectedMemberIdsSet.has(userId)) return null;
|
||||
|
||||
const userDetails = getUserDetails(userId);
|
||||
return {
|
||||
value: userId,
|
||||
@@ -102,30 +94,25 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4">
|
||||
{isSuspended ? (
|
||||
{isUserSuspended(userId, workspaceSlug?.toString()) ? (
|
||||
<SuspendedUserIcon className="h-3.5 w-3.5 text-custom-text-400" />
|
||||
) : (
|
||||
<Avatar name={userDetails?.display_name} src={getFileURL(userDetails?.avatar_url ?? "")} />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn("flex-grow truncate", isSuspended ? "text-custom-text-400" : "")}
|
||||
className={cn(
|
||||
"flex-grow truncate",
|
||||
isUserSuspended(userId, workspaceSlug?.toString()) ? "text-custom-text-400" : ""
|
||||
)}
|
||||
>
|
||||
{currentUser?.id === userId ? t("you") : userDetails?.display_name}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
isSuspended,
|
||||
};
|
||||
})
|
||||
.filter((o): o is NonNullable<typeof o> => !!o)
|
||||
?.sort((a, b) => {
|
||||
const isASelected = selectedMemberIdsSet.has(a.value);
|
||||
const isBSelected = selectedMemberIdsSet.has(b.value);
|
||||
|
||||
if (isASelected === isBSelected) return 0;
|
||||
return isASelected ? -1 : 1;
|
||||
});
|
||||
.filter((o) => !!o);
|
||||
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((o) => o?.query.toLowerCase().includes(query.toLowerCase()));
|
||||
@@ -170,16 +157,18 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
|
||||
"flex w-full select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5",
|
||||
active && "bg-custom-background-80",
|
||||
selected ? "text-custom-text-100" : "text-custom-text-200",
|
||||
option.isSuspended && !selected ? "cursor-not-allowed" : "cursor-pointer"
|
||||
isUserSuspended(option.value, workspaceSlug?.toString())
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer"
|
||||
)
|
||||
}
|
||||
disabled={option.isSuspended && !selectedMemberIdsSet.has(option.value)}
|
||||
disabled={isUserSuspended(option.value, workspaceSlug?.toString())}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
{option.isSuspended && (
|
||||
{isUserSuspended(option.value, workspaceSlug?.toString()) && (
|
||||
<Pill variant={EPillVariant.DEFAULT} size={EPillSize.XS} className="border-none">
|
||||
Suspended
|
||||
</Pill>
|
||||
|
||||
@@ -319,7 +319,6 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
{
|
||||
name: watch("name"),
|
||||
description_html: getTextContent(watch("description_html")),
|
||||
issueId: data?.id,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export type TPageActions =
|
||||
| "move";
|
||||
|
||||
type Props = {
|
||||
editorRef?: EditorRefApi | null;
|
||||
extraOptions?: (TContextMenuItem & { key: TPageActions })[];
|
||||
optionsOrder: TPageActions[];
|
||||
page: TPageInstance;
|
||||
@@ -62,7 +63,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const PageActions: React.FC<Props> = observer((props) => {
|
||||
const { extraOptions, optionsOrder, page, parentRef, storeType } = props;
|
||||
const { editorRef, extraOptions, optionsOrder, page, parentRef, storeType } = props;
|
||||
// states
|
||||
const [deletePageModal, setDeletePageModal] = useState(false);
|
||||
const [movePageModal, setMovePageModal] = useState(false);
|
||||
@@ -74,6 +75,7 @@ export const PageActions: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
// page operations
|
||||
const { pageOperations } = usePageOperations({
|
||||
editorRef,
|
||||
page,
|
||||
});
|
||||
// derived values
|
||||
|
||||
@@ -130,6 +130,7 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
pageTitle={name ?? ""}
|
||||
/>
|
||||
<PageActions
|
||||
editorRef={editorRef}
|
||||
extraOptions={EXTRA_MENU_OPTIONS}
|
||||
optionsOrder={[
|
||||
"full-screen",
|
||||
|
||||
@@ -44,7 +44,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
await removePage({ pageId })
|
||||
await removePage(pageId)
|
||||
.then(() => {
|
||||
captureSuccess({
|
||||
eventName: PROJECT_PAGE_TRACKER_EVENTS.delete,
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { PROJECT_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { PROJECT_TRACKER_ELEMENTS, PROJECT_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IProject } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
// components
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
// helpers
|
||||
@@ -18,7 +19,6 @@ import { useUser } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge";
|
||||
import { PROJECT_FEATURES_LIST } from "@/plane-web/constants/project/settings";
|
||||
import { ProjectFeatureToggle } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
@@ -96,13 +96,12 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectFeatureToggle
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
featureItem={featureItem}
|
||||
<ToggleSwitch
|
||||
value={Boolean(currentProjectDetails?.[featureItem.property as keyof IProject])}
|
||||
handleSubmit={handleSubmit}
|
||||
disabled={!isAdmin}
|
||||
onChange={() => handleSubmit(featureItemKey, featureItem.property)}
|
||||
disabled={!featureItem.isEnabled || !isAdmin}
|
||||
size="sm"
|
||||
data-ph-element={PROJECT_TRACKER_ELEMENTS.TOGGLE_FEATURE}
|
||||
/>
|
||||
</div>
|
||||
<div className="pl-14">
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EPillVariant, Pill, EPillSize } from "@plane/propel/pill";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import type { TProperties } from "@/plane-web/constants/project/settings/features";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
featureItem: TProperties;
|
||||
value: boolean;
|
||||
handleSubmit: (featureKey: string, featureProperty: string) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const ProjectFeatureToggle = (props: Props) => {
|
||||
const { workspaceSlug, projectId, featureItem, value, handleSubmit, disabled } = props;
|
||||
return featureItem.href ? (
|
||||
<Link href={`/${workspaceSlug}/settings/projects/${projectId}/features/${featureItem.href}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill
|
||||
variant={value ? EPillVariant.PRIMARY : EPillVariant.DEFAULT}
|
||||
size={EPillSize.SM}
|
||||
className="border-none rounded-lg"
|
||||
>
|
||||
{value ? "Enabled" : "Disabled"}
|
||||
</Pill>
|
||||
<ChevronRight className="h-4 w-4 text-custom-text-300" />
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={() => handleSubmit(featureItem.key, featureItem.property)}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
data-ph-element={PROJECT_TRACKER_ELEMENTS.TOGGLE_FEATURE}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import type { FC } from "react";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = React.ComponentProps<"button"> & {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import type { FC } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
|
||||
@@ -6,51 +6,51 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
export type CollaborativeAction = {
|
||||
execute: (shouldSync?: boolean, recursive?: boolean) => Promise<void>;
|
||||
// Better type naming and structure
|
||||
type CollaborativeAction = {
|
||||
execute: (shouldSync?: boolean) => Promise<void>;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
type CollaborativeActionEvent =
|
||||
| { type: "sendMessageToServer"; message: TDocumentEventsServer; recursive?: boolean }
|
||||
| { type: "sendMessageToServer"; message: TDocumentEventsServer }
|
||||
| { type: "receivedMessageFromServer"; message: TDocumentEventsClient };
|
||||
|
||||
type Props = {
|
||||
editorRef?: EditorRefApi | null;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const useCollaborativePageActions = (props: Props) => {
|
||||
const { page } = props;
|
||||
const editorRef = page.editor.editorRef;
|
||||
const { editorRef, page } = props;
|
||||
// currentUserAction local state to track if the current action is being processed, a
|
||||
// local action is basically the action performed by the current user to avoid double operations
|
||||
const [currentActionBeingProcessed, setCurrentActionBeingProcessed] = useState<TDocumentEventsClient | null>(null);
|
||||
|
||||
// @ts-expect-error - TODO: fix this
|
||||
const actionHandlerMap: Record<TDocumentEventsClient, CollaborativeAction> = useMemo(
|
||||
() => ({
|
||||
[DocumentCollaborativeEvents.lock.client]: {
|
||||
execute: (shouldSync?: boolean, recursive?: boolean) => page.lock({ shouldSync, recursive }),
|
||||
execute: (shouldSync) => page.lock(shouldSync),
|
||||
errorMessage: "Page could not be locked. Please try again later.",
|
||||
},
|
||||
[DocumentCollaborativeEvents.unlock.client]: {
|
||||
execute: (shouldSync?: boolean, recursive?: boolean) => page.unlock({ shouldSync, recursive }),
|
||||
execute: (shouldSync) => page.unlock(shouldSync),
|
||||
errorMessage: "Page could not be unlocked. Please try again later.",
|
||||
},
|
||||
[DocumentCollaborativeEvents.archive.client]: {
|
||||
execute: (shouldSync?: boolean) => page.archive({ shouldSync }),
|
||||
execute: (shouldSync) => page.archive(shouldSync),
|
||||
errorMessage: "Page could not be archived. Please try again later.",
|
||||
},
|
||||
[DocumentCollaborativeEvents.unarchive.client]: {
|
||||
execute: (shouldSync?: boolean) => page.restore({ shouldSync }),
|
||||
execute: (shouldSync) => page.restore(shouldSync),
|
||||
errorMessage: "Page could not be restored. Please try again later.",
|
||||
},
|
||||
[DocumentCollaborativeEvents["make-public"].client]: {
|
||||
execute: (shouldSync?: boolean) => page.makePublic({ shouldSync }),
|
||||
execute: (shouldSync) => page.makePublic(shouldSync),
|
||||
errorMessage: "Page could not be made public. Please try again later.",
|
||||
},
|
||||
[DocumentCollaborativeEvents["make-private"].client]: {
|
||||
execute: (shouldSync?: boolean) => page.makePrivate({ shouldSync }),
|
||||
execute: (shouldSync) => page.makePrivate(shouldSync),
|
||||
errorMessage: "Page could not be made private. Please try again later.",
|
||||
},
|
||||
}),
|
||||
@@ -62,22 +62,22 @@ export const useCollaborativePageActions = (props: Props) => {
|
||||
const isPerformedByCurrentUser = event.type === "sendMessageToServer";
|
||||
const clientAction = isPerformedByCurrentUser ? DocumentCollaborativeEvents[event.message].client : event.message;
|
||||
const actionDetails = actionHandlerMap[clientAction];
|
||||
|
||||
try {
|
||||
await actionDetails.execute(isPerformedByCurrentUser, isPerformedByCurrentUser ? event?.recursive : undefined);
|
||||
await actionDetails.execute(isPerformedByCurrentUser);
|
||||
if (isPerformedByCurrentUser) {
|
||||
const serverEventName = getServerEventName(clientAction);
|
||||
if (serverEventName) {
|
||||
editorRef?.emitRealTimeUpdate(serverEventName);
|
||||
}
|
||||
setCurrentActionBeingProcessed(clientAction);
|
||||
}
|
||||
} catch {
|
||||
if (actionDetails?.errorMessage) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: actionDetails.errorMessage,
|
||||
});
|
||||
}
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: actionDetails.errorMessage,
|
||||
});
|
||||
}
|
||||
},
|
||||
[actionHandlerMap, editorRef]
|
||||
|
||||
@@ -25,6 +25,7 @@ export type TPageOperations = {
|
||||
};
|
||||
|
||||
type Props = {
|
||||
editorRef?: EditorRefApi | null;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
// plane imports
|
||||
import type { EventToPayloadMap } from "@plane/editor";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
// types
|
||||
import type { IUserLite } from "@plane/types";
|
||||
// components
|
||||
import type { TEditorBodyHandlers } from "@/components/pages/editor/editor-body";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import type { EPageStoreType } from "@/plane-web/hooks/store";
|
||||
import { usePageStore } from "@/plane-web/hooks/store";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
// Type for page update handlers with proper typing for action data
|
||||
export type PageUpdateHandler<T extends keyof EventToPayloadMap = keyof EventToPayloadMap> = (params: {
|
||||
pageIds: string[];
|
||||
data: EventToPayloadMap[T];
|
||||
performAction: boolean;
|
||||
}) => void;
|
||||
|
||||
// Type for custom event handlers that can be provided to override default behavior
|
||||
export type TCustomEventHandlers = {
|
||||
[K in keyof EventToPayloadMap]?: PageUpdateHandler<K>;
|
||||
};
|
||||
|
||||
interface UsePageEventsProps {
|
||||
page: TPageInstance;
|
||||
storeType: EPageStoreType;
|
||||
getUserDetails: (userId: string) => IUserLite | undefined;
|
||||
customRealtimeEventHandlers?: TCustomEventHandlers;
|
||||
handlers: TEditorBodyHandlers;
|
||||
}
|
||||
|
||||
export const useRealtimePageEvents = ({
|
||||
page,
|
||||
storeType,
|
||||
getUserDetails,
|
||||
customRealtimeEventHandlers,
|
||||
handlers,
|
||||
}: UsePageEventsProps) => {
|
||||
const router = useAppRouter();
|
||||
const { removePage, getPageById } = usePageStore(storeType);
|
||||
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
// Helper function to safely get user display text
|
||||
const getUserDisplayText = useCallback(
|
||||
(userId: string | undefined) => {
|
||||
if (!userId) return "";
|
||||
try {
|
||||
const userDetails = getUserDetails(userId as string);
|
||||
return userDetails?.display_name ? ` by ${userDetails.display_name}` : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
[getUserDetails]
|
||||
);
|
||||
|
||||
const ACTION_HANDLERS = useMemo<
|
||||
Partial<{
|
||||
[K in keyof EventToPayloadMap]: PageUpdateHandler<K>;
|
||||
}>
|
||||
>(
|
||||
() => ({
|
||||
archived: ({ pageIds, data }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.archive({ archived_at: data.archived_at, shouldSync: false });
|
||||
});
|
||||
},
|
||||
unarchived: ({ pageIds }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.restore({ shouldSync: false });
|
||||
});
|
||||
},
|
||||
locked: ({ pageIds }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.lock({ shouldSync: false, recursive: false });
|
||||
});
|
||||
},
|
||||
unlocked: ({ pageIds }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.unlock({ shouldSync: false, recursive: false });
|
||||
});
|
||||
},
|
||||
"made-public": ({ pageIds }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.makePublic({ shouldSync: false });
|
||||
});
|
||||
},
|
||||
"made-private": ({ pageIds }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) pageItem.makePrivate({ shouldSync: false });
|
||||
});
|
||||
},
|
||||
deleted: ({ pageIds, data }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageItem = getPageById(pageId);
|
||||
if (pageItem) {
|
||||
removePage({ pageId, shouldSync: false });
|
||||
if (page.id === pageId && data?.user_id !== currentUser?.id) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Page deleted",
|
||||
message: `Page deleted${getUserDisplayText(data.user_id)}`,
|
||||
});
|
||||
router.push(handlers.getRedirectionLink());
|
||||
} else if (page.id === pageId) {
|
||||
router.push(handlers.getRedirectionLink());
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
property_updated: ({ pageIds, data }) => {
|
||||
pageIds.forEach((pageId) => {
|
||||
const pageInstance = getPageById(pageId);
|
||||
const { name: updatedName, ...rest } = data;
|
||||
if (updatedName != null) pageInstance?.updateTitle(updatedName);
|
||||
pageInstance?.mutateProperties(rest);
|
||||
});
|
||||
},
|
||||
error: ({ pageIds, data }) => {
|
||||
const errorType = data.error_type;
|
||||
const errorMessage = data.error_message || "An error occurred";
|
||||
const errorCode = data.error_code;
|
||||
|
||||
if (page.id && pageIds.includes(page.id)) {
|
||||
// Show toast notification
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: errorType === "fetch" ? "Failed to load page" : "Failed to save page",
|
||||
message: errorMessage,
|
||||
});
|
||||
|
||||
// Handle specific error codes
|
||||
const pageInstance = getPageById(page.id);
|
||||
if (pageInstance) {
|
||||
if (errorCode === "page_locked") {
|
||||
// Lock the page if not already locked
|
||||
if (!pageInstance.is_locked) {
|
||||
pageInstance.mutateProperties({ is_locked: true });
|
||||
}
|
||||
} else if (errorCode === "page_archived") {
|
||||
// Mark page as archived if not already
|
||||
if (!pageInstance.archived_at) {
|
||||
pageInstance.mutateProperties({ archived_at: new Date().toISOString() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
...customRealtimeEventHandlers,
|
||||
}),
|
||||
[getPageById, page, router, getUserDisplayText, removePage, currentUser, customRealtimeEventHandlers, handlers]
|
||||
);
|
||||
|
||||
// The main function that will be returned from this hook
|
||||
const updatePageProperties = useCallback(
|
||||
<T extends keyof EventToPayloadMap>(
|
||||
pageIds: string | string[],
|
||||
actionType: T,
|
||||
data: EventToPayloadMap[T],
|
||||
performAction = false
|
||||
) => {
|
||||
// Convert to array if single string is passed
|
||||
const normalizedPageIds = Array.isArray(pageIds) ? pageIds : [pageIds];
|
||||
|
||||
if (normalizedPageIds.length === 0) return;
|
||||
|
||||
// Get the handler for this message type
|
||||
const handler = ACTION_HANDLERS[actionType];
|
||||
|
||||
if (handler) {
|
||||
// Now TypeScript knows that handler and data match in type
|
||||
handler({ pageIds: normalizedPageIds, data, performAction });
|
||||
} else {
|
||||
console.warn(`No handler for message type: ${actionType.toString()}`);
|
||||
}
|
||||
},
|
||||
[ACTION_HANDLERS]
|
||||
);
|
||||
|
||||
return { updatePageProperties };
|
||||
};
|
||||
@@ -1,13 +1,10 @@
|
||||
import { observable, action, makeObservable, runInAction, computed, reaction } from "mobx";
|
||||
import { observable, action, makeObservable, runInAction, computed } from "mobx";
|
||||
|
||||
// helpers
|
||||
import { computedFn } from "mobx-utils";
|
||||
import type { ICalendarPayload, ICalendarWeek } from "@plane/types";
|
||||
import { EStartOfTheWeek } from "@plane/types";
|
||||
import { generateCalendarData, getWeekNumberOfDate } from "@plane/utils";
|
||||
// types
|
||||
import type { IIssueRootStore } from "./root.store";
|
||||
|
||||
export interface ICalendarStore {
|
||||
calendarFilters: {
|
||||
activeMonthDate: Date;
|
||||
@@ -18,7 +15,6 @@ export interface ICalendarStore {
|
||||
// action
|
||||
updateCalendarFilters: (filters: Partial<{ activeMonthDate: Date; activeWeekDate: Date }>) => void;
|
||||
updateCalendarPayload: (date: Date) => void;
|
||||
regenerateCalendar: () => void;
|
||||
|
||||
// computed
|
||||
allWeeksOfActiveMonth:
|
||||
@@ -42,10 +38,8 @@ export class CalendarStore implements ICalendarStore {
|
||||
activeWeekDate: new Date(),
|
||||
};
|
||||
calendarPayload: ICalendarPayload | null = null;
|
||||
// root store
|
||||
rootStore;
|
||||
|
||||
constructor(_rootStore: IIssueRootStore) {
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
loader: observable.ref,
|
||||
error: observable.ref,
|
||||
@@ -57,7 +51,6 @@ export class CalendarStore implements ICalendarStore {
|
||||
// actions
|
||||
updateCalendarFilters: action,
|
||||
updateCalendarPayload: action,
|
||||
regenerateCalendar: action,
|
||||
|
||||
//computed
|
||||
allWeeksOfActiveMonth: computed,
|
||||
@@ -65,17 +58,7 @@ export class CalendarStore implements ICalendarStore {
|
||||
allDaysOfActiveWeek: computed,
|
||||
});
|
||||
|
||||
this.rootStore = _rootStore;
|
||||
this.initCalendar();
|
||||
|
||||
// Watch for changes in startOfWeek preference and regenerate calendar
|
||||
reaction(
|
||||
() => this.rootStore.rootStore.user.userProfile.data?.start_of_the_week,
|
||||
() => {
|
||||
// Regenerate calendar when startOfWeek preference changes
|
||||
this.regenerateCalendar();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
get allWeeksOfActiveMonth() {
|
||||
@@ -155,32 +138,14 @@ export class CalendarStore implements ICalendarStore {
|
||||
if (!this.calendarPayload) return null;
|
||||
|
||||
const nextDate = new Date(date);
|
||||
const startOfWeek = this.rootStore.rootStore.user.userProfile.data?.start_of_the_week ?? EStartOfTheWeek.SUNDAY;
|
||||
|
||||
runInAction(() => {
|
||||
this.calendarPayload = generateCalendarData(this.calendarPayload, nextDate, startOfWeek);
|
||||
this.calendarPayload = generateCalendarData(this.calendarPayload, nextDate);
|
||||
});
|
||||
};
|
||||
|
||||
initCalendar = () => {
|
||||
const startOfWeek = this.rootStore.rootStore.user.userProfile.data?.start_of_the_week ?? EStartOfTheWeek.SUNDAY;
|
||||
const newCalendarPayload = generateCalendarData(null, new Date(), startOfWeek);
|
||||
|
||||
runInAction(() => {
|
||||
this.calendarPayload = newCalendarPayload;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Force complete regeneration of calendar data
|
||||
* This should be called when startOfWeek preference changes
|
||||
*/
|
||||
regenerateCalendar = () => {
|
||||
const startOfWeek = this.rootStore.rootStore.user.userProfile.data?.start_of_the_week ?? EStartOfTheWeek.SUNDAY;
|
||||
const { activeMonthDate } = this.calendarFilters;
|
||||
|
||||
// Force complete regeneration by passing null to clear all cached data
|
||||
const newCalendarPayload = generateCalendarData(null, activeMonthDate, startOfWeek);
|
||||
const newCalendarPayload = generateCalendarData(null, new Date());
|
||||
|
||||
runInAction(() => {
|
||||
this.calendarPayload = newCalendarPayload;
|
||||
|
||||
@@ -266,7 +266,7 @@ export class IssueRootStore implements IIssueRootStore {
|
||||
this.archivedIssues = new ArchivedIssues(this, this.archivedIssuesFilter);
|
||||
|
||||
this.issueKanBanView = new IssueKanBanViewStore(this);
|
||||
this.issueCalendarView = new CalendarStore(this);
|
||||
this.issueCalendarView = new CalendarStore();
|
||||
|
||||
this.projectEpicsFilter = new ProjectEpicsFilter(this);
|
||||
this.projectEpics = new ProjectEpics(this, this.projectEpicsFilter);
|
||||
|
||||
@@ -25,12 +25,12 @@ export type TBasePage = TPage & {
|
||||
update: (pageData: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
|
||||
updateTitle: (title: string) => void;
|
||||
updateDescription: (document: TDocumentPayload) => Promise<void>;
|
||||
makePublic: (params: { shouldSync?: boolean }) => Promise<void>;
|
||||
makePrivate: (params: { shouldSync?: boolean }) => Promise<void>;
|
||||
lock: (params: { shouldSync?: boolean; recursive?: boolean }) => Promise<void>;
|
||||
unlock: (params: { shouldSync?: boolean; recursive?: boolean }) => Promise<void>;
|
||||
archive: (params: { shouldSync?: boolean; archived_at?: string | null }) => Promise<void>;
|
||||
restore: (params: { shouldSync?: boolean }) => Promise<void>;
|
||||
makePublic: (shouldSync?: boolean) => Promise<void>;
|
||||
makePrivate: (shouldSync?: boolean) => Promise<void>;
|
||||
lock: (shouldSync?: boolean) => Promise<void>;
|
||||
unlock: (shouldSync?: boolean) => Promise<void>;
|
||||
archive: (shouldSync?: boolean) => Promise<void>;
|
||||
restore: (shouldSync?: boolean) => Promise<void>;
|
||||
updatePageLogo: (value: TChangeHandlerProps) => Promise<void>;
|
||||
addToFavorites: () => Promise<void>;
|
||||
removePageFromFavorites: () => Promise<void>;
|
||||
@@ -315,7 +315,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
||||
/**
|
||||
* @description make the page public
|
||||
*/
|
||||
makePublic = async ({ shouldSync = true }) => {
|
||||
makePublic = async (shouldSync: boolean = true) => {
|
||||
const pageAccess = this.access;
|
||||
runInAction(() => {
|
||||
this.access = EPageAccess.PUBLIC;
|
||||
@@ -338,7 +338,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
||||
/**
|
||||
* @description make the page private
|
||||
*/
|
||||
makePrivate = async ({ shouldSync = true }) => {
|
||||
makePrivate = async (shouldSync: boolean = true) => {
|
||||
const pageAccess = this.access;
|
||||
runInAction(() => {
|
||||
this.access = EPageAccess.PRIVATE;
|
||||
@@ -361,7 +361,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
||||
/**
|
||||
* @description lock the page
|
||||
*/
|
||||
lock = async ({ shouldSync = true }) => {
|
||||
lock = async (shouldSync: boolean = true) => {
|
||||
const pageIsLocked = this.is_locked;
|
||||
runInAction(() => (this.is_locked = true));
|
||||
|
||||
@@ -378,7 +378,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
||||
/**
|
||||
* @description unlock the page
|
||||
*/
|
||||
unlock = async ({ shouldSync = true }) => {
|
||||
unlock = async (shouldSync: boolean = true) => {
|
||||
const pageIsLocked = this.is_locked;
|
||||
runInAction(() => (this.is_locked = false));
|
||||
|
||||
@@ -395,12 +395,12 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
||||
/**
|
||||
* @description archive the page
|
||||
*/
|
||||
archive = async ({ shouldSync = true, archived_at }: { shouldSync?: boolean; archived_at?: string | null }) => {
|
||||
archive = async (shouldSync: boolean = true) => {
|
||||
if (!this.id) return undefined;
|
||||
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.archived_at = archived_at ?? new Date().toISOString();
|
||||
this.archived_at = new Date().toISOString();
|
||||
});
|
||||
|
||||
if (this.rootStore.favorite.entityMap[this.id]) this.rootStore.favorite.removeFavoriteFromStore(this.id);
|
||||
@@ -422,7 +422,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
||||
/**
|
||||
* @description restore the page
|
||||
*/
|
||||
restore = async ({ shouldSync = true }: { shouldSync?: boolean }) => {
|
||||
restore = async (shouldSync: boolean = true) => {
|
||||
const archivedAtBeforeRestore = this.archived_at;
|
||||
|
||||
try {
|
||||
@@ -438,7 +438,6 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
|
||||
runInAction(() => {
|
||||
this.archived_at = archivedAtBeforeRestore;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface IProjectPageStore {
|
||||
options?: { trackVisit?: boolean }
|
||||
) => Promise<TPage | undefined>;
|
||||
createPage: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
|
||||
removePage: (params: { pageId: string; shouldSync?: boolean }) => Promise<void>;
|
||||
removePage: (pageId: string) => Promise<void>;
|
||||
movePage: (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||
* @description delete a page
|
||||
* @param {string} pageId
|
||||
*/
|
||||
removePage = async ({ pageId, shouldSync = true }: { pageId: string; shouldSync?: boolean }) => {
|
||||
removePage = async (pageId: string) => {
|
||||
try {
|
||||
const { workspaceSlug, projectId } = this.store.router;
|
||||
if (!workspaceSlug || !projectId || !pageId) return undefined;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ProjectService, ProjectStateService, ProjectArchiveService } from "@/se
|
||||
// store
|
||||
import type { CoreRootStore } from "../root.store";
|
||||
|
||||
type ProjectOverviewCollapsible = "links" | "attachments" | "milestones";
|
||||
type ProjectOverviewCollapsible = "links" | "attachments";
|
||||
|
||||
export interface IProjectStore {
|
||||
// observables
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { ProjectFeaturesList } from "@/components/project/settings/features-list";
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@@ -42,6 +42,7 @@
|
||||
"dotenv": "^16.0.3",
|
||||
"emoji-picker-react": "^4.5.16",
|
||||
"export-to-csv": "^1.4.0",
|
||||
"isomorphic-dompurify": "^2.12.0",
|
||||
"lodash-es": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mobx": "catalog:",
|
||||
|
||||
@@ -44,7 +44,6 @@ x-mq-env: &mq-env # RabbitMQ Settings
|
||||
|
||||
x-live-env: &live-env
|
||||
API_BASE_URL: ${API_BASE_URL:-http://api:8000}
|
||||
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW}
|
||||
|
||||
x-app-env: &app-env
|
||||
WEB_URL: ${WEB_URL:-http://localhost}
|
||||
@@ -57,7 +56,6 @@ x-app-env: &app-env
|
||||
AMQP_URL: ${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
|
||||
API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute}
|
||||
MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0}
|
||||
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW}
|
||||
|
||||
services:
|
||||
web:
|
||||
|
||||
@@ -76,7 +76,3 @@ MINIO_ENDPOINT_SSL=0
|
||||
|
||||
# API key rate limit
|
||||
API_KEY_RATE_LIMIT=60/minute
|
||||
|
||||
# Live server environment variables
|
||||
# WARNING: You must set a secure value for LIVE_SERVER_SECRET_KEY in production environments.
|
||||
LIVE_SERVER_SECRET_KEY=
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "plane",
|
||||
"description": "Open-source project management that unlocks customer value",
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
@@ -38,31 +38,29 @@
|
||||
"@floating-ui/dom": "^1.7.1",
|
||||
"@floating-ui/react": "^0.26.4",
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@hocuspocus/provider": "3.2.5",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/hooks": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@tiptap/core": "catalog:",
|
||||
"@tiptap/extension-blockquote": "^2.22.3",
|
||||
"@tiptap/extension-character-count": "^2.22.3",
|
||||
"@tiptap/extension-collaboration": "^2.22.3",
|
||||
"@tiptap/extension-emoji": "^2.22.3",
|
||||
"@tiptap/extension-image": "^2.22.3",
|
||||
"@tiptap/extension-list-item": "^2.22.3",
|
||||
"@tiptap/extension-mention": "^2.22.3",
|
||||
"@tiptap/extension-placeholder": "^2.22.3",
|
||||
"@tiptap/extension-task-item": "^2.22.3",
|
||||
"@tiptap/extension-task-list": "^2.22.3",
|
||||
"@tiptap/extension-text-align": "^2.22.3",
|
||||
"@tiptap/extension-text-style": "^2.22.3",
|
||||
"@tiptap/extension-underline": "^2.22.3",
|
||||
"@tiptap/extension-blockquote": "^3.5.3",
|
||||
"@tiptap/extension-collaboration": "^3.5.3",
|
||||
"@tiptap/extension-emoji": "^3.5.3",
|
||||
"@tiptap/extension-image": "^3.5.3",
|
||||
"@tiptap/extension-list-item": "^3.5.3",
|
||||
"@tiptap/extension-mention": "^3.5.3",
|
||||
"@tiptap/extension-task-item": "^3.5.3",
|
||||
"@tiptap/extension-task-list": "^3.5.3",
|
||||
"@tiptap/extension-text-align": "^3.5.3",
|
||||
"@tiptap/extension-text-style": "^3.5.3",
|
||||
"@tiptap/extensions": "^3.5.3",
|
||||
"@tiptap/html": "catalog:",
|
||||
"@tiptap/pm": "^2.22.3",
|
||||
"@tiptap/react": "^2.22.3",
|
||||
"@tiptap/starter-kit": "^2.22.3",
|
||||
"@tiptap/suggestion": "^2.22.3",
|
||||
"@tiptap/pm": "^3.5.3",
|
||||
"@tiptap/react": "^3.5.3",
|
||||
"@tiptap/starter-kit": "^3.5.3",
|
||||
"@tiptap/suggestion": "^3.5.3",
|
||||
"emoji-regex": "^10.3.0",
|
||||
"highlight.js": "^11.8.0",
|
||||
"is-emoji-supported": "^0.0.5",
|
||||
@@ -72,7 +70,7 @@
|
||||
"lucide-react": "catalog:",
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "catalog:",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-prosemirror": "^1.2.15",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type Editor, isNodeSelection } from "@tiptap/core";
|
||||
import { BubbleMenu, type BubbleMenuProps, useEditorState } from "@tiptap/react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { BubbleMenu, type BubbleMenuProps } from "@tiptap/react/menus";
|
||||
import { FC, useEffect, useState, useRef } from "react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
@@ -118,10 +119,7 @@ export const EditorBubbleMenu: FC<Props> = (props) => {
|
||||
}
|
||||
return true;
|
||||
},
|
||||
tippyOptions: {
|
||||
moveTransition: "transform 0.15s ease-out",
|
||||
duration: [300, 0],
|
||||
zIndex: 9,
|
||||
options: {
|
||||
onShow: () => {
|
||||
if (editor.storage.link) {
|
||||
editor.storage.link.isBubbleMenuOpen = true;
|
||||
@@ -136,15 +134,13 @@ export const EditorBubbleMenu: FC<Props> = (props) => {
|
||||
editor.commands.removeActiveDropbarExtension("bubble-menu");
|
||||
}, 0);
|
||||
},
|
||||
onHidden: () => {
|
||||
if (editor.storage.link) {
|
||||
editor.storage.link.isBubbleMenuOpen = false;
|
||||
}
|
||||
setTimeout(() => {
|
||||
editor.commands.removeActiveDropbarExtension("bubble-menu");
|
||||
}, 0);
|
||||
},
|
||||
},
|
||||
// TODO: Migrate these to floating UI options
|
||||
// tippyOptions: {
|
||||
// moveTransition: "transform 0.15s ease-out",
|
||||
// duration: [300, 0],
|
||||
// zIndex: 9,
|
||||
// },
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,91 +1,8 @@
|
||||
import { EPageAccess } from "@plane/constants";
|
||||
import { TPage } from "@plane/types";
|
||||
import { CreatePayload, BaseActionPayload } from "@/types";
|
||||
|
||||
// Define all payload types for each event.
|
||||
export type ArchivedPayload = CreatePayload<{ archived_at: string | null }>;
|
||||
export type UnarchivedPayload = BaseActionPayload;
|
||||
export type LockedPayload = CreatePayload<{ is_locked: boolean }>;
|
||||
export type UnlockedPayload = BaseActionPayload;
|
||||
export type MadePublicPayload = CreatePayload<{ access: EPageAccess }>;
|
||||
export type MadePrivatePayload = CreatePayload<{ access: EPageAccess }>;
|
||||
export type DeletedPayload = CreatePayload<{ deleted_at: Date | null }>;
|
||||
export type DuplicatedPayload = CreatePayload<{ new_page_id: string }>;
|
||||
export type PropertyUpdatedPayload = CreatePayload<Partial<TPage>>;
|
||||
export type MovedPayload = CreatePayload<{
|
||||
new_project_id: string;
|
||||
new_page_id: string;
|
||||
}>;
|
||||
export type RestoredPayload = CreatePayload<{ deleted_page_ids?: string[] }>;
|
||||
export type ErrorPayload = CreatePayload<{
|
||||
error_message: string;
|
||||
error_type: "fetch" | "store";
|
||||
error_code?: "content_too_large" | "page_locked" | "page_archived";
|
||||
should_disconnect?: boolean;
|
||||
}>;
|
||||
|
||||
// Enhanced DocumentCollaborativeEvents with payload types.
|
||||
// Both the client name and server name are defined, and we add a "payloadType" property
|
||||
// so that we can later derive a mapping from client event to payload type.
|
||||
export const DocumentCollaborativeEvents = {
|
||||
lock: {
|
||||
client: "locked",
|
||||
server: "lock",
|
||||
payloadType: {} as LockedPayload,
|
||||
},
|
||||
unlock: {
|
||||
client: "unlocked",
|
||||
server: "unlock",
|
||||
payloadType: {} as UnlockedPayload,
|
||||
},
|
||||
archive: {
|
||||
client: "archived",
|
||||
server: "archive",
|
||||
payloadType: {} as ArchivedPayload,
|
||||
},
|
||||
unarchive: {
|
||||
client: "unarchived",
|
||||
server: "unarchive",
|
||||
payloadType: {} as UnarchivedPayload,
|
||||
},
|
||||
"make-public": {
|
||||
client: "made-public",
|
||||
server: "make-public",
|
||||
payloadType: {} as MadePublicPayload,
|
||||
},
|
||||
"make-private": {
|
||||
client: "made-private",
|
||||
server: "make-private",
|
||||
payloadType: {} as MadePrivatePayload,
|
||||
},
|
||||
delete: {
|
||||
client: "deleted",
|
||||
server: "delete",
|
||||
payloadType: {} as DeletedPayload,
|
||||
},
|
||||
move: {
|
||||
client: "moved",
|
||||
server: "move",
|
||||
payloadType: {} as MovedPayload,
|
||||
},
|
||||
duplicate: {
|
||||
client: "duplicated",
|
||||
server: "duplicate",
|
||||
payloadType: {} as DuplicatedPayload,
|
||||
},
|
||||
property_update: {
|
||||
client: "property_updated",
|
||||
server: "property_update",
|
||||
payloadType: {} as PropertyUpdatedPayload,
|
||||
},
|
||||
restore: {
|
||||
client: "restored",
|
||||
server: "restore",
|
||||
payloadType: {} as RestoredPayload,
|
||||
},
|
||||
error: {
|
||||
client: "error",
|
||||
server: "error",
|
||||
payloadType: {} as ErrorPayload,
|
||||
},
|
||||
lock: { client: "locked", server: "lock" },
|
||||
unlock: { client: "unlocked", server: "unlock" },
|
||||
archive: { client: "archived", server: "archive" },
|
||||
unarchive: { client: "unarchived", server: "unarchive" },
|
||||
"make-public": { client: "made-public", server: "make-public" },
|
||||
"make-private": { client: "made-private", server: "make-private" },
|
||||
} as const;
|
||||
|
||||
@@ -56,7 +56,7 @@ export const CodeBlockComponent: React.FC<Props> = ({ node }) => {
|
||||
</Tooltip>
|
||||
|
||||
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-4 my-2">
|
||||
<NodeViewContent as="code" className="whitespace-pre-wrap" />
|
||||
<NodeViewContent<"code"> as="code" className="whitespace-pre-wrap" />
|
||||
</pre>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Underline } from "@tiptap/extension-underline";
|
||||
// plane editor imports
|
||||
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
|
||||
// extensions
|
||||
@@ -31,7 +30,6 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
CustomLinkExtension,
|
||||
ImageExtensionConfig,
|
||||
CustomImageExtensionConfig,
|
||||
Underline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
|
||||
@@ -50,32 +50,6 @@ type LinkOptions = {
|
||||
};
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
[CORE_EXTENSIONS.CUSTOM_LINK]: {
|
||||
/**
|
||||
* Set a link mark
|
||||
*/
|
||||
setLink: (attributes: {
|
||||
href: string;
|
||||
target?: string | null;
|
||||
rel?: string | null;
|
||||
class?: string | null;
|
||||
}) => ReturnType;
|
||||
/**
|
||||
* Toggle a link mark
|
||||
*/
|
||||
toggleLink: (attributes: {
|
||||
href: string;
|
||||
target?: string | null;
|
||||
rel?: string | null;
|
||||
class?: string | null;
|
||||
}) => ReturnType;
|
||||
/**
|
||||
* Unset a link mark
|
||||
*/
|
||||
unsetLink: () => ReturnType;
|
||||
};
|
||||
}
|
||||
interface Storage {
|
||||
[CORE_EXTENSIONS.CUSTOM_LINK]: CustomLinkStorage;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EmojiOptions, EmojiStorage } from "@tiptap/extension-emoji";
|
||||
import type { EmojiOptions } from "@tiptap/extension-emoji";
|
||||
import { ReactRenderer, type Editor } from "@tiptap/react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
@@ -12,7 +12,7 @@ const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"];
|
||||
|
||||
export const emojiSuggestion: EmojiOptions["suggestion"] = {
|
||||
items: ({ editor, query }: { editor: Editor; query: string }): EmojiItem[] => {
|
||||
const { emojis, isSupported } = editor.storage.emoji as EmojiStorage;
|
||||
const { emojis, isSupported } = editor.storage.emoji;
|
||||
const filteredEmojis = emojis.filter((emoji) => {
|
||||
const hasEmoji = !!emoji?.emoji;
|
||||
const hasFallbackImage = !!emoji?.fallbackImage;
|
||||
@@ -79,7 +79,7 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = {
|
||||
component.updateProps(props);
|
||||
if (!props.clientRect) return;
|
||||
cleanup();
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element as HTMLElement).cleanup;
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element).cleanup;
|
||||
},
|
||||
onKeyDown: ({ event }) => {
|
||||
if ([...DROPDOWN_NAVIGATION_KEYS, "Escape"].includes(event.key)) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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 { Underline } from "@tiptap/extension-underline";
|
||||
import { CharacterCount } from "@tiptap/extensions";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
// extensions
|
||||
import {
|
||||
@@ -76,7 +75,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
ListKeymap({ tabIndex }),
|
||||
CustomLinkExtension,
|
||||
CustomTypographyExtension,
|
||||
Underline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
|
||||
@@ -52,6 +52,7 @@ export const HeadingListExtension = Extension.create<unknown, HeadingExtensionSt
|
||||
this.editor.emit("update", {
|
||||
editor: this.editor,
|
||||
transaction: newState.tr,
|
||||
appendedTransactions: [],
|
||||
});
|
||||
|
||||
return null;
|
||||
@@ -60,8 +61,4 @@ export const HeadingListExtension = Extension.create<unknown, HeadingExtensionSt
|
||||
|
||||
return [plugin];
|
||||
},
|
||||
|
||||
getHeadings() {
|
||||
return this.storage.headings;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ export const renderMentionsDropdown =
|
||||
component.updateProps(props);
|
||||
if (!props.clientRect) return;
|
||||
cleanup();
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element as HTMLElement).cleanup;
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element).cleanup;
|
||||
},
|
||||
onKeyDown: ({ event }) => {
|
||||
if ([...DROPDOWN_NAVIGATION_KEYS, "Escape"].includes(event.key)) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { Placeholder } from "@tiptap/extensions";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// types
|
||||
|
||||
@@ -27,6 +27,8 @@ export const CustomStarterKitExtension = (args: TArgs) => {
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
link: false,
|
||||
listKeymap: false,
|
||||
paragraph: {
|
||||
HTMLAttributes: {
|
||||
class: "editor-paragraph-block",
|
||||
@@ -41,6 +43,6 @@ export const CustomStarterKitExtension = (args: TArgs) => {
|
||||
class:
|
||||
"text-custom-text-300 transition-all motion-reduce:transition-none motion-reduce:hover:transform-none duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]",
|
||||
},
|
||||
...(enableHistory ? {} : { history: false }),
|
||||
...(enableHistory ? {} : { undoRedo: false }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -94,8 +94,11 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
|
||||
?.chain()
|
||||
.setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true)
|
||||
.setMeta(CORE_EDITOR_META.INTENTIONAL_DELETION, true)
|
||||
.setContent(content, emitUpdate, {
|
||||
preserveWhitespace: true,
|
||||
.setContent(content, {
|
||||
emitUpdate,
|
||||
parseOptions: {
|
||||
preserveWhitespace: true,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
},
|
||||
|
||||
@@ -93,8 +93,11 @@ export const useEditor = (props: TEditorHookProps) => {
|
||||
const { uploadInProgress: isUploadInProgress } = editor.storage.utility;
|
||||
if (!editor.isDestroyed && !isUploadInProgress) {
|
||||
try {
|
||||
editor.commands.setContent(value, false, {
|
||||
preserveWhitespace: true,
|
||||
editor.commands.setContent(value, {
|
||||
emitUpdate: false,
|
||||
parseOptions: {
|
||||
preserveWhitespace: true,
|
||||
},
|
||||
});
|
||||
if (editor.state.selection) {
|
||||
const docLength = editor.state.doc.content.size;
|
||||
|
||||
@@ -57,6 +57,7 @@ export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHand
|
||||
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
|
||||
if (!nodeFileSetDetails || !src) return;
|
||||
try {
|
||||
// @ts-expect-error add proper types for storage
|
||||
editor.storage[nodeType]?.[nodeFileSetDetails.fileSetName]?.set(src, true);
|
||||
// update assets list storage value
|
||||
editor.commands.updateAssetsList?.({
|
||||
|
||||
@@ -63,6 +63,7 @@ export const TrackFileRestorationPlugin = (editor: Editor, restoreHandler: TFile
|
||||
const src = node.attrs.src;
|
||||
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
|
||||
if (!nodeFileSetDetails) return;
|
||||
// @ts-expect-error add proper types for storage
|
||||
const extensionFileSetStorage = editor.storage[nodeType]?.[nodeFileSetDetails.fileSetName];
|
||||
const wasDeleted = extensionFileSetStorage?.get(src);
|
||||
if (!nodeFileSetDetails || !src) return;
|
||||
|
||||
@@ -9,6 +9,7 @@ export const MarkdownClipboardPlugin = (editor: Editor): Plugin =>
|
||||
key: new PluginKey("markdownClipboard"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice) => {
|
||||
// @ts-expect-error tiptap-markdown types are not updated
|
||||
const markdownSerializer = editor.storage.markdown.serializer;
|
||||
const isTableRow = slice.content.firstChild?.type?.name === CORE_EXTENSIONS.TABLE_ROW;
|
||||
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
|
||||
|
||||
@@ -1,87 +1,10 @@
|
||||
import { DocumentCollaborativeEvents } from "@/constants/document-collaborative-events";
|
||||
|
||||
// Base type for all action payloads
|
||||
export type BaseActionPayload = {
|
||||
user_id?: string;
|
||||
};
|
||||
|
||||
// Generic type for creating specific payloads
|
||||
export type CreatePayload<T = Record<string, never>> = BaseActionPayload & T;
|
||||
|
||||
export type TDocumentEventEmitter = {
|
||||
on: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
|
||||
off: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
|
||||
};
|
||||
|
||||
export type TDocumentEventKey = keyof typeof DocumentCollaborativeEvents;
|
||||
export type TDocumentEventsClient = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["client"];
|
||||
export type TDocumentEventsServer = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["server"];
|
||||
|
||||
// In this version, our union of all events (the client names) is:
|
||||
export type TAllEventTypes = TDocumentEventsClient;
|
||||
|
||||
// Create a mapping from each client event to its payload type using key remapping.
|
||||
export type EventToPayloadMap = {
|
||||
[K in keyof typeof DocumentCollaborativeEvents as (typeof DocumentCollaborativeEvents)[K]["client"]]: (typeof DocumentCollaborativeEvents)[K]["payloadType"];
|
||||
};
|
||||
|
||||
// Common fields for every realtime event
|
||||
export type CommonRealtimeFields = {
|
||||
affectedPages: {
|
||||
currentPage: string;
|
||||
parentPage: string | null;
|
||||
descendantPages: string[];
|
||||
};
|
||||
workspace_slug: string;
|
||||
project_id?: string;
|
||||
teamspace_id?: string;
|
||||
user_id: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
// Helper function to create a realtime event in a type‑safe way.
|
||||
export function createRealtimeEvent<T extends keyof EventToPayloadMap>(
|
||||
opts: ApiServerPayload<T>
|
||||
): CommonRealtimeFields & BroadcastedEvent<T> {
|
||||
return {
|
||||
affectedPages: {
|
||||
currentPage: opts.page_id || "",
|
||||
parentPage: opts.parent_id || null,
|
||||
descendantPages: opts.descendants_ids || [],
|
||||
},
|
||||
workspace_slug: opts.workspace_slug,
|
||||
project_id: opts.project_id || "",
|
||||
teamspace_id: opts.teamspace_id || "",
|
||||
user_id: opts.user_id,
|
||||
timestamp: new Date().toISOString(),
|
||||
action: opts.action,
|
||||
data: opts.data,
|
||||
};
|
||||
}
|
||||
|
||||
export type ApiServerPayload<T extends keyof EventToPayloadMap> = {
|
||||
action: T;
|
||||
descendants_ids: string[];
|
||||
page_id?: string;
|
||||
parent_id?: string;
|
||||
data: EventToPayloadMap[T];
|
||||
project_id?: string;
|
||||
teamspace_id?: string;
|
||||
workspace_slug: string;
|
||||
user_id: string;
|
||||
};
|
||||
|
||||
// Create a discriminated union for broadcast payloads.
|
||||
// For every key in EventToPayloadMap, we make a union member with the common fields.
|
||||
export type BroadcastPayloadUnion = {
|
||||
[K in keyof EventToPayloadMap]: ApiServerPayload<K>;
|
||||
}[keyof EventToPayloadMap];
|
||||
|
||||
export type BroadcastedEventUnion = {
|
||||
[K in keyof EventToPayloadMap]: BroadcastedEvent<K>;
|
||||
}[keyof EventToPayloadMap];
|
||||
|
||||
export type BroadcastedEvent<T extends keyof EventToPayloadMap = keyof EventToPayloadMap> = CommonRealtimeFields & {
|
||||
action: T;
|
||||
data: EventToPayloadMap[T];
|
||||
export type TDocumentEventEmitter = {
|
||||
on: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
|
||||
off: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@plane/eslint-config",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"files": [
|
||||
"library.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/hooks",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "React hooks that are shared across multiple apps internally",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/i18n",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "I18n shared across multiple apps internally",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/logger",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Logger shared across multiple apps internally",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/propel",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/services",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/shared-state",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Shared state shared across multiple apps internally",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/tailwind-config",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "common tailwind configuration across monorepo",
|
||||
"main": "tailwind.config.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/types",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TIssuePriorities } from "./issues";
|
||||
export type TDuplicateIssuePayload = {
|
||||
title: string;
|
||||
workspace_id: string;
|
||||
issue_id?: string | null;
|
||||
issue_id?: string;
|
||||
project_id?: string;
|
||||
description_stripped?: string;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/typescript-config",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@plane/ui",
|
||||
"description": "UI components shared across multiple apps internally",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"sideEffects": false,
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/utils",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.0",
|
||||
"description": "Helper functions shared across multiple apps internally",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
@@ -27,7 +27,7 @@
|
||||
"@plane/types": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "3.2.7",
|
||||
"isomorphic-dompurify": "^2.16.0",
|
||||
"lodash-es": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"react": "catalog:",
|
||||
|
||||
@@ -7,14 +7,9 @@ import { getWeekNumberOfDate, renderFormattedPayloadDate } from "./datetime";
|
||||
* @returns {ICalendarPayload} calendar payload to render the calendar
|
||||
* @param {ICalendarPayload | null} currentStructure current calendar payload
|
||||
* @param {Date} startDate date of the month to render
|
||||
* @param {EStartOfTheWeek} startOfWeek the day to start the week on
|
||||
* @description Returns calendar payload to render the calendar, if currentStructure is null, it will generate the payload for the month of startDate, else it will construct the payload for the month of startDate and append it to the currentStructure
|
||||
*/
|
||||
export const generateCalendarData = (
|
||||
currentStructure: ICalendarPayload | null,
|
||||
startDate: Date,
|
||||
startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY
|
||||
): ICalendarPayload => {
|
||||
export const generateCalendarData = (currentStructure: ICalendarPayload | null, startDate: Date): ICalendarPayload => {
|
||||
const calendarData: ICalendarPayload = currentStructure ?? {};
|
||||
|
||||
const startMonth = startDate.getMonth();
|
||||
@@ -24,15 +19,10 @@ export const generateCalendarData = (
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
const totalDaysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const firstDayOfMonthRaw = new Date(year, month, 1).getDay(); // Sunday is 0, Monday is 1, ..., Saturday is 6
|
||||
|
||||
// Adjust firstDayOfMonth based on startOfWeek preference
|
||||
// This calculates how many empty cells we need at the start of the calendar
|
||||
const firstDayOfMonth = (firstDayOfMonthRaw - startOfWeek + 7) % 7;
|
||||
const firstDayOfMonth = new Date(year, month, 1).getDay(); // Sunday is 0, Monday is 1, ..., Saturday is 6
|
||||
|
||||
calendarData[`y-${year}`] ||= {};
|
||||
// Always reset the month data to ensure clean regeneration with correct startOfWeek
|
||||
calendarData[`y-${year}`][`m-${month}`] = {};
|
||||
calendarData[`y-${year}`][`m-${month}`] ||= {};
|
||||
|
||||
const numWeeks = Math.ceil((totalDaysInMonth + firstDayOfMonth) / 7);
|
||||
|
||||
@@ -60,9 +50,7 @@ export const generateCalendarData = (
|
||||
};
|
||||
}
|
||||
|
||||
// Use sequential week index instead of calculated week number for the key
|
||||
// This ensures weeks are grouped correctly regardless of startOfWeek preference
|
||||
calendarData[`y-${year}`][`m-${month}`][`w-${week}`] = currentWeekObject;
|
||||
calendarData[`y-${year}`][`m-${month}`][`w-${weekNumber}`] = currentWeekObject;
|
||||
}
|
||||
|
||||
return calendarData;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import type { Content, JSONContent } from "@plane/types";
|
||||
|
||||
/**
|
||||
|
||||
Generated
+818
-552
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -27,10 +27,10 @@ catalog:
|
||||
"@types/node": 22.12.0
|
||||
typescript: 5.8.3
|
||||
tsdown: 0.15.5
|
||||
vite: 7.1.11
|
||||
vite: 7.1.7
|
||||
uuid: 13.0.0
|
||||
"@tiptap/core": ^2.22.3
|
||||
"@tiptap/html": ^2.22.3
|
||||
"@tiptap/core": ^3.5.3
|
||||
"@tiptap/html": ^3.5.3
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- turbo
|
||||
|
||||
Reference in New Issue
Block a user