Compare commits

..
Author SHA1 Message Date
Vihar Kurama 52745cdc0d Adjust assignee dropdown to hide suspended users 2025-10-23 12:24:10 +01:00
e710f5b278 [WEB-5196] chore: switch from isomorphic-dompurify to dompurify (#7983)
* [WEB-5196] chore: switch from isomorphic-dompurify to dompurify

Replace isomorphic-dompurify with dompurify package in utils.
This change simplifies the dependency and uses the canonical
DOMPurify package directly.

* fix: removing dompurify from the space app as dependency

* chore: remove unused import

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-10-23 16:33:32 +05:30
sriramveeraghanta 80bd1b6dcd Merge branch 'canary' of github.com:makeplane/plane into preview 2025-10-23 15:43:16 +05:30
b34c9ca04f fix: live server secret key app version env variables (#7997)
* fix: live server secret key app version env variables

* fix: revert to stable version

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-23 15:42:43 +05:30
sriramveeraghanta ad16ea1440 Merge branch 'canary' of github.com:makeplane/plane into preview 2025-10-23 14:54:09 +05:30
sriramveeraghanta addf3c4104 chore: added live server secret key in community docker file 2025-10-23 14:53:41 +05:30
Anmol Singh BhatiaandGitHub 38cdf756a5 [WEB-5158] chore: calendar start of week alignment fix and code refactoring #7978 2025-10-23 13:59:29 +05:30
Bavisetti NarayanandGitHub f94da68597 [WEB-5153] chore: optimised the cycle transfer issues (#7969)
* chore: optimised the cycle transfer issues

* chore: added more vlaidation in transfer

* chore: improve the comments
2025-10-23 00:29:32 +05:30
Bavisetti NarayanandGitHub 68aa2fe0b8 [WIKI-553] chore: improved pages components tracking (#7966)
* chore: page components tracking

* chore: changed the transaction task

* chore: added logger for description html
2025-10-23 00:29:05 +05:30
Vamsi KrishnaandGitHub 5fa9943b66 [WEB-5141] chore: project features refactor (#7960)
* chore: project features refactor

* fix: enable/disable condition for toggle

* fix: dependencies

* chore: lint fix

* chore: lint fix

* chore: added fallback exports
2025-10-23 00:27:16 +05:30
Anmol Singh BhatiaandGitHub 772b5c5719 [WEB-5201] chore: work item and inbox modal refactor (#7990)
* chore: work item and inbbox modal refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor
2025-10-22 21:11:08 +05:30
NikhilandGitHub d6657d5168 [WEB-5207] fix: update date formatting in IssueExportSchema for cycle start and end dates #7992 2025-10-22 17:11:43 +05:30
sriramveeraghanta 1b43efbc2a chore: version bump 2025-10-21 19:14:43 +05:30
64781be7d2 regression: downgrade to tiptap v2 (#7982)
* chore: downgrade to tiptap v2

* fix: revert back to hocuspocus

* fix: collaboration events added

* fix: lock unlock issues

* fix: build errors

* fix: type errors

* fix: graceful shutdown

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
2025-10-21 18:28:16 +05:30
sriramveeraghanta 59022b6beb fix: vite package upgrade 2025-10-21 14:41:34 +05:30
163 changed files with 3399 additions and 2726 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "1.0.0",
"version": "1.1.0",
"license": "AGPL-3.0",
"private": true,
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "1.0.0",
"version": "1.1.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+94 -423
View File
@@ -12,13 +12,7 @@ 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
@@ -47,7 +41,7 @@ from plane.db.models import (
ProjectMember,
UserFavorite,
)
from plane.utils.analytics_plot import burndown_plot
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
@@ -201,7 +195,9 @@ 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,
@@ -258,7 +254,9 @@ 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),
@@ -304,11 +302,17 @@ 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")
@@ -351,7 +355,9 @@ 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,
)
@@ -499,7 +505,9 @@ 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(
@@ -512,14 +520,20 @@ 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")
@@ -527,7 +541,9 @@ 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()
):
@@ -584,7 +600,11 @@ 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",
@@ -604,7 +624,9 @@ 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)
@@ -742,7 +764,9 @@ 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(
@@ -761,7 +785,9 @@ 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"},
@@ -792,7 +818,9 @@ 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)
@@ -855,7 +883,9 @@ 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()
@@ -892,7 +922,9 @@ 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(
@@ -922,10 +954,13 @@ 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(
@@ -937,9 +972,13 @@ 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))
@@ -990,7 +1029,9 @@ 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()),
@@ -1066,7 +1107,9 @@ 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(
@@ -1171,406 +1214,34 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
{"error": "New Cycle Id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
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,
),
)
)
)
old_cycle = old_cycle.first()
estimate_type = Project.objects.filter(
old_cycle = Cycle.objects.get(
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")
)
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,
pk=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():
# 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 cycle where the issues are transferred is already completed"},
{"error": "The old cycle is not completed yet"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle_issues = CycleIssue.objects.filter(
cycle_id=cycle_id,
# Call the utility function to handle the transfer
result = transfer_cycle_issues(
slug=slug,
project_id=project_id,
workspace__slug=slug,
issue__state__group__in=["backlog", "unstarted", "started"],
cycle_id=cycle_id,
new_cycle_id=new_cycle_id,
request=request,
user_id=self.request.user.id,
)
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),
}
# Handle the result
if result.get("success"):
return Response({"message": "Success"}, status=status.HTTP_200_OK)
else:
return Response(
{"error": result.get("error")},
status=status.HTTP_400_BAD_REQUEST,
)
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)
+108 -425
View File
@@ -51,6 +51,7 @@ 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
@@ -96,7 +97,9 @@ 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(
@@ -147,7 +150,8 @@ 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")),
@@ -166,7 +170,11 @@ 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())),
)
@@ -197,7 +205,9 @@ 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
@@ -264,10 +274,16 @@ 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 = (
@@ -307,7 +323,9 @@ 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(
@@ -323,13 +341,17 @@ 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(
@@ -337,21 +359,29 @@ 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(
@@ -451,7 +481,9 @@ 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
@@ -473,7 +505,11 @@ 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",
@@ -524,7 +560,9 @@ 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,
@@ -597,409 +635,23 @@ class TransferCycleIssueEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
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")
)
# 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,
# Transfer cycle issues and create progress snapshot
result = transfer_cycle_issues(
slug=slug,
project_id=project_id,
plot_type="issues",
cycle_id=cycle_id,
new_cycle_id=new_cycle_id,
request=request,
user_id=request.user.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():
# Handle error response
if result.get("error"):
return Response(
{"error": "The cycle where the issues are transferred is already completed"},
{"error": result["error"]},
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)
@@ -1014,8 +666,12 @@ 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
)
@@ -1039,9 +695,13 @@ 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",
@@ -1087,7 +747,9 @@ 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:
@@ -1147,11 +809,22 @@ 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,
@@ -1169,7 +842,9 @@ 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",
@@ -1252,7 +927,9 @@ 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()),
@@ -1287,7 +964,9 @@ 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()),
@@ -1389,7 +1068,11 @@ 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",
+17 -8
View File
@@ -137,7 +137,11 @@ class PageViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
# capture the page transaction
page_transaction.delay(request.data, None, serializer.data["id"])
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=None,
page_id=serializer.data["id"],
)
page = self.get_queryset().get(pk=serializer.data["id"])
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -168,11 +172,8 @@ class PageViewSet(BaseViewSet):
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data,
old_value=json.dumps(
{"description_html": page_description},
cls=DjangoJSONEncoder,
),
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page_description,
page_id=page_id,
)
@@ -504,7 +505,11 @@ class PagesDescriptionViewSet(BaseViewSet):
if serializer.is_valid():
# Capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(new_value=request.data, old_value=existing_instance, page_id=page_id)
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page.description_html,
page_id=page_id,
)
# Update the page using serializer
updated_page = serializer.save()
@@ -550,7 +555,11 @@ class PageDuplicateEndpoint(BaseAPIView):
updated_by_id=page.updated_by_id,
)
page_transaction.delay({"description_html": page.description_html}, None, page.id)
page_transaction.delay(
new_description_html=page.description_html,
old_description_html=None,
page_id=page.id,
)
# Copy the s3 objects uploaded in the page
copy_s3_objects_of_description_and_assets.delay(
+105 -43
View File
@@ -1,5 +1,5 @@
# Python imports
import json
import logging
# Django imports
from django.utils import timezone
@@ -7,72 +7,134 @@ from django.utils import timezone
# Third-party imports
from bs4 import BeautifulSoup
# Module imports
from plane.db.models import Page, PageLog
# App imports
from celery import shared_task
from plane.db.models import Page, PageLog
from plane.utils.exception_logger import log_exception
logger = logging.getLogger("plane.worker")
def extract_components(value, tag):
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.
"""
try:
mentions = []
html = value.get("description_html")
soup = BeautifulSoup(html, "html.parser")
mention_tags = soup.find_all(tag)
if not description_html:
return {component: [] for component in component_map.keys()}
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)
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
return mentions
except Exception:
return []
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)
@shared_task
def page_transaction(new_value, old_value, page_id):
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.
"""
try:
page = Page.objects.get(pk=page_id)
new_page_mention = PageLog.objects.filter(page_id=page_id).exists()
old_value = json.loads(old_value) if old_value else {}
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)
new_transactions = []
deleted_transaction_ids = set()
# 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)
for component in component_map.keys():
old_entities = old_components[component]
new_entities = new_components[component]
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)
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_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(),
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,
)
)
for mention in new_mentions
if mention["id"] not in old_mention_ids or not new_page_mention
# Bulk insert and cleanup
if new_transactions:
PageLog.objects.bulk_create(
new_transactions, batch_size=50, ignore_conflicts=True
)
# Create new PageLog objects for new transactions
PageLog.objects.bulk_create(new_transactions, batch_size=10, ignore_conflicts=True)
if deleted_transaction_ids:
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
# Delete the removed transactions
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
except Page.DoesNotExist:
return
except Exception as e:
+7 -2
View File
@@ -4,7 +4,9 @@ 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
@@ -54,7 +56,9 @@ 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
@@ -232,8 +236,9 @@ 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(
f"HTML sanitization removals: {summary}",
ValueError(f"HTML sanitization removals: {summary}"),
warning=True,
)
return True, None, clean_html
@@ -0,0 +1,486 @@
# 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}
@@ -169,12 +169,16 @@ class IssueExportSchema(ExportSchema):
def prepare_cycle_start_date(self, i):
cycles_dict = self.context.get("cycles_dict") or {}
last_cycle = cycles_dict.get(i.id)
return last_cycle.cycle.start_date if last_cycle else None
if last_cycle and last_cycle.cycle.start_date:
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)
return last_cycle.cycle.end_date if last_cycle else None
if last_cycle and last_cycle.cycle.end_date:
return self._format_date(last_cycle.cycle.end_date)
return ""
def prepare_parent(self, i):
if not i.parent:
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "1.0.0",
"version": "1.1.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": "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",
"@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",
"@plane/decorators": "workspace:*",
"@plane/editor": "workspace:*",
"@plane/logger": "workspace:*",
+60 -17
View File
@@ -6,22 +6,17 @@ import {
} from "@plane/editor";
// logger
import { logger } from "@plane/logger";
// lib
import { AppError } from "@/lib/errors";
// services
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 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) => {
const fetchDocument = async ({ context, documentName: pageId, instance }: FetchPayloadWithContext) => {
try {
const service = getPageService(context.documentType, context);
// fetch details
@@ -38,12 +33,22 @@ const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWith
// return binary data
return binaryData;
} catch (error) {
logger.error("DATABASE_EXTENSION: Error in fetching document", error);
throw normalizeToError(error, `Failed to fetch document: ${pageId}`);
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;
}
};
const storeDocument = async ({ context, state: pageBinaryData, documentName: pageId }: StorePayloadWithContext) => {
const storeDocument = async ({
context,
state: pageBinaryData,
documentName: pageId,
instance,
}: StorePayloadWithContext) => {
try {
const service = getPageService(context.documentType, context);
// convert binary data to all formats
@@ -57,8 +62,46 @@ const storeDocument = async ({ context, state: pageBinaryData, documentName: pag
};
await service.updateDescriptionBinary(pageId, payload);
} catch (error) {
logger.error("DATABASE_EXTENSION: Error in updating document:", error);
throw normalizeToError(error, `Failed to update document: ${pageId}`);
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;
}
};
@@ -0,0 +1,203 @@
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`);
}
};
+114 -10
View File
@@ -1,30 +1,134 @@
import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis";
import { OutgoingMessage } from "@hocuspocus/server";
// redis
import { OutgoingMessage, type onConfigurePayload } from "@hocuspocus/server";
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
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 Error("Redis client not initialized");
throw new AppError("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() });
}
public broadcastToDocument(documentName: string, payload: any): Promise<number> {
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> {
const stringPayload = typeof payload === "string" ? payload : JSON.stringify(payload);
const message = new OutgoingMessage(documentName).writeBroadcastStateless(stringPayload);
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())
);
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;
}
}
+50
View File
@@ -0,0 +1,50 @@
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();
};
+14 -6
View File
@@ -2,6 +2,7 @@
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
@@ -35,8 +36,10 @@ export const onAuthenticate = async ({
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
// If token parsing fails, fallback to request headers
logger.error("AUTH: Token parsing failed, using request headers:", error);
const appError = new AppError(error, {
context: { operation: "onAuthenticate" },
});
logger.error("Token parsing failed, using request headers", appError);
} finally {
// If cookie is still not found, fallback to request headers
if (!cookie) {
@@ -45,7 +48,9 @@ export const onAuthenticate = async ({
}
if (!cookie || !userId) {
throw new Error("Credentials not provided");
const appError = new AppError("Credentials not provided", { code: "AUTH_MISSING_CREDENTIALS" });
logger.error("Credentials not provided", appError);
throw appError;
}
// set cookie in context, so it can be used throughout the ws connection
@@ -67,7 +72,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 Error("Authentication unsuccessful!");
throw new AppError("Authentication unsuccessful: User ID mismatch", { code: "AUTH_USER_MISMATCH" });
}
return {
@@ -77,7 +82,10 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string;
},
};
} catch (error) {
logger.error("AUTH: Token parsing failed, using request headers:", error);
throw Error("Authentication unsuccessful!");
const appError = new AppError(error, {
context: { operation: "handleAuthentication" },
});
logger.error("Authentication failed", appError);
throw new AppError("Authentication unsuccessful", { code: appError.code });
}
};
+73
View File
@@ -0,0 +1,73 @@
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";
}
}
+1 -1
View File
@@ -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);
}
+10 -7
View File
@@ -59,13 +59,20 @@ 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: true,
lazyConnect: false, // Connect immediately for reliability (duplicates inherit this)
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
@@ -94,10 +101,6 @@ 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) {
-1
View File
@@ -39,7 +39,6 @@ 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) {
+2 -3
View File
@@ -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,8 +21,7 @@ export abstract class APIService {
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
logger.error("AXIOS_ERROR:", error);
return Promise.reject(error);
return Promise.reject(new AppError(error));
}
);
}
+29 -9
View File
@@ -1,5 +1,7 @@
import { logger } from "@plane/logger";
import { TPage } from "@plane/types";
// services
import { AppError } from "@/lib/errors";
import { APIService } from "../api.service";
export type TPageDescriptionPayload = {
@@ -21,7 +23,11 @@ export abstract class PageCoreService extends APIService {
})
.then((response) => response?.data)
.catch((error) => {
throw error;
const appError = new AppError(error, {
context: { operation: "fetchDetails", pageId },
});
logger.error("Failed to fetch page details", appError);
throw appError;
});
}
@@ -35,7 +41,11 @@ export abstract class PageCoreService extends APIService {
})
.then((response) => response?.data)
.catch((error) => {
throw error;
const appError = new AppError(error, {
context: { operation: "fetchDescriptionBinary", pageId },
});
logger.error("Failed to fetch page description binary", appError);
throw appError;
});
}
@@ -50,7 +60,7 @@ export abstract class PageCoreService extends APIService {
// Early abort check
if (abortSignal?.aborted) {
throw new DOMException("Aborted", "AbortError");
throw new AppError(new DOMException("Aborted", "AbortError"));
}
// Create an abort listener that will reject the pending promise
@@ -58,7 +68,7 @@ export abstract class PageCoreService extends APIService {
const abortPromise = new Promise((_, reject) => {
if (abortSignal) {
abortListener = () => {
reject(new DOMException("Aborted", "AbortError"));
reject(new AppError(new DOMException("Aborted", "AbortError")));
};
abortSignal.addEventListener("abort", abortListener);
}
@@ -66,16 +76,22 @@ 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) => {
if (error.name === "AbortError") {
throw new DOMException("Aborted", "AbortError");
const appError = new AppError(error, {
context: { operation: "updatePageProperties", pageId },
});
if (appError.code === "ABORT_ERROR") {
throw appError;
}
throw error;
logger.error("Failed to update page properties", appError);
throw appError;
}),
abortPromise,
]);
@@ -93,7 +109,11 @@ export abstract class PageCoreService extends APIService {
})
.then((response) => response?.data)
.catch((error) => {
throw error;
const appError = new AppError(error, {
context: { operation: "updateDescriptionBinary", pageId },
});
logger.error("Failed to update page description binary", appError);
throw appError;
});
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { AppError } from "@/lib/errors";
import type { HocusPocusServerContext, TDocumentTypes } from "@/types";
// services
import { ProjectPageService } from "./project-page.service";
@@ -11,5 +12,5 @@ export const getPageService = (documentType: TDocumentTypes, context: HocusPocus
});
}
throw new Error(`Invalid document type ${documentType} provided.`);
throw new AppError(`Invalid document type ${documentType} provided.`);
};
@@ -1,3 +1,4 @@
import { AppError } from "@/lib/errors";
import { PageService } from "./extended.service";
interface ProjectPageServiceParams {
@@ -13,9 +14,9 @@ export class ProjectPageService extends PageService {
constructor(params: ProjectPageServiceParams) {
super();
const { workspaceSlug, projectId } = params;
if (!workspaceSlug || !projectId) throw new Error("Missing required fields.");
if (!workspaceSlug || !projectId) throw new AppError("Missing required fields.");
// validate cookie
if (!params.cookie) throw new Error("Cookie is required.");
if (!params.cookie) throw new AppError("Cookie is required.");
// set cookie
this.setHeader("Cookie", params.cookie);
// set base path
+7 -1
View File
@@ -1,6 +1,8 @@
// 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 {
@@ -22,7 +24,11 @@ export class UserService extends APIService {
})
.then((response) => response?.data)
.catch((error) => {
throw error;
const appError = new AppError(error, {
context: { operation: "currentUser" },
});
logger.error("Failed to fetch current user", appError);
throw appError;
});
}
}
+33 -19
View File
@@ -1,8 +1,9 @@
// 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;
@@ -20,28 +21,41 @@ async function startServer() {
startServer();
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", async (err: Error) => {
logger.error(`UNHANDLED REJECTION!`, err);
// Handle process signals
process.on("SIGTERM", async () => {
logger.info("Received SIGTERM signal. Initiating graceful shutdown...");
try {
// if (server) {
// await server.destroy();
// }
} finally {
// logger.info("Exiting process...");
// process.exit(1);
if (server) {
await server.destroy();
}
logger.info("Server shut down gracefully");
} catch (error) {
logger.error("Error during graceful shutdown:", error);
process.exit(1);
}
process.exit(0);
});
// Graceful shutdown on uncaught exception
process.on("uncaughtException", async (err: Error) => {
logger.error(`UNCAUGHT EXCEPTION!`, err);
process.on("SIGINT", async () => {
logger.info("Received SIGINT signal. Killing node process...");
try {
// if (server) {
// await server.destroy();
// }
} finally {
// logger.info("Exiting process...");
// process.exit(1);
if (server) {
await server.destroy();
}
logger.info("Server shut down gracefully");
} catch (error) {
logger.error("Error during graceful shutdown:", error);
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);
});
+143
View File
@@ -0,0 +1,143 @@
/**
* 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.";
}
+38
View File
@@ -0,0 +1,38 @@
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);
}
};
+34
View File
@@ -0,0 +1,34 @@
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;
}
};
+18 -80
View File
@@ -1,83 +1,21 @@
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);
export const generateTitleProsemirrorJson = (text: string) => {
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
...(text
? {
content: [
{
type: "text",
text,
},
],
}
: {}),
},
],
};
};
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);
};
-9
View File
@@ -1,5 +1,3 @@
import DOMPurify from "dompurify";
export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2");
const fallbackCopyTextToClipboard = (text: string) => {
@@ -50,13 +48,6 @@ 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 -2
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -31,7 +31,6 @@
"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",
@@ -8,7 +8,7 @@ import { useWorkspace } from "@/hooks/store/use-workspace";
// plane web components
import { WorkspaceActiveCyclesRoot } from "@/plane-web/components/active-cycles";
function WorkspaceActiveCyclesPage() {
const WorkspaceActiveCyclesPage = observer(() => {
const { currentWorkspace } = useWorkspace();
// derived values
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Active Cycles` : undefined;
@@ -19,6 +19,6 @@ function WorkspaceActiveCyclesPage() {
<WorkspaceActiveCyclesRoot />
</>
);
}
});
export default observer(WorkspaceActiveCyclesPage);
export default WorkspaceActiveCyclesPage;
@@ -22,14 +22,16 @@ import { useUserPermissions } from "@/hooks/store/user";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { getAnalyticsTabs } from "@/plane-web/components/analytics/tabs";
type AnalyticsPageProps = {
type Props = {
params: {
tabId: string;
workspaceSlug: string;
};
};
function AnalyticsPage({ params }: AnalyticsPageProps) {
const AnalyticsPage = observer((props: Props) => {
// props
const { params } = props;
const { tabId } = params;
// hooks
@@ -116,6 +118,6 @@ function AnalyticsPage({ params }: AnalyticsPageProps) {
)}
</>
);
}
});
export default observer(AnalyticsPage);
export default AnalyticsPage;
@@ -2,6 +2,7 @@
import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useTheme } from "next-themes";
import useSWR from "swr";
// plane imports
@@ -23,17 +24,10 @@ import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp";
import emptyIssueLight from "@/public/empty-state/search/issues-light.webp";
type IssueDetailsPageProps = {
params: {
workspaceSlug: string;
workItem: string;
};
};
function IssueDetailsPage({ params }: IssueDetailsPageProps) {
const { workspaceSlug, workItem } = params;
const IssueDetailsPage = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, workItem } = useParams();
// hooks
const { resolvedTheme } = useTheme();
// store hooks
@@ -45,23 +39,27 @@ function IssueDetailsPage({ params }: IssueDetailsPageProps) {
const { getProjectById } = useProject();
const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme();
const [projectIdentifier, sequence_id] = workItem.split("-");
const projectIdentifier = workItem?.toString().split("-")[0];
const sequence_id = workItem?.toString().split("-")[1];
// fetching issue details
const { data, isLoading, error } = useSWR(`ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}`, () =>
fetchIssueWithIdentifier(workspaceSlug, projectIdentifier, sequence_id)
const { data, isLoading, error } = useSWR(
workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null,
workspaceSlug && workItem
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
: null
);
const issueId = data?.id;
const projectId = data?.project_id;
// derived values
const issue = getIssueById(issueId || "") || undefined;
const issue = getIssueById(issueId?.toString() || "") || undefined;
const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined;
const issueLoader = !issue || isLoading;
const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined;
useWorkItemProperties(
projectId,
workspaceSlug,
workspaceSlug.toString(),
issueId,
issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES
);
@@ -115,13 +113,14 @@ function IssueDetailsPage({ params }: IssueDetailsPageProps) {
</div>
</Loader>
) : (
workspaceSlug &&
projectId &&
issueId && (
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
<IssueDetailRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={issueId.toString()}
is_archived={!!issue?.archived_at}
/>
</ProjectAuthWrapper>
@@ -129,6 +128,6 @@ function IssueDetailsPage({ params }: IssueDetailsPageProps) {
)}
</>
);
}
});
export default observer(IssueDetailsPage);
export default IssueDetailsPage;
@@ -1,19 +1,19 @@
"use client";
import { useParams } from "next/navigation";
// components
import { PageHead } from "@/components/core/page-title";
import { WorkspaceDraftIssuesRoot } from "@/components/issues/workspace-draft";
type WorkspaceDraftPageProps = {
params: {
workspaceSlug: string;
};
};
function WorkspaceDraftPage({ params }: WorkspaceDraftPageProps) {
const { workspaceSlug } = params;
const WorkspaceDraftPage = () => {
// router
const { workspaceSlug: routeWorkspaceSlug } = useParams();
const pageTitle = "Workspace Draft";
// derived values
const workspaceSlug = (routeWorkspaceSlug as string) || undefined;
if (!workspaceSlug) return null;
return (
<>
<PageHead title={pageTitle} />
@@ -22,6 +22,6 @@ function WorkspaceDraftPage({ params }: WorkspaceDraftPageProps) {
</div>
</>
);
}
};
export default WorkspaceDraftPage;
@@ -1,6 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
@@ -9,14 +10,8 @@ import { NotificationsRoot } from "@/components/workspace-notifications";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
type WorkspaceDashboardPageProps = {
params: {
workspaceSlug: string;
};
};
function WorkspaceDashboardPage({ params }: WorkspaceDashboardPageProps) {
const { workspaceSlug } = params;
const WorkspaceDashboardPage = observer(() => {
const { workspaceSlug } = useParams();
// plane hooks
const { t } = useTranslation();
// hooks
@@ -29,9 +24,9 @@ function WorkspaceDashboardPage({ params }: WorkspaceDashboardPageProps) {
return (
<>
<PageHead title={pageTitle} />
<NotificationsRoot workspaceSlug={workspaceSlug} />
<NotificationsRoot workspaceSlug={workspaceSlug?.toString()} />
</>
);
}
});
export default observer(WorkspaceDashboardPage);
export default WorkspaceDashboardPage;
@@ -12,7 +12,7 @@ import { useWorkspace } from "@/hooks/store/use-workspace";
// local components
import { WorkspaceDashboardHeader } from "./header";
function WorkspaceDashboardPage() {
const WorkspaceDashboardPage = observer(() => {
const { currentWorkspace } = useWorkspace();
const { t } = useTranslation();
// derived values
@@ -27,6 +27,6 @@ function WorkspaceDashboardPage() {
</ContentWrapper>
</>
);
}
});
export default observer(WorkspaceDashboardPage);
export default WorkspaceDashboardPage;
@@ -1,6 +1,7 @@
"use client";
import React from "react";
import { useParams } from "next/navigation";
// components
import { PageHead } from "@/components/core/page-title";
import { ProfileIssuesPage } from "@/components/profile/profile-issues";
@@ -11,22 +12,10 @@ const ProfilePageHeader = {
subscribed: "Profile - Subscribed",
};
function isValidProfileViewId(profileViewId: string): profileViewId is keyof typeof ProfilePageHeader {
return profileViewId in ProfilePageHeader;
}
const ProfileIssuesTypePage = () => {
const { profileViewId } = useParams() as { profileViewId: "assigned" | "subscribed" | "created" | undefined };
type ProfileIssuesTypePageProps = {
params: {
workspaceSlug: string;
userId: string;
profileViewId: string;
};
};
function ProfileIssuesTypePage({ params }: ProfileIssuesTypePageProps) {
const { profileViewId } = params;
if (!isValidProfileViewId(profileViewId)) return null;
if (!profileViewId) return null;
const header = ProfilePageHeader[profileViewId];
@@ -36,6 +25,6 @@ function ProfileIssuesTypePage({ params }: ProfileIssuesTypePageProps) {
<ProfileIssuesPage type={profileViewId} />
</>
);
}
};
export default ProfileIssuesTypePage;
@@ -15,7 +15,7 @@ import { useUserPermissions } from "@/hooks/store/user";
const PER_PAGE = 100;
function ProfileActivityPage() {
const ProfileActivityPage = observer(() => {
// states
const [pageCount, setPageCount] = useState(1);
const [totalPages, setTotalPages] = useState(0);
@@ -69,6 +69,6 @@ function ProfileActivityPage() {
</div>
</>
);
}
});
export default observer(ProfileActivityPage);
export default ProfileActivityPage;
@@ -1,7 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { useParams, usePathname } from "next/navigation";
import useSWR from "swr";
// components
import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
@@ -22,16 +22,14 @@ import { ProfileNavbar } from "./navbar";
const userService = new UserService();
type UseProfileLayoutProps = {
type Props = {
children: React.ReactNode;
params: {
workspaceSlug: string;
userId: string;
};
};
function UseProfileLayout({ children, params }: UseProfileLayoutProps) {
const { workspaceSlug, userId } = params;
const UseProfileLayout: React.FC<Props> = observer((props) => {
const { children } = props;
// router
const { workspaceSlug, userId } = useParams();
const pathname = usePathname();
// store hooks
const { allowPermissions } = useUserPermissions();
@@ -45,8 +43,11 @@ function UseProfileLayout({ children, params }: UseProfileLayoutProps) {
const windowSize = useSize();
const isSmallerScreen = windowSize[0] >= 768;
const { data: userProjectsData } = useSWR(USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug, userId), () =>
userService.getUserProfileProjectsSegregation(workspaceSlug, userId)
const { data: userProjectsData } = useSWR(
workspaceSlug && userId ? USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString()) : null,
workspaceSlug && userId
? () => userService.getUserProfileProjectsSegregation(workspaceSlug.toString(), userId.toString())
: null
);
// derived values
const isAuthorizedPath =
@@ -92,6 +93,6 @@ function UseProfileLayout({ children, params }: UseProfileLayoutProps) {
</div>
</>
);
}
});
export default observer(UseProfileLayout);
export default UseProfileLayout;
@@ -1,5 +1,6 @@
"use client";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane imports
import { GROUP_CHOICES } from "@plane/constants";
@@ -19,19 +20,13 @@ import { USER_PROFILE_DATA } from "@/constants/fetch-keys";
import { UserService } from "@/services/user.service";
const userService = new UserService();
type ProfileOverviewPageProps = {
params: {
workspaceSlug: string;
userId: string;
};
};
export default function ProfileOverviewPage({ params }: ProfileOverviewPageProps) {
const { workspaceSlug, userId } = params;
export default function ProfileOverviewPage() {
const { workspaceSlug, userId } = useParams();
const { t } = useTranslation();
const { data: userProfile } = useSWR(USER_PROFILE_DATA(workspaceSlug, userId), () =>
userService.getUserProfileData(workspaceSlug, userId)
const { data: userProfile } = useSWR(
workspaceSlug && userId ? USER_PROFILE_DATA(workspaceSlug.toString(), userId.toString()) : null,
workspaceSlug && userId ? () => userService.getUserProfileData(workspaceSlug.toString(), userId.toString()) : null
);
const stateDistribution: IUserStateDistribution[] = Object.keys(GROUP_CHOICES).map((key) => {
@@ -1,6 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { PageHead } from "@/components/core/page-title";
import { ArchivedCycleLayoutRoot } from "@/components/cycles/archived-cycles";
@@ -8,19 +9,13 @@ import { ArchivedCyclesHeader } from "@/components/cycles/archived-cycles/header
// hooks
import { useProject } from "@/hooks/store/use-project";
type ProjectArchivedCyclesPageProps = {
params: {
workspaceSlug: string;
projectId: string;
};
};
function ProjectArchivedCyclesPage({ params }: ProjectArchivedCyclesPageProps) {
const { projectId } = params;
const ProjectArchivedCyclesPage = observer(() => {
// router
const { projectId } = useParams();
// store hooks
const { getProjectById } = useProject();
// derived values
const project = getProjectById(projectId);
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name && `${project?.name} - Archived cycles`;
return (
@@ -32,6 +27,6 @@ function ProjectArchivedCyclesPage({ params }: ProjectArchivedCyclesPageProps) {
</div>
</>
);
}
});
export default observer(ProjectArchivedCyclesPage);
export default ProjectArchivedCyclesPage;
@@ -1,6 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// ui
import { Loader } from "@plane/ui";
@@ -12,16 +13,10 @@ import { IssueDetailRoot } from "@/components/issues/issue-detail";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
type ArchivedIssueDetailsPageProps = {
params: {
workspaceSlug: string;
projectId: string;
archivedIssueId: string;
};
};
function ArchivedIssueDetailsPage({ params }: ArchivedIssueDetailsPageProps) {
const { workspaceSlug, projectId, archivedIssueId } = params;
const ArchivedIssueDetailsPage = observer(() => {
// router
const { workspaceSlug, projectId, archivedIssueId } = useParams();
// states
// hooks
const {
fetchIssue,
@@ -30,13 +25,18 @@ function ArchivedIssueDetailsPage({ params }: ArchivedIssueDetailsPageProps) {
const { getProjectById } = useProject();
const { isLoading } = useSWR(`ARCHIVED_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${archivedIssueId}`, () =>
fetchIssue(workspaceSlug, projectId, archivedIssueId)
const { isLoading } = useSWR(
workspaceSlug && projectId && archivedIssueId
? `ARCHIVED_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${archivedIssueId}`
: null,
workspaceSlug && projectId && archivedIssueId
? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString())
: null
);
// derived values
const issue = getIssueById(archivedIssueId);
const project = issue ? getProjectById(issue.project_id ?? "") : undefined;
const issue = archivedIssueId ? getIssueById(archivedIssueId.toString()) : undefined;
const project = issue ? getProjectById(issue?.project_id ?? "") : undefined;
const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined;
if (!issue) return <></>;
@@ -64,17 +64,19 @@ function ArchivedIssueDetailsPage({ params }: ArchivedIssueDetailsPageProps) {
) : (
<div className="flex h-full overflow-hidden">
<div className="h-full w-full space-y-3 divide-y-2 divide-custom-border-200 overflow-y-auto">
<IssueDetailRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={archivedIssueId}
is_archived
/>
{workspaceSlug && projectId && archivedIssueId && (
<IssueDetailRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={archivedIssueId.toString()}
is_archived
/>
)}
</div>
</div>
)}
</>
);
}
});
export default observer(ArchivedIssueDetailsPage);
export default ArchivedIssueDetailsPage;
@@ -1,6 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { PageHead } from "@/components/core/page-title";
import { ArchivedIssuesHeader } from "@/components/issues/archived-issues-header";
@@ -8,19 +9,13 @@ import { ArchivedIssueLayoutRoot } from "@/components/issues/issue-layouts/roots
// hooks
import { useProject } from "@/hooks/store/use-project";
type ProjectArchivedIssuesPageProps = {
params: {
workspaceSlug: string;
projectId: string;
};
};
function ProjectArchivedIssuesPage({ params }: ProjectArchivedIssuesPageProps) {
const { projectId } = params;
const ProjectArchivedIssuesPage = observer(() => {
// router
const { projectId } = useParams();
// store hooks
const { getProjectById } = useProject();
// derived values
const project = getProjectById(projectId);
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name && `${project?.name} - Archived work items`;
return (
@@ -32,6 +27,6 @@ function ProjectArchivedIssuesPage({ params }: ProjectArchivedIssuesPageProps) {
</div>
</>
);
}
});
export default observer(ProjectArchivedIssuesPage);
export default ProjectArchivedIssuesPage;
@@ -1,25 +1,20 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { PageHead } from "@/components/core/page-title";
import { ArchivedModuleLayoutRoot, ArchivedModulesHeader } from "@/components/modules";
// hooks
import { useProject } from "@/hooks/store/use-project";
type ProjectArchivedModulesPageProps = {
params: {
workspaceSlug: string;
projectId: string;
};
};
function ProjectArchivedModulesPage({ params }: ProjectArchivedModulesPageProps) {
const { projectId } = params;
const ProjectArchivedModulesPage = observer(() => {
// router
const { projectId } = useParams();
// store hooks
const { getProjectById } = useProject();
// derived values
const project = getProjectById(projectId);
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name && `${project?.name} - Archived modules`;
return (
@@ -31,6 +26,6 @@ function ProjectArchivedModulesPage({ params }: ProjectArchivedModulesPageProps)
</div>
</>
);
}
});
export default observer(ProjectArchivedModulesPage);
export default ProjectArchivedModulesPage;
@@ -1,6 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { cn } from "@plane/utils";
// components
@@ -17,18 +18,10 @@ import useLocalStorage from "@/hooks/use-local-storage";
// assets
import emptyCycle from "@/public/empty-state/cycle.svg";
type CycleDetailPageProps = {
params: {
workspaceSlug: string;
projectId: string;
cycleId: string;
};
};
function CycleDetailPage({ params }: CycleDetailPageProps) {
const { workspaceSlug, projectId, cycleId } = params;
const CycleDetailPage = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, projectId, cycleId } = useParams();
// store hooks
const { getCycleById, loader } = useCycle();
const { getProjectById } = useProject();
@@ -37,14 +30,14 @@ function CycleDetailPage({ params }: CycleDetailPageProps) {
const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", false);
useCyclesDetails({
workspaceSlug,
projectId,
cycleId,
workspaceSlug: workspaceSlug?.toString(),
projectId: projectId.toString(),
cycleId: cycleId.toString(),
});
// derived values
const isSidebarCollapsed = storedValue ? (storedValue === true ? true : false) : false;
const cycle = getCycleById(cycleId);
const project = getProjectById(projectId);
const cycle = cycleId ? getCycleById(cycleId.toString()) : undefined;
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name && cycle?.name ? `${project?.name} - ${cycle?.name}` : undefined;
/**
@@ -85,9 +78,9 @@ function CycleDetailPage({ params }: CycleDetailPageProps) {
>
<CycleDetailsSidebar
handleClose={toggleSidebar}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
cycleId={cycleId.toString()}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
</div>
)}
@@ -96,6 +89,6 @@ function CycleDetailPage({ params }: CycleDetailPageProps) {
)}
</>
);
}
});
export default observer(CycleDetailPage);
export default CycleDetailPage;
@@ -2,6 +2,7 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
@@ -25,15 +26,7 @@ import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
type ProjectCyclesPageProps = {
params: {
workspaceSlug: string;
projectId: string;
};
};
function ProjectCyclesPage({ params }: ProjectCyclesPageProps) {
const { workspaceSlug, projectId } = params;
const ProjectCyclesPage = observer(() => {
// states
const [createModal, setCreateModal] = useState(false);
// store hooks
@@ -41,6 +34,7 @@ function ProjectCyclesPage({ params }: ProjectCyclesPageProps) {
const { getProjectById, currentProjectDetails } = useProject();
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
// plane hooks
const { t } = useTranslation();
// cycle filters hook
@@ -48,7 +42,7 @@ function ProjectCyclesPage({ params }: ProjectCyclesPageProps) {
const { allowPermissions } = useUserPermissions();
// derived values
const totalCycles = currentProjectCycleIds?.length ?? 0;
const project = getProjectById(projectId);
const project = projectId ? getProjectById(projectId?.toString()) : undefined;
const pageTitle = project?.name ? `${project?.name} - ${t("common.cycles", { count: 2 })}` : undefined;
const hasAdminLevelPermission = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
const hasMemberLevelPermission = allowPermissions(
@@ -58,14 +52,17 @@ function ProjectCyclesPage({ params }: ProjectCyclesPageProps) {
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/cycles" });
const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => {
if (!projectId) return;
let newValues = currentProjectFilters?.[key] ?? [];
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value);
updateFilters(projectId, { [key]: newValues });
updateFilters(projectId.toString(), { [key]: newValues });
};
if (!workspaceSlug || !projectId) return <></>;
// No access to cycle
if (currentProjectDetails?.cycle_view === false)
return (
@@ -92,8 +89,8 @@ function ProjectCyclesPage({ params }: ProjectCyclesPageProps) {
<PageHead title={pageTitle} />
<div className="w-full h-full">
<CycleCreateUpdateModal
workspaceSlug={workspaceSlug}
projectId={projectId}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isOpen={createModal}
handleClose={() => setCreateModal(false)}
/>
@@ -123,18 +120,18 @@ function ProjectCyclesPage({ params }: ProjectCyclesPageProps) {
<Header variant={EHeaderVariant.TERNARY}>
<CycleAppliedFiltersList
appliedFilters={currentProjectFilters ?? {}}
handleClearAllFilters={() => clearAllFilters(projectId)}
handleClearAllFilters={() => clearAllFilters(projectId.toString())}
handleRemoveFilter={handleRemoveFilter}
/>
</Header>
)}
<CyclesView workspaceSlug={workspaceSlug} projectId={projectId} />
<CyclesView workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</>
)}
</div>
</>
);
}
});
export default observer(ProjectCyclesPage);
export default ProjectCyclesPage;
@@ -1,6 +1,6 @@
"use client";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
// plane imports
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
@@ -15,17 +15,10 @@ import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
type ProjectInboxPageProps = {
params: {
workspaceSlug: string;
projectId: string;
};
};
function ProjectInboxPage({ params }: ProjectInboxPageProps) {
const { workspaceSlug, projectId } = params;
const ProjectInboxPage = observer(() => {
/// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
const searchParams = useSearchParams();
const navigationTab = searchParams.get("currentTab");
const inboxIssueId = searchParams.get("inboxIssueId");
@@ -79,15 +72,15 @@ function ProjectInboxPage({ params }: ProjectInboxPageProps) {
<PageHead title={pageTitle} />
<div className="w-full h-full overflow-hidden">
<InboxIssueRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxIssueId={inboxIssueId || undefined}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
inboxIssueId={inboxIssueId?.toString() || undefined}
inboxAccessible={currentProjectDetails?.inbox_view || false}
navigationTab={currentNavigationTab}
/>
</div>
</div>
);
}
});
export default observer(ProjectInboxPage);
export default ProjectInboxPage;
@@ -2,6 +2,7 @@
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useTheme } from "next-themes";
import useSWR from "swr";
import { useTranslation } from "@plane/i18n";
@@ -18,22 +19,17 @@ import { IssueService } from "@/services/issue/issue.service";
const issueService = new IssueService();
type IssueDetailsPageProps = {
params: {
workspaceSlug: string;
projectId: string;
issueId: string;
};
};
function IssueDetailsPage({ params }: IssueDetailsPageProps) {
const { workspaceSlug, projectId, issueId } = params;
const IssueDetailsPage = observer(() => {
const router = useAppRouter();
const { t } = useTranslation();
const { workspaceSlug, projectId, issueId } = useParams();
const { resolvedTheme } = useTheme();
const { data, isLoading, error } = useSWR(`ISSUE_DETAIL_META_${workspaceSlug}_${projectId}_${issueId}`, () =>
issueService.getIssueMetaFromURL(workspaceSlug, projectId, issueId)
const { data, isLoading, error } = useSWR(
workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_META_${workspaceSlug}_${projectId}_${issueId}` : null,
workspaceSlug && projectId && issueId
? () => issueService.getIssueMetaFromURL(workspaceSlug.toString(), projectId.toString(), issueId.toString())
: null
);
useEffect(() => {
@@ -63,6 +59,6 @@ function IssueDetailsPage({ params }: IssueDetailsPageProps) {
)}
</div>
);
}
});
export default observer(IssueDetailsPage);
export default IssueDetailsPage;
@@ -2,6 +2,7 @@
import { observer } from "mobx-react";
import Head from "next/head";
import { useParams } from "next/navigation";
// i18n
import { useTranslation } from "@plane/i18n";
// components
@@ -10,22 +11,19 @@ import { ProjectLayoutRoot } from "@/components/issues/issue-layouts/roots/proje
// hooks
import { useProject } from "@/hooks/store/use-project";
type ProjectIssuesPageProps = {
params: {
workspaceSlug: string;
projectId: string;
};
};
function ProjectIssuesPage({ params }: ProjectIssuesPageProps) {
const { projectId } = params;
const ProjectIssuesPage = observer(() => {
const { projectId } = useParams();
// i18n
const { t } = useTranslation();
// store
const { getProjectById } = useProject();
if (!projectId) {
return <></>;
}
// derived values
const project = getProjectById(projectId);
const project = getProjectById(projectId.toString());
const pageTitle = project?.name ? `${project?.name} - ${t("issue.label", { count: 2 })}` : undefined; // Count is for pluralization
return (
@@ -41,6 +39,6 @@ function ProjectIssuesPage({ params }: ProjectIssuesPageProps) {
</div>
</>
);
}
});
export default observer(ProjectIssuesPage);
export default ProjectIssuesPage;
@@ -1,6 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// components
import { cn } from "@plane/utils";
@@ -17,18 +18,10 @@ import useLocalStorage from "@/hooks/use-local-storage";
// assets
import emptyModule from "@/public/empty-state/module.svg";
type ModuleIssuesPageProps = {
params: {
workspaceSlug: string;
projectId: string;
moduleId: string;
};
};
function ModuleIssuesPage({ params }: ModuleIssuesPageProps) {
const { workspaceSlug, projectId, moduleId } = params;
const ModuleIssuesPage = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, projectId, moduleId } = useParams();
// store hooks
const { fetchModuleDetails, getModuleById } = useModule();
const { getProjectById } = useProject();
@@ -37,18 +30,22 @@ function ModuleIssuesPage({ params }: ModuleIssuesPageProps) {
const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false");
const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false;
// fetching module details
const { error } = useSWR(`CURRENT_MODULE_DETAILS_${moduleId}`, () =>
fetchModuleDetails(workspaceSlug, projectId, moduleId)
const { error } = useSWR(
workspaceSlug && projectId && moduleId ? `CURRENT_MODULE_DETAILS_${moduleId.toString()}` : null,
workspaceSlug && projectId && moduleId
? () => fetchModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString())
: null
);
// derived values
const projectModule = getModuleById(moduleId);
const project = getProjectById(projectId);
const projectModule = moduleId ? getModuleById(moduleId.toString()) : undefined;
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name && projectModule?.name ? `${project?.name} - ${projectModule?.name}` : undefined;
const toggleSidebar = () => {
setValue(`${!isSidebarCollapsed}`);
};
if (!workspaceSlug || !projectId || !moduleId) return <></>;
// const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
@@ -80,13 +77,13 @@ function ModuleIssuesPage({ params }: ModuleIssuesPageProps) {
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
}}
>
<ModuleAnalyticsSidebar moduleId={moduleId} handleClose={toggleSidebar} />
<ModuleAnalyticsSidebar moduleId={moduleId.toString()} handleClose={toggleSidebar} />
</div>
)}
</div>
)}
</>
);
}
});
export default observer(ModuleIssuesPage);
export default ModuleIssuesPage;
@@ -2,6 +2,7 @@
import { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
@@ -20,17 +21,10 @@ import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
type ProjectModulesPageProps = {
params: {
workspaceSlug: string;
projectId: string;
};
};
function ProjectModulesPage({ params }: ProjectModulesPageProps) {
const { workspaceSlug, projectId } = params;
const ProjectModulesPage = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
// plane hooks
const { t } = useTranslation();
// store
@@ -39,23 +33,25 @@ function ProjectModulesPage({ params }: ProjectModulesPageProps) {
useModuleFilter();
const { allowPermissions } = useUserPermissions();
// derived values
const project = getProjectById(projectId);
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name ? `${project?.name} - Modules` : undefined;
const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/modules" });
const handleRemoveFilter = useCallback(
(key: keyof TModuleFilters, value: string | null) => {
if (!projectId) return;
let newValues = currentProjectFilters?.[key] ?? [];
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value);
updateFilters(projectId, { [key]: newValues });
updateFilters(projectId.toString(), { [key]: newValues });
},
[currentProjectFilters, projectId, updateFilters]
);
if (!workspaceSlug || !projectId) return <></>;
// No access to
if (currentProjectDetails?.module_view === false)
@@ -84,10 +80,11 @@ function ProjectModulesPage({ params }: ProjectModulesPageProps) {
<ModuleAppliedFiltersList
appliedFilters={currentProjectFilters ?? {}}
isFavoriteFilterApplied={currentProjectDisplayFilters?.favorites ?? false}
handleClearAllFilters={() => clearAllFilters(projectId)}
handleClearAllFilters={() => clearAllFilters(`${projectId}`)}
handleRemoveFilter={handleRemoveFilter}
handleDisplayFiltersUpdate={(val) => {
updateDisplayFilters(projectId, val);
if (!projectId) return;
updateDisplayFilters(projectId.toString(), val);
}}
alwaysAllowEditing
/>
@@ -96,6 +93,6 @@ function ProjectModulesPage({ params }: ProjectModulesPageProps) {
</div>
</>
);
}
});
export default observer(ProjectModulesPage);
export default ProjectModulesPage;
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane types
import { getButtonStyling } from "@plane/propel/button";
@@ -34,35 +35,27 @@ const projectPageVersionService = new ProjectPageVersionService();
const storeType = EPageStoreType.PROJECT;
type PageDetailsPageProps = {
params: {
workspaceSlug: string;
projectId: string;
pageId: string;
};
};
function PageDetailsPage({ params }: PageDetailsPageProps) {
const { workspaceSlug, projectId, pageId } = params;
const PageDetailsPage = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, projectId, pageId } = useParams();
// store hooks
const { createPage, fetchPageDetails } = usePageStore(storeType);
const page = usePage({
pageId,
pageId: pageId?.toString() ?? "",
storeType,
});
const { getWorkspaceBySlug } = useWorkspace();
const { uploadEditorAsset } = useEditorAsset();
// derived values
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
const { canCurrentUserAccessPage, id, name, updateDescription } = page ?? {};
// entity search handler
const fetchEntityCallback = useCallback(
async (payload: TSearchEntityRequestPayload) =>
await workspaceService.searchEntity(workspaceSlug, {
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId,
project_id: projectId?.toString() ?? "",
}),
[projectId, workspaceSlug]
);
@@ -70,8 +63,10 @@ function PageDetailsPage({ params }: PageDetailsPageProps) {
const { getEditorFileHandlers } = useEditorConfig();
// fetch page details
const { error: pageDetailsError } = useSWR(
`PAGE_DETAILS_${pageId}`,
() => fetchPageDetails(workspaceSlug, projectId, pageId),
workspaceSlug && projectId && pageId ? `PAGE_DETAILS_${pageId}` : null,
workspaceSlug && projectId && pageId
? () => fetchPageDetails(workspaceSlug?.toString(), projectId?.toString(), pageId.toString())
: null,
{
revalidateIfStale: true,
revalidateOnFocus: true,
@@ -83,18 +78,31 @@ function PageDetailsPage({ params }: PageDetailsPageProps) {
() => ({
create: createPage,
fetchAllVersions: async (pageId) => {
return await projectPageVersionService.fetchAllVersions(workspaceSlug, projectId, pageId);
if (!workspaceSlug || !projectId) return;
return await projectPageVersionService.fetchAllVersions(workspaceSlug.toString(), projectId.toString(), pageId);
},
fetchDescriptionBinary: async () => {
if (!id) return;
return await projectPageService.fetchDescriptionBinary(workspaceSlug, projectId, id);
if (!workspaceSlug || !projectId || !id) return;
return await projectPageService.fetchDescriptionBinary(workspaceSlug.toString(), projectId.toString(), id);
},
fetchEntity: fetchEntityCallback,
fetchVersionDetails: async (pageId, versionId) => {
return await projectPageVersionService.fetchVersionById(workspaceSlug, projectId, pageId, versionId);
if (!workspaceSlug || !projectId) return;
return await projectPageVersionService.fetchVersionById(
workspaceSlug.toString(),
projectId.toString(),
pageId,
versionId
);
},
restoreVersion: async (pageId, versionId) => {
await projectPageVersionService.restoreVersion(workspaceSlug, projectId, pageId, versionId);
if (!workspaceSlug || !projectId) return;
await projectPageVersionService.restoreVersion(
workspaceSlug.toString(),
projectId.toString(),
pageId,
versionId
);
},
getRedirectionLink: (pageId) => {
if (pageId) {
@@ -111,7 +119,7 @@ function PageDetailsPage({ params }: PageDetailsPageProps) {
const pageRootConfig: TPageRootConfig = useMemo(
() => ({
fileHandler: getEditorFileHandlers({
projectId,
projectId: projectId?.toString() ?? "",
uploadFile: async (blockId, file) => {
const { asset_id } = await uploadEditorAsset({
blockId,
@@ -120,13 +128,13 @@ function PageDetailsPage({ params }: PageDetailsPageProps) {
entity_type: EFileAssetType.PAGE_DESCRIPTION,
},
file,
projectId,
workspaceSlug,
projectId: projectId?.toString() ?? "",
workspaceSlug: workspaceSlug?.toString() ?? "",
});
return asset_id;
},
workspaceId,
workspaceSlug,
workspaceSlug: workspaceSlug?.toString() ?? "",
}),
}),
[getEditorFileHandlers, id, uploadEditorAsset, projectId, workspaceId, workspaceSlug]
@@ -135,8 +143,8 @@ function PageDetailsPage({ params }: PageDetailsPageProps) {
const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo(
() => ({
documentType: "project_page",
projectId,
workspaceSlug,
projectId: projectId?.toString() ?? "",
workspaceSlug: workspaceSlug?.toString() ?? "",
}),
[projectId, workspaceSlug]
);
@@ -170,7 +178,7 @@ function PageDetailsPage({ params }: PageDetailsPageProps) {
</div>
);
if (!page) return null;
if (!page || !workspaceSlug || !projectId) return null;
return (
<>
@@ -183,14 +191,14 @@ function PageDetailsPage({ params }: PageDetailsPageProps) {
storeType={storeType}
page={page}
webhookConnectionParams={webhookConnectionParams}
workspaceSlug={workspaceSlug}
projectId={projectId}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId?.toString()}
/>
<IssuePeekOverview />
</div>
</div>
</>
);
}
});
export default observer(PageDetailsPage);
export default PageDetailsPage;
@@ -1,6 +1,7 @@
"use client";
// component
import { useParams } from "next/navigation";
import useSWR from "swr";
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
@@ -9,19 +10,14 @@ import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
// local components
import { PageDetailsHeader } from "./header";
type ProjectPageDetailsLayoutProps = {
params: {
workspaceSlug: string;
projectId: string;
};
children: React.ReactNode;
};
export default function ProjectPageDetailsLayout({ params, children }: ProjectPageDetailsLayoutProps) {
const { workspaceSlug, projectId } = params;
export default function ProjectPageDetailsLayout({ children }: { children: React.ReactNode }) {
const { workspaceSlug, projectId } = useParams();
const { fetchPagesList } = usePageStore(EPageStoreType.PROJECT);
// fetching pages list
useSWR(`PROJECT_PAGES_${projectId}`, () => fetchPagesList(workspaceSlug, projectId));
useSWR(
workspaceSlug && projectId ? `PROJECT_PAGES_${projectId}` : null,
workspaceSlug && projectId ? () => fetchPagesList(workspaceSlug.toString(), projectId.toString()) : null
);
return (
<>
<AppHeader header={<PageDetailsHeader />} />
@@ -1,7 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
// plane imports
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
@@ -20,43 +20,31 @@ import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// plane web hooks
import { EPageStoreType } from "@/plane-web/hooks/store";
type ProjectPagesPageProps = {
params: {
workspaceSlug: string;
projectId: string;
};
};
function getPageType(type: string | null): TPageNavigationTabs {
switch (type) {
case "private":
return "private";
case "archived":
return "archived";
default:
return "public";
}
};
function ProjectPagesPage({ params }: ProjectPagesPageProps) {
const { workspaceSlug, projectId } = params;
const ProjectPagesPage = observer(() => {
// router
const router = useAppRouter();
const searchParams = useSearchParams();
const type = searchParams.get("type");
const { workspaceSlug, projectId } = useParams();
// plane hooks
const { t } = useTranslation();
// store hooks
const { getProjectById, currentProjectDetails } = useProject();
const { allowPermissions } = useUserPermissions();
// derived values
const project = getProjectById(projectId);
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name ? `${project?.name} - Pages` : undefined;
const pageType = getPageType(type);
const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/pages" });
const currentPageType = (): TPageNavigationTabs => {
const pageType = type?.toString();
if (pageType === "private") return "private";
if (pageType === "archived") return "archived";
return "public";
};
if (!workspaceSlug || !projectId) return <></>;
// No access to cycle
if (currentProjectDetails?.page_view === false)
@@ -80,15 +68,15 @@ function ProjectPagesPage({ params }: ProjectPagesPageProps) {
<>
<PageHead title={pageTitle} />
<PagesListView
pageType={pageType}
projectId={projectId}
pageType={currentPageType()}
projectId={projectId.toString()}
storeType={EPageStoreType.PROJECT}
workspaceSlug={workspaceSlug}
workspaceSlug={workspaceSlug.toString()}
>
<PagesListRoot pageType={pageType} storeType={EPageStoreType.PROJECT} />
<PagesListRoot pageType={currentPageType()} storeType={EPageStoreType.PROJECT} />
</PagesListView>
</>
);
}
});
export default observer(ProjectPagesPage);
export default ProjectPagesPage;
@@ -1,6 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// components
import { EmptyState } from "@/components/common/empty-state";
@@ -13,27 +14,24 @@ import { useProjectView } from "@/hooks/store/use-project-view";
import { useAppRouter } from "@/hooks/use-app-router";
import emptyView from "@/public/empty-state/view.svg";
type ProjectViewIssuesPageProps = {
params: {
workspaceSlug: string;
projectId: string;
viewId: string;
};
};
function ProjectViewIssuesPage({ params }: ProjectViewIssuesPageProps) {
const { workspaceSlug, projectId, viewId } = params;
const ProjectViewIssuesPage = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, projectId, viewId } = useParams();
// store hooks
const { fetchViewDetails, getViewById } = useProjectView();
const { getProjectById } = useProject();
// derived values
const projectView = getViewById(viewId);
const project = getProjectById(projectId);
const projectView = viewId ? getViewById(viewId.toString()) : undefined;
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name && projectView?.name ? `${project?.name} - ${projectView?.name}` : undefined;
const { error } = useSWR(`VIEW_DETAILS_${viewId}`, () => fetchViewDetails(workspaceSlug, projectId, viewId));
const { error } = useSWR(
workspaceSlug && projectId && viewId ? `VIEW_DETAILS_${viewId.toString()}` : null,
workspaceSlug && projectId && viewId
? () => fetchViewDetails(workspaceSlug.toString(), projectId.toString(), viewId.toString())
: null
);
if (error) {
return (
@@ -55,6 +53,6 @@ function ProjectViewIssuesPage({ params }: ProjectViewIssuesPageProps) {
<ProjectViewLayoutRoot />
</>
);
}
});
export default observer(ProjectViewIssuesPage);
export default ProjectViewIssuesPage;
@@ -2,6 +2,7 @@
import { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
@@ -22,17 +23,10 @@ import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
type ProjectViewsPageProps = {
params: {
workspaceSlug: string;
projectId: string;
};
};
function ProjectViewsPage({ params }: ProjectViewsPageProps) {
const { workspaceSlug, projectId } = params;
const ProjectViewsPage = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
// plane hooks
const { t } = useTranslation();
// store
@@ -40,7 +34,7 @@ function ProjectViewsPage({ params }: ProjectViewsPageProps) {
const { filters, updateFilters, clearAllFilters } = useProjectView();
const { allowPermissions } = useUserPermissions();
// derived values
const project = getProjectById(projectId);
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name ? `${project?.name} - Views` : undefined;
const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/views" });
@@ -64,6 +58,8 @@ function ProjectViewsPage({ params }: ProjectViewsPageProps) {
const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0;
if (!workspaceSlug || !projectId) return <></>;
// No access to
if (currentProjectDetails?.issue_views_view === false)
return (
@@ -99,6 +95,6 @@ function ProjectViewsPage({ params }: ProjectViewsPageProps) {
<ProjectViewsList />
</>
);
}
});
export default observer(ProjectViewsPage);
export default ProjectViewsPage;
@@ -1,7 +1,4 @@
import { ProjectPageRoot } from "@/plane-web/components/projects/page";
function ProjectsPage() {
return <ProjectPageRoot />;
}
const ProjectsPage = () => <ProjectPageRoot />;
export default ProjectsPage;
@@ -1,24 +1,18 @@
"use client";
import type { ReactNode } from "react";
import { useParams } from "next/navigation";
// plane web layouts
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
type ProjectDetailLayoutProps = {
children: ReactNode;
params: {
workspaceSlug: string;
projectId: string;
};
};
function ProjectDetailLayout({ children, params }: ProjectDetailLayoutProps) {
const { workspaceSlug, projectId } = params;
const ProjectDetailLayout = ({ children }: { children: ReactNode }) => {
// router
const { workspaceSlug, projectId } = useParams();
return (
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
{children}
</ProjectAuthWrapper>
);
}
};
export default ProjectDetailLayout;
@@ -1,7 +1,4 @@
import { ProjectPageRoot } from "@/plane-web/components/projects/page";
function ProjectsPage() {
return <ProjectPageRoot />;
}
const ProjectsPage = () => <ProjectPageRoot />;
export default ProjectsPage;
@@ -2,6 +2,7 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { DEFAULT_GLOBAL_VIEWS_LIST } from "@plane/constants";
// components
@@ -10,15 +11,9 @@ import { AllIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/all-
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
type GlobalViewIssuesPageProps = {
params: {
workspaceSlug: string;
globalViewId: string;
};
};
function GlobalViewIssuesPage({ params }: GlobalViewIssuesPageProps) {
const { globalViewId } = params;
const GlobalViewIssuesPage = observer(() => {
// router
const { globalViewId } = useParams();
// store hooks
const { currentWorkspace } = useWorkspace();
// states
@@ -36,6 +31,6 @@ function GlobalViewIssuesPage({ params }: GlobalViewIssuesPageProps) {
<AllIssueLayoutRoot isDefaultView={!!defaultView} isLoading={isLoading} toggleLoading={toggleLoading} />
</>
);
}
});
export default observer(GlobalViewIssuesPage);
export default GlobalViewIssuesPage;
@@ -14,7 +14,7 @@ import { GlobalViewsList } from "@/components/workspace/views/views-list";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
function WorkspaceViewsPage() {
const WorkspaceViewsPage = observer(() => {
const [query, setQuery] = useState("");
// store
const { currentWorkspace } = useWorkspace();
@@ -47,6 +47,6 @@ function WorkspaceViewsPage() {
</div>
</>
);
}
});
export default observer(WorkspaceViewsPage);
export default WorkspaceViewsPage;
@@ -12,7 +12,7 @@ import { useUserPermissions } from "@/hooks/store/user";
// plane web components
import { BillingRoot } from "@/plane-web/components/workspace/billing";
function BillingSettingsPage() {
const BillingSettingsPage = observer(() => {
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { currentWorkspace } = useWorkspace();
@@ -30,6 +30,6 @@ function BillingSettingsPage() {
<BillingRoot />
</SettingsContentWrapper>
);
}
});
export default observer(BillingSettingsPage);
export default BillingSettingsPage;
@@ -15,7 +15,7 @@ import SettingsHeading from "@/components/settings/heading";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
function ExportsPage() {
const ExportsPage = observer(() => {
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { currentWorkspace } = useWorkspace();
@@ -51,6 +51,6 @@ function ExportsPage() {
</div>
</SettingsContentWrapper>
);
}
});
export default observer(ExportsPage);
export default ExportsPage;
@@ -12,7 +12,7 @@ import { SettingsHeading } from "@/components/settings/heading";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
function ImportsPage() {
const ImportsPage = observer(() => {
// router
// store hooks
const { currentWorkspace } = useWorkspace();
@@ -32,6 +32,6 @@ function ImportsPage() {
</section>
</SettingsContentWrapper>
);
}
});
export default observer(ImportsPage);
export default ImportsPage;
@@ -1,5 +1,6 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
@@ -19,8 +20,9 @@ import { IntegrationService } from "@/services/integrations";
const integrationService = new IntegrationService();
function WorkspaceIntegrationsPage() {
const WorkspaceIntegrationsPage = observer(() => {
// router
const { workspaceSlug } = useParams();
// store hooks
const { currentWorkspace } = useWorkspace();
const { allowPermissions } = useUserPermissions();
@@ -28,8 +30,8 @@ function WorkspaceIntegrationsPage() {
// derived values
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Integrations` : undefined;
const { data: appIntegrations } = useSWR(isAdmin ? APP_INTEGRATIONS : null, () =>
isAdmin ? integrationService.getAppIntegrationsList() : null
const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () =>
workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null
);
if (!isAdmin) return <NotAuthorizedView section="settings" className="h-auto" />;
@@ -51,6 +53,6 @@ function WorkspaceIntegrationsPage() {
</section>
</SettingsContentWrapper>
);
}
});
export default observer(WorkspaceIntegrationsPage);
export default WorkspaceIntegrationsPage;
@@ -1,6 +1,6 @@
"use client";
import type { ReactNode } from "react";
import type { FC, ReactNode } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
// constants
@@ -15,11 +15,11 @@ import { useUserPermissions } from "@/hooks/store/user";
// local components
import { WorkspaceSettingsSidebar } from "./sidebar";
type WorkspaceSettingLayoutProps = {
export interface IWorkspaceSettingLayout {
children: ReactNode;
};
}
function WorkspaceSettingLayout(props: WorkspaceSettingLayoutProps) {
const WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) => {
const { children } = props;
// store hooks
const { workspaceUserInfo, getWorkspaceRoleByWorkspaceSlug } = useUserPermissions();
@@ -27,10 +27,10 @@ function WorkspaceSettingLayout(props: WorkspaceSettingLayoutProps) {
const pathname = usePathname();
// derived values
const { workspaceSlug, accessKey } = pathnameToAccessKey(pathname);
const userWorkspaceRole = getWorkspaceRoleByWorkspaceSlug(workspaceSlug);
const userWorkspaceRole = getWorkspaceRoleByWorkspaceSlug(workspaceSlug.toString());
let isAuthorized: boolean | string = false;
if (pathname && userWorkspaceRole) {
if (pathname && workspaceSlug && userWorkspaceRole) {
isAuthorized = WORKSPACE_SETTINGS_ACCESS[accessKey]?.includes(userWorkspaceRole as EUserWorkspaceRoles);
}
@@ -52,6 +52,6 @@ function WorkspaceSettingLayout(props: WorkspaceSettingLayoutProps) {
</div>
</>
);
}
});
export default observer(WorkspaceSettingLayout);
export default WorkspaceSettingLayout;
@@ -2,6 +2,7 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Search } from "lucide-react";
// types
import {
@@ -32,17 +33,12 @@ import { useUserPermissions } from "@/hooks/store/user";
import { BillingActionsButton } from "@/plane-web/components/workspace/billing/billing-actions-button";
import { SendWorkspaceInvitationModal } from "@/plane-web/components/workspace/members/invite-modal";
type WorkspaceMembersSettingsPageProps = {
params: {
workspaceSlug: string;
};
};
function WorkspaceMembersSettingsPage({ params }: WorkspaceMembersSettingsPageProps) {
const { workspaceSlug } = params;
const WorkspaceMembersSettingsPage = observer(() => {
// states
const [inviteModal, setInviteModal] = useState(false);
const [searchQuery, setSearchQuery] = useState<string>("");
// router
const { workspaceSlug } = useParams();
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const {
@@ -59,8 +55,9 @@ function WorkspaceMembersSettingsPage({ params }: WorkspaceMembersSettingsPagePr
);
const handleWorkspaceInvite = (data: IWorkspaceBulkInviteFormData) => {
if (!workspaceSlug) return;
return inviteMembersToWorkspace(workspaceSlug, data)
return inviteMembersToWorkspace(workspaceSlug.toString(), data)
.then(() => {
setInviteModal(false);
captureSuccess({
@@ -165,6 +162,6 @@ function WorkspaceMembersSettingsPage({ params }: WorkspaceMembersSettingsPagePr
</section>
</SettingsContentWrapper>
);
}
});
export default observer(WorkspaceMembersSettingsPage);
export default WorkspaceMembersSettingsPage;
@@ -10,7 +10,7 @@ import { WorkspaceDetails } from "@/components/workspace/settings/workspace-deta
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
function WorkspaceSettingsPage() {
const WorkspaceSettingsPage = observer(() => {
// store hooks
const { currentWorkspace } = useWorkspace();
const { t } = useTranslation();
@@ -25,6 +25,6 @@ function WorkspaceSettingsPage() {
<WorkspaceDetails />
</SettingsContentWrapper>
);
}
});
export default observer(WorkspaceSettingsPage);
export default WorkspaceSettingsPage;
@@ -2,6 +2,7 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
@@ -18,17 +19,11 @@ import { useWebhook } from "@/hooks/store/use-webhook";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
type WebhookDetailsPageProps = {
params: {
workspaceSlug: string;
webhookId: string;
};
};
function WebhookDetailsPage({ params }: WebhookDetailsPageProps) {
const { workspaceSlug, webhookId } = params;
const WebhookDetailsPage = observer(() => {
// states
const [deleteWebhookModal, setDeleteWebhookModal] = useState(false);
// router
const { workspaceSlug, webhookId } = useParams();
// mobx store
const { currentWebhook, fetchWebhookById, updateWebhook } = useWebhook();
const { currentWorkspace } = useWorkspace();
@@ -44,8 +39,10 @@ function WebhookDetailsPage({ params }: WebhookDetailsPageProps) {
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Webhook` : undefined;
useSWR(
isAdmin ? `WEBHOOK_DETAILS_${workspaceSlug}_${webhookId}` : null,
isAdmin ? () => fetchWebhookById(workspaceSlug, webhookId) : null
workspaceSlug && webhookId && isAdmin ? `WEBHOOK_DETAILS_${workspaceSlug}_${webhookId}` : null,
workspaceSlug && webhookId && isAdmin
? () => fetchWebhookById(workspaceSlug.toString(), webhookId.toString())
: null
);
const handleUpdateWebhook = async (formData: IWebhook) => {
@@ -59,7 +56,7 @@ function WebhookDetailsPage({ params }: WebhookDetailsPageProps) {
issue: formData?.issue,
issue_comment: formData?.issue_comment,
};
await updateWebhook(workspaceSlug, formData.id, payload)
await updateWebhook(workspaceSlug.toString(), formData.id, payload)
.then(() => {
captureSuccess({
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_updated,
@@ -118,6 +115,6 @@ function WebhookDetailsPage({ params }: WebhookDetailsPageProps) {
</div>
</SettingsContentWrapper>
);
}
});
export default observer(WebhookDetailsPage);
export default WebhookDetailsPage;
@@ -2,6 +2,7 @@
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
@@ -21,16 +22,11 @@ import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
type WebhooksListPageProps = {
params: {
workspaceSlug: string;
};
};
function WebhooksListPage({ params }: WebhooksListPageProps) {
const { workspaceSlug } = params;
const WebhooksListPage = observer(() => {
// states
const [showCreateWebhookModal, setShowCreateWebhookModal] = useState(false);
// router
const { workspaceSlug } = useParams();
// plane hooks
const { t } = useTranslation();
// mobx store
@@ -42,8 +38,8 @@ function WebhooksListPage({ params }: WebhooksListPageProps) {
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/webhooks" });
useSWR(
canPerformWorkspaceAdminActions ? `WEBHOOKS_LIST_${workspaceSlug}` : null,
canPerformWorkspaceAdminActions ? () => fetchWebhooks(workspaceSlug) : null
workspaceSlug && canPerformWorkspaceAdminActions ? `WEBHOOKS_LIST_${workspaceSlug}` : null,
workspaceSlug && canPerformWorkspaceAdminActions ? () => fetchWebhooks(workspaceSlug.toString()) : null
);
const pageTitle = currentWorkspace?.name
@@ -116,6 +112,6 @@ function WebhooksListPage({ params }: WebhooksListPageProps) {
</div>
</SettingsContentWrapper>
);
}
});
export default observer(WebhooksListPage);
export default WebhooksListPage;
@@ -15,7 +15,7 @@ import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
const PER_PAGE = 100;
function ProfileActivityPage() {
const ProfileActivityPage = observer(() => {
// states
const [pageCount, setPageCount] = useState(1);
const [totalPages, setTotalPages] = useState(0);
@@ -84,6 +84,6 @@ function ProfileActivityPage() {
)}
</>
);
}
});
export default observer(ProfileActivityPage);
export default ProfileActivityPage;
@@ -22,7 +22,7 @@ import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
const apiTokenService = new APITokenService();
function ApiTokensPage() {
const ApiTokensPage = observer(() => {
// states
const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false);
// router
@@ -107,6 +107,6 @@ function ApiTokensPage() {
</section>
</div>
);
}
});
export default observer(ApiTokensPage);
export default ApiTokensPage;
@@ -10,11 +10,11 @@ import { SettingsMobileNav } from "@/components/settings/mobile";
// local imports
import { ProfileSidebar } from "./sidebar";
type ProfileSettingsLayoutProps = {
type Props = {
children: ReactNode;
};
function ProfileSettingsLayout(props: ProfileSettingsLayoutProps) {
const ProfileSettingsLayout = observer((props: Props) => {
const { children } = props;
// router
const pathname = usePathname();
@@ -32,6 +32,6 @@ function ProfileSettingsLayout(props: ProfileSettingsLayoutProps) {
</div>
</>
);
}
});
export default observer(ProfileSettingsLayout);
export default ProfileSettingsLayout;
@@ -9,7 +9,7 @@ import { ProfileForm } from "@/components/profile/form";
// hooks
import { useUser } from "@/hooks/store/user";
function ProfileSettingsPage() {
const ProfileSettingsPage = observer(() => {
const { t } = useTranslation();
// store hooks
const { data: currentUser, userProfile } = useUser();
@@ -27,6 +27,6 @@ function ProfileSettingsPage() {
<ProfileForm user={currentUser} profile={userProfile.data} />
</>
);
}
});
export default observer(ProfileSettingsPage);
export default ProfileSettingsPage;
@@ -13,7 +13,7 @@ import { SettingsHeading } from "@/components/settings/heading";
// hooks
import { useUserProfile } from "@/hooks/store/user";
function ProfileAppearancePage() {
const ProfileAppearancePage = observer(() => {
const { t } = useTranslation();
// hooks
const { data: userProfile } = useUserProfile();
@@ -44,6 +44,6 @@ function ProfileAppearancePage() {
)}
</>
);
}
});
export default observer(ProfileAppearancePage);
export default ProfileAppearancePage;
@@ -42,7 +42,7 @@ const defaultShowPassword = {
confirmPassword: false,
};
function SecurityPage() {
const SecurityPage = observer(() => {
// store
const { data: currentUser, changePassword } = useUser();
// states
@@ -255,6 +255,6 @@ function SecurityPage() {
</form>
</>
);
}
});
export default observer(SecurityPage);
export default SecurityPage;
@@ -2,6 +2,7 @@
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
@@ -19,15 +20,11 @@ import { useUserPermissions } from "@/hooks/store/user";
// plane web imports
import { CustomAutomationsRoot } from "@/plane-web/components/automations/root";
type AutomationSettingsPageProps = {
params: {
workspaceSlug: string;
projectId: string;
};
};
function AutomationSettingsPage({ params }: AutomationSettingsPageProps) {
const { workspaceSlug, projectId } = params;
const AutomationSettingsPage = observer(() => {
// router
const { workspaceSlug: workspaceSlugParam, projectId: projectIdParam } = useParams();
const workspaceSlug = workspaceSlugParam?.toString();
const projectId = projectIdParam?.toString();
// store hooks
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
const { currentProjectDetails: projectDetails, updateProject } = useProject();
@@ -40,7 +37,7 @@ function AutomationSettingsPage({ params }: AutomationSettingsPageProps) {
const handleChange = async (formData: Partial<IProject>) => {
if (!workspaceSlug || !projectId || !projectDetails) return;
await updateProject(workspaceSlug, projectId, formData).catch(() => {
await updateProject(workspaceSlug.toString(), projectId.toString(), formData).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
@@ -70,6 +67,6 @@ function AutomationSettingsPage({ params }: AutomationSettingsPageProps) {
<CustomAutomationsRoot projectId={projectId} workspaceSlug={workspaceSlug} />
</SettingsContentWrapper>
);
}
});
export default observer(AutomationSettingsPage);
export default AutomationSettingsPage;
@@ -1,6 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
@@ -11,15 +12,8 @@ import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
type EstimatesSettingsPageProps = {
params: {
workspaceSlug: string;
projectId: string;
};
};
function EstimatesSettingsPage({ params }: EstimatesSettingsPageProps) {
const { workspaceSlug, projectId } = params;
const EstimatesSettingsPage = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store
const { currentProjectDetails } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
@@ -28,6 +22,8 @@ function EstimatesSettingsPage({ params }: EstimatesSettingsPageProps) {
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Estimates` : undefined;
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
if (!workspaceSlug || !projectId) return <></>;
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
@@ -36,10 +32,14 @@ function EstimatesSettingsPage({ params }: EstimatesSettingsPageProps) {
<SettingsContentWrapper>
<PageHead title={pageTitle} />
<div className={`w-full ${canPerformProjectAdminActions ? "" : "pointer-events-none opacity-60"}`}>
<EstimateRoot workspaceSlug={workspaceSlug} projectId={projectId} isAdmin={canPerformProjectAdminActions} />
<EstimateRoot
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
isAdmin={canPerformProjectAdminActions}
/>
</div>
</SettingsContentWrapper>
);
}
});
export default observer(EstimatesSettingsPage);
export default EstimatesSettingsPage;
@@ -1,25 +1,19 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
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";
type FeaturesSettingsPageProps = {
params: {
workspaceSlug: string;
projectId: string;
};
};
function FeaturesSettingsPage({ params }: FeaturesSettingsPageProps) {
const { workspaceSlug, projectId } = params;
const FeaturesSettingsPage = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
@@ -28,6 +22,8 @@ function FeaturesSettingsPage({ params }: FeaturesSettingsPageProps) {
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Features` : undefined;
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
if (!workspaceSlug || !projectId) return null;
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
}
@@ -37,13 +33,13 @@ function FeaturesSettingsPage({ params }: FeaturesSettingsPageProps) {
<PageHead title={pageTitle} />
<section className={`w-full ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
<ProjectFeaturesList
workspaceSlug={workspaceSlug}
projectId={projectId}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isAdmin={canPerformProjectAdminActions}
/>
</section>
</SettingsContentWrapper>
);
}
});
export default observer(FeaturesSettingsPage);
export default FeaturesSettingsPage;
@@ -14,7 +14,7 @@ import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
function LabelsSettingsPage() {
const LabelsSettingsPage = observer(() => {
// store hooks
const { currentProjectDetails } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
@@ -54,6 +54,6 @@ function LabelsSettingsPage() {
</div>
</SettingsContentWrapper>
);
}
});
export default observer(LabelsSettingsPage);
export default LabelsSettingsPage;
@@ -1,6 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
@@ -18,20 +19,17 @@ import { useUserPermissions } from "@/hooks/store/user";
import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces/teamspace-list";
import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings";
type MembersSettingsPageProps = {
params: {
workspaceSlug: string;
projectId: string;
};
};
function MembersSettingsPage({ params }: MembersSettingsPageProps) {
const { workspaceSlug, projectId } = params;
const MembersSettingsPage = observer(() => {
// router
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
// plane hooks
const { t } = useTranslation();
// store hooks
const { currentProjectDetails } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
// derived values
const projectId = routerProjectId?.toString();
const workspaceSlug = routerWorkspaceSlug?.toString();
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined;
const isProjectMemberOrAdmin = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
@@ -53,6 +51,6 @@ function MembersSettingsPage({ params }: MembersSettingsPageProps) {
<ProjectMemberList projectId={projectId} workspaceSlug={workspaceSlug} />
</SettingsContentWrapper>
);
}
});
export default observer(MembersSettingsPage);
export default MembersSettingsPage;
@@ -2,6 +2,7 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
@@ -18,38 +19,40 @@ import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
type ProjectSettingsPageProps = {
params: {
workspaceSlug: string;
projectId: string;
};
};
function ProjectSettingsPage({ params }: ProjectSettingsPageProps) {
const { workspaceSlug, projectId } = params;
const ProjectSettingsPage = observer(() => {
// states
const [selectProject, setSelectedProject] = useState<string | null>(null);
const [archiveProject, setArchiveProject] = useState<boolean>(false);
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
const { currentProjectDetails, fetchProjectDetails } = useProject();
const { allowPermissions } = useUserPermissions();
// api call to fetch project details
// TODO: removed this API if not necessary
const { isLoading } = useSWR(`PROJECT_DETAILS_${projectId}`, () => fetchProjectDetails(workspaceSlug, projectId));
const { isLoading } = useSWR(
workspaceSlug && projectId ? `PROJECT_DETAILS_${projectId}` : null,
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()) : null
);
// derived values
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
const isAdmin = allowPermissions(
[EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT,
workspaceSlug.toString(),
projectId.toString()
);
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined;
return (
<SettingsContentWrapper>
<PageHead title={pageTitle} />
{currentProjectDetails && (
{currentProjectDetails && workspaceSlug && projectId && (
<>
<ArchiveRestoreProjectModal
workspaceSlug={workspaceSlug}
projectId={projectId}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isOpen={archiveProject}
onClose={() => setArchiveProject(false)}
archive
@@ -63,11 +66,11 @@ function ProjectSettingsPage({ params }: ProjectSettingsPageProps) {
)}
<div className={`w-full ${isAdmin ? "" : "opacity-60"}`}>
{currentProjectDetails && !isLoading ? (
{currentProjectDetails && workspaceSlug && projectId && !isLoading ? (
<ProjectDetailsForm
project={currentProjectDetails}
workspaceSlug={workspaceSlug}
projectId={projectId}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isAdmin={isAdmin}
/>
) : (
@@ -89,6 +92,6 @@ function ProjectSettingsPage({ params }: ProjectSettingsPageProps) {
</div>
</SettingsContentWrapper>
);
}
});
export default observer(ProjectSettingsPage);
export default ProjectSettingsPage;
@@ -1,6 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// components
@@ -13,15 +14,8 @@ import { SettingsHeading } from "@/components/settings/heading";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
type StatesSettingsPageProps = {
params: {
workspaceSlug: string;
projectId: string;
};
};
function StatesSettingsPage({ params }: StatesSettingsPageProps) {
const { workspaceSlug, projectId } = params;
const StatesSettingsPage = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store
const { currentProjectDetails } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
@@ -48,10 +42,12 @@ function StatesSettingsPage({ params }: StatesSettingsPageProps) {
title={t("project_settings.states.heading")}
description={t("project_settings.states.description")}
/>
<ProjectStateRoot workspaceSlug={workspaceSlug} projectId={projectId} />
{workspaceSlug && projectId && (
<ProjectStateRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
)}
</div>
</SettingsContentWrapper>
);
}
});
export default observer(StatesSettingsPage);
export default StatesSettingsPage;
@@ -3,7 +3,7 @@
import type { ReactNode } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { useParams, usePathname } from "next/navigation";
// components
import { getProjectActivePath } from "@/components/settings/helper";
import { SettingsMobileNav } from "@/components/settings/mobile";
@@ -12,19 +12,16 @@ import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
type ProjectSettingsLayoutProps = {
type Props = {
children: ReactNode;
params: {
workspaceSlug: string;
projectId?: string;
};
};
function ProjectSettingsLayout({ children, params }: ProjectSettingsLayoutProps) {
const { workspaceSlug, projectId } = params;
const ProjectSettingsLayout = observer((props: Props) => {
const { children } = props;
// router
const router = useAppRouter();
const pathname = usePathname();
const { workspaceSlug, projectId } = useParams();
const { joinedProjectIds } = useProject();
useEffect(() => {
@@ -37,22 +34,14 @@ function ProjectSettingsLayout({ children, params }: ProjectSettingsLayoutProps)
return (
<>
<SettingsMobileNav hamburgerContent={ProjectSettingsSidebar} activePath={getProjectActivePath(pathname) || ""} />
{projectId ? (
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
<div className="relative flex h-full w-full">
<div className="hidden md:block">
<ProjectSettingsSidebar />
</div>
<div className="w-full h-full overflow-y-scroll md:pt-page-y">{children}</div>
</div>
</ProjectAuthWrapper>
) : (
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
<div className="relative flex h-full w-full">
<div className="hidden md:block">{projectId && <ProjectSettingsSidebar />}</div>
<div className="w-full h-full overflow-y-scroll md:pt-page-y">{children}</div>
</div>
)}
</ProjectAuthWrapper>
</>
);
}
});
export default observer(ProjectSettingsLayout);
export default ProjectSettingsLayout;
@@ -7,7 +7,7 @@ import { Button, getButtonStyling } from "@plane/propel/button";
import { cn } from "@plane/utils";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
function ProjectSettingsPage() {
const ProjectSettingsPage = () => {
// store hooks
const { resolvedTheme } = useTheme();
const { toggleCreateProjectModal } = useCommandPalette();
@@ -38,6 +38,6 @@ function ProjectSettingsPage() {
</div>
</div>
);
}
};
export default ProjectSettingsPage;
@@ -10,17 +10,15 @@ import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper";
import DefaultLayout from "@/layouts/default-layout";
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
function ForgotPasswordPage() {
return (
<DefaultLayout>
<AuthenticationWrapper pageType={EPageTypes.NON_AUTHENTICATED}>
<div className="relative z-10 flex flex-col items-center w-screen h-screen overflow-hidden overflow-y-auto pt-6 pb-10 px-8">
<AuthHeader type={EAuthModes.SIGN_IN} />
<ForgotPasswordForm />
</div>
</AuthenticationWrapper>
</DefaultLayout>
);
}
const ForgotPasswordPage = observer(() => (
<DefaultLayout>
<AuthenticationWrapper pageType={EPageTypes.NON_AUTHENTICATED}>
<div className="relative z-10 flex flex-col items-center w-screen h-screen overflow-hidden overflow-y-auto pt-6 pb-10 px-8">
<AuthHeader type={EAuthModes.SIGN_IN} />
<ForgotPasswordForm />
</div>
</AuthenticationWrapper>
</DefaultLayout>
));
export default observer(ForgotPasswordPage);
export default ForgotPasswordPage;
@@ -11,17 +11,15 @@ import { EPageTypes } from "@/helpers/authentication.helper";
import DefaultLayout from "@/layouts/default-layout";
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
function ResetPasswordPage() {
return (
<DefaultLayout>
<AuthenticationWrapper pageType={EPageTypes.NON_AUTHENTICATED}>
<div className="relative z-10 flex flex-col items-center w-screen h-screen overflow-hidden overflow-y-auto pt-6 pb-10 px-8">
<AuthHeader type={EAuthModes.SIGN_IN} />
<ResetPasswordForm />
</div>
</AuthenticationWrapper>
</DefaultLayout>
);
}
const ResetPasswordPage = () => (
<DefaultLayout>
<AuthenticationWrapper pageType={EPageTypes.NON_AUTHENTICATED}>
<div className="relative z-10 flex flex-col items-center w-screen h-screen overflow-hidden overflow-y-auto pt-6 pb-10 px-8">
<AuthHeader type={EAuthModes.SIGN_IN} />
<ResetPasswordForm />
</div>
</AuthenticationWrapper>
</DefaultLayout>
);
export default ResetPasswordPage;
@@ -11,17 +11,15 @@ import { EPageTypes } from "@/helpers/authentication.helper";
import DefaultLayout from "@/layouts/default-layout";
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
function SetPasswordPage() {
return (
<DefaultLayout>
<AuthenticationWrapper pageType={EPageTypes.SET_PASSWORD}>
<div className="relative z-10 flex flex-col items-center w-screen h-screen overflow-hidden overflow-y-auto pt-6 pb-10 px-8">
<AuthHeader type={EAuthModes.SIGN_IN} />
<ResetPasswordForm />
</div>
</AuthenticationWrapper>
</DefaultLayout>
);
}
const SetPasswordPage = () => (
<DefaultLayout>
<AuthenticationWrapper pageType={EPageTypes.SET_PASSWORD}>
<div className="relative z-10 flex flex-col items-center w-screen h-screen overflow-hidden overflow-y-auto pt-6 pb-10 px-8">
<AuthHeader type={EAuthModes.SIGN_IN} />
<ResetPasswordForm />
</div>
</AuthenticationWrapper>
</DefaultLayout>
);
export default SetPasswordPage;
+3 -3
View File
@@ -21,7 +21,7 @@ import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.hel
// images
import WorkspaceCreationDisabled from "@/public/workspace/workspace-creation-disabled.png";
function CreateWorkspacePage() {
const CreateWorkspacePage = observer(() => {
const { t } = useTranslation();
// router
const router = useAppRouter();
@@ -103,6 +103,6 @@ function CreateWorkspacePage() {
</div>
</AuthenticationWrapper>
);
}
});
export default observer(CreateWorkspacePage);
export default CreateWorkspacePage;
@@ -1,7 +1,7 @@
"use client";
import React, { useEffect } from "react";
import { useSearchParams } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
// ui
import { LogoSpinner } from "@/components/common/logo-spinner";
// services
@@ -10,14 +10,9 @@ import { AppInstallationService } from "@/services/app_installation.service";
// services
const appInstallationService = new AppInstallationService();
type AppPostInstallationProps = {
params: {
provider: string;
};
};
export default function AppPostInstallation({ params }: AppPostInstallationProps) {
const { provider } = params;
export default function AppPostInstallation() {
// params
const { provider } = useParams();
// query params
const searchParams = useSearchParams();
const installation_id = searchParams.get("installation_id");
+3 -3
View File
@@ -34,7 +34,7 @@ import emptyInvitation from "@/public/empty-state/invitation.svg";
const workspaceService = new WorkspaceService();
function UserInvitationsPage() {
const UserInvitationsPage = observer(() => {
// states
const [invitationsRespond, setInvitationsRespond] = useState<string[]>([]);
const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false);
@@ -220,6 +220,6 @@ function UserInvitationsPage() {
</div>
</AuthenticationWrapper>
);
}
});
export default observer(UserInvitationsPage);
export default UserInvitationsPage;
+3 -3
View File
@@ -20,7 +20,7 @@ import { WorkspaceService } from "@/plane-web/services";
const workspaceService = new WorkspaceService();
function OnboardingPage() {
const OnboardingPage = observer(() => {
// store hooks
const { data: user } = useUser();
const { fetchWorkspaces } = useWorkspace();
@@ -57,6 +57,6 @@ function OnboardingPage() {
</div>
</AuthenticationWrapper>
);
}
});
export default observer(OnboardingPage);
export default OnboardingPage;
+3 -3
View File
@@ -16,7 +16,7 @@ import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
const PER_PAGE = 100;
function ProfileActivityPage() {
const ProfileActivityPage = observer(() => {
// states
const [pageCount, setPageCount] = useState(1);
const [totalPages, setTotalPages] = useState(0);
@@ -76,6 +76,6 @@ function ProfileActivityPage() {
</ProfileSettingContentWrapper>
</>
);
}
});
export default observer(ProfileActivityPage);
export default ProfileActivityPage;
@@ -20,7 +20,7 @@ import { ProfileSettingContentWrapper } from "@/components/profile/profile-setti
// hooks
import { useUserProfile } from "@/hooks/store/user";
function ProfileAppearancePage() {
const ProfileAppearancePage = observer(() => {
const { t } = useTranslation();
const { setTheme } = useTheme();
// states
@@ -86,6 +86,6 @@ function ProfileAppearancePage() {
)}
</>
);
}
});
export default observer(ProfileAppearancePage);
export default ProfileAppearancePage;
+2 -2
View File
@@ -8,11 +8,11 @@ import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
// layout
import { ProfileLayoutSidebar } from "./sidebar";
type ProfileSettingsLayoutProps = {
type Props = {
children: ReactNode;
};
export default function ProfileSettingsLayout(props: ProfileSettingsLayoutProps) {
export default function ProfileSettingsLayout(props: Props) {
const { children } = props;
return (
+3 -3
View File
@@ -11,7 +11,7 @@ import { ProfileSettingContentWrapper } from "@/components/profile/profile-setti
// hooks
import { useUser } from "@/hooks/store/user";
function ProfileSettingsPage() {
const ProfileSettingsPage = observer(() => {
const { t } = useTranslation();
// store hooks
const { data: currentUser, userProfile } = useUser();
@@ -31,6 +31,6 @@ function ProfileSettingsPage() {
</ProfileSettingContentWrapper>
</>
);
}
});
export default observer(ProfileSettingsPage);
export default ProfileSettingsPage;
+3 -3
View File
@@ -43,7 +43,7 @@ const defaultShowPassword = {
confirmPassword: false,
};
function SecurityPage() {
const SecurityPage = observer(() => {
// store
const { data: currentUser, changePassword } = useUser();
// states
@@ -254,6 +254,6 @@ function SecurityPage() {
</ProfileSettingContentWrapper>
</>
);
}
});
export default observer(SecurityPage);
export default SecurityPage;
+7 -9
View File
@@ -8,14 +8,12 @@ import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper";
import DefaultLayout from "@/layouts/default-layout";
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
function SignUpPage() {
return (
<DefaultLayout>
<AuthenticationWrapper pageType={EPageTypes.NON_AUTHENTICATED}>
<AuthBase authType={EAuthModes.SIGN_UP} />
</AuthenticationWrapper>
</DefaultLayout>
);
}
const SignUpPage = () => (
<DefaultLayout>
<AuthenticationWrapper pageType={EPageTypes.NON_AUTHENTICATED}>
<AuthBase authType={EAuthModes.SIGN_UP} />
</AuthenticationWrapper>
</DefaultLayout>
);
export default SignUpPage;
@@ -23,7 +23,7 @@ import { WorkspaceService } from "@/plane-web/services";
// service initialization
const workspaceService = new WorkspaceService();
function WorkspaceInvitationPage() {
const WorkspaceInvitationPage = observer(() => {
// router
const router = useAppRouter();
// query params
@@ -123,6 +123,6 @@ function WorkspaceInvitationPage() {
</div>
</AuthenticationWrapper>
);
}
});
export default observer(WorkspaceInvitationPage);
export default WorkspaceInvitationPage;

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