mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
13 Commits
fix/toast-
...
feat/edito
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
203df2840e | ||
|
|
d81f4d7a00 | ||
|
|
07ef3a9411 | ||
|
|
5692565d1a | ||
|
|
b391afc23f | ||
|
|
6e705bda0a | ||
|
|
3a47e3f6be | ||
|
|
b01b372a30 | ||
|
|
31b30e33e9 | ||
|
|
a4942106d5 | ||
|
|
e0882b05f3 | ||
|
|
970308dfdd | ||
|
|
e120c076ab |
@@ -4,6 +4,7 @@ from django.urls import path
|
||||
from plane.app.views import (
|
||||
InboxViewSet,
|
||||
InboxIssueViewSet,
|
||||
InboxIssueDescriptionViewSet,
|
||||
)
|
||||
|
||||
|
||||
@@ -50,4 +51,14 @@ urlpatterns = [
|
||||
),
|
||||
name="inbox-issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:issue_id>/description/",
|
||||
InboxIssueDescriptionViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
}
|
||||
),
|
||||
name="inbox-issue-description",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -19,8 +19,8 @@ from plane.app.views import (
|
||||
IssueUserDisplayPropertyEndpoint,
|
||||
IssueViewSet,
|
||||
LabelViewSet,
|
||||
BulkIssueOperationsEndpoint,
|
||||
BulkArchiveIssuesEndpoint,
|
||||
IssueDescriptionViewSet,
|
||||
ArchivedIssueDescriptionViewSet,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -51,6 +51,16 @@ urlpatterns = [
|
||||
),
|
||||
name="project-issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/description/",
|
||||
IssueDescriptionViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
}
|
||||
),
|
||||
name="issue-description",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
|
||||
LabelViewSet.as_view(
|
||||
@@ -83,11 +93,6 @@ urlpatterns = [
|
||||
BulkDeleteIssuesEndpoint.as_view(),
|
||||
name="project-issues-bulk",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-archive-issues/",
|
||||
BulkArchiveIssuesEndpoint.as_view(),
|
||||
name="bulk-archive-issues",
|
||||
),
|
||||
##
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
|
||||
@@ -261,6 +266,16 @@ urlpatterns = [
|
||||
),
|
||||
name="project-issue-archive-unarchive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/<uuid:pk>/description/",
|
||||
ArchivedIssueDescriptionViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
}
|
||||
),
|
||||
name="archived-issue-description",
|
||||
),
|
||||
## End Issue Archives
|
||||
## Issue Relation
|
||||
path(
|
||||
@@ -306,8 +321,13 @@ urlpatterns = [
|
||||
name="project-issue-draft",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/bulk-operation-issues/",
|
||||
BulkIssueOperationsEndpoint.as_view(),
|
||||
name="bulk-operations-issues",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-drafts/<uuid:pk>/description/",
|
||||
IssueDraftViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
}
|
||||
),
|
||||
name="draft-issue-description",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -106,6 +106,7 @@ from .issue.base import (
|
||||
IssueListEndpoint,
|
||||
IssueViewSet,
|
||||
IssueUserDisplayPropertyEndpoint,
|
||||
IssueDescriptionViewSet,
|
||||
BulkDeleteIssuesEndpoint,
|
||||
)
|
||||
|
||||
@@ -113,7 +114,8 @@ from .issue.activity import (
|
||||
IssueActivityEndpoint,
|
||||
)
|
||||
|
||||
from .issue.archive import IssueArchiveViewSet, BulkArchiveIssuesEndpoint
|
||||
from .issue.archive import IssueArchiveViewSet
|
||||
from .issue.archive import IssueArchiveViewSet, ArchivedIssueDescriptionViewSet
|
||||
|
||||
from .issue.attachment import (
|
||||
IssueAttachmentEndpoint,
|
||||
@@ -124,7 +126,7 @@ from .issue.comment import (
|
||||
CommentReactionViewSet,
|
||||
)
|
||||
|
||||
from .issue.draft import IssueDraftViewSet
|
||||
from .issue.draft import IssueDraftViewSet, DraftIssueDescriptionViewSet
|
||||
|
||||
from .issue.label import (
|
||||
LabelViewSet,
|
||||
@@ -151,9 +153,6 @@ from .issue.subscriber import (
|
||||
IssueSubscriberViewSet,
|
||||
)
|
||||
|
||||
|
||||
from .issue.bulk_operations import BulkIssueOperationsEndpoint
|
||||
|
||||
from .module.base import (
|
||||
ModuleViewSet,
|
||||
ModuleLinkViewSet,
|
||||
@@ -195,7 +194,11 @@ from .estimate.base import (
|
||||
EstimatePointEndpoint,
|
||||
)
|
||||
|
||||
from .inbox.base import InboxViewSet, InboxIssueViewSet
|
||||
from .inbox.base import (
|
||||
InboxViewSet,
|
||||
InboxIssueViewSet,
|
||||
InboxIssueDescriptionViewSet,
|
||||
)
|
||||
|
||||
from .analytic.base import (
|
||||
AnalyticsEndpoint,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Python imports
|
||||
import json
|
||||
import base64
|
||||
|
||||
# Django import
|
||||
from django.utils import timezone
|
||||
@@ -9,6 +10,7 @@ from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Value, UUIDField
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import StreamingHttpResponse
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -578,3 +580,48 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
|
||||
inbox_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class InboxIssueDescriptionViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectLitePermission,
|
||||
]
|
||||
|
||||
def retrieve(self, request, slug, project_id, issue_id):
|
||||
issue = Issue.objects.get(
|
||||
pk=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
binary_data = issue.description_binary
|
||||
|
||||
def stream_data():
|
||||
if binary_data:
|
||||
yield binary_data
|
||||
else:
|
||||
yield b""
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
stream_data(), content_type="application/octet-stream"
|
||||
)
|
||||
response["Content-Disposition"] = (
|
||||
'attachment; filename="issue_description.bin"'
|
||||
)
|
||||
return response
|
||||
|
||||
def partial_update(self, request, slug, project_id, issue_id):
|
||||
issue = Issue.objects.get(
|
||||
pk=issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
base64_data = request.data.get("description_binary")
|
||||
|
||||
if base64_data:
|
||||
# Decode the base64 data to bytes
|
||||
new_binary_data = base64.b64decode(base64_data)
|
||||
|
||||
# Store the updated binary data
|
||||
issue.description_binary = new_binary_data
|
||||
issue.description_html = request.data.get("description_html")
|
||||
issue.save()
|
||||
return Response({"message": "Updated successfully"})
|
||||
else:
|
||||
return Response({"error": "No binary data provided"})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Python imports
|
||||
import json
|
||||
import base64
|
||||
|
||||
# Django imports
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
@@ -12,6 +13,7 @@ from django.db.models import (
|
||||
Exists,
|
||||
)
|
||||
from django.utils import timezone
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
|
||||
@@ -319,56 +321,46 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class BulkArchiveIssuesEndpoint(BaseAPIView):
|
||||
class ArchivedIssueDescriptionViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug, project_id):
|
||||
issue_ids = request.data.get("issue_ids", [])
|
||||
|
||||
if not len(issue_ids):
|
||||
return Response(
|
||||
{"error": "Issue IDs are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
issues = Issue.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
|
||||
).select_related("state")
|
||||
bulk_archive_issues = []
|
||||
for issue in issues:
|
||||
if issue.state.group not in ["completed", "cancelled"]:
|
||||
return Response(
|
||||
{
|
||||
"error_code": 4091,
|
||||
"error_message": "INVALID_ARCHIVE_STATE_GROUP"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps(
|
||||
{
|
||||
"archived_at": str(timezone.now().date()),
|
||||
"automation": False,
|
||||
}
|
||||
),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue.archived_at = timezone.now().date()
|
||||
bulk_archive_issues.append(issue)
|
||||
Issue.objects.bulk_update(bulk_archive_issues, ["archived_at"])
|
||||
|
||||
return Response(
|
||||
{"archived_at": str(timezone.now().date())},
|
||||
status=status.HTTP_200_OK,
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
issue = Issue.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
binary_data = issue.description_binary
|
||||
|
||||
def stream_data():
|
||||
if binary_data:
|
||||
yield binary_data
|
||||
else:
|
||||
yield b""
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
stream_data(), content_type="application/octet-stream"
|
||||
)
|
||||
response["Content-Disposition"] = (
|
||||
'attachment; filename="issue_description.bin"'
|
||||
)
|
||||
return response
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
issue = Issue.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
base64_data = request.data.get("description_binary")
|
||||
|
||||
if base64_data:
|
||||
# Decode the base64 data to bytes
|
||||
new_binary_data = base64.b64decode(base64_data)
|
||||
|
||||
# Store the updated binary data
|
||||
issue.description_binary = new_binary_data
|
||||
issue.description_html = request.data.get("description_html")
|
||||
issue.save()
|
||||
return Response({"message": "Updated successfully"})
|
||||
else:
|
||||
return Response({"error": "No binary data provided"})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Python imports
|
||||
import json
|
||||
import base64
|
||||
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import (
|
||||
Exists,
|
||||
@@ -59,8 +61,6 @@ from plane.utils.paginator import (
|
||||
from .. import BaseAPIView, BaseViewSet
|
||||
from plane.utils.user_timezone_converter import user_timezone_converter
|
||||
|
||||
# Module imports
|
||||
|
||||
|
||||
class IssueListEndpoint(BaseAPIView):
|
||||
|
||||
@@ -591,3 +591,48 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
|
||||
{"message": f"{total_issues} issues were deleted"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class IssueDescriptionViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
issue = Issue.issue_objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
binary_data = issue.description_binary
|
||||
|
||||
def stream_data():
|
||||
if binary_data:
|
||||
yield binary_data
|
||||
else:
|
||||
yield b""
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
stream_data(), content_type="application/octet-stream"
|
||||
)
|
||||
response["Content-Disposition"] = (
|
||||
'attachment; filename="issue_description.bin"'
|
||||
)
|
||||
return response
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
issue = Issue.issue_objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
base64_data = request.data.get("description_binary")
|
||||
|
||||
if base64_data:
|
||||
# Decode the base64 data to bytes
|
||||
new_binary_data = base64.b64decode(base64_data)
|
||||
|
||||
# Store the updated binary data
|
||||
issue.description_binary = new_binary_data
|
||||
issue.description_html = request.data.get("description_html")
|
||||
issue.save()
|
||||
return Response({"message": "Updated successfully"})
|
||||
else:
|
||||
return Response({"error": "No binary data provided"})
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
# Python imports
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
Issue,
|
||||
IssueLabel,
|
||||
IssueAssignee,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
|
||||
|
||||
class BulkIssueOperationsEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def post(self, request, slug, project_id):
|
||||
issue_ids = request.data.get("issue_ids", [])
|
||||
if not len(issue_ids):
|
||||
return Response(
|
||||
{"error": "Issue IDs are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get all the issues
|
||||
issues = (
|
||||
Issue.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
|
||||
)
|
||||
.select_related("state")
|
||||
.prefetch_related("labels", "assignees")
|
||||
)
|
||||
# Current epoch
|
||||
epoch = int(timezone.now().timestamp())
|
||||
|
||||
# Project details
|
||||
project = Project.objects.get(workspace__slug=slug, pk=project_id)
|
||||
workspace_id = project.workspace_id
|
||||
|
||||
# Initialize arrays
|
||||
bulk_update_issues = []
|
||||
bulk_issue_activities = []
|
||||
bulk_update_issue_labels = []
|
||||
bulk_update_issue_assignees = []
|
||||
|
||||
properties = request.data.get("properties", {})
|
||||
|
||||
if properties.get("start_date", False) and properties.get("target_date", False):
|
||||
if (
|
||||
datetime.strptime(properties.get("start_date"), "%Y-%m-%d").date()
|
||||
> datetime.strptime(properties.get("target_date"), "%Y-%m-%d").date()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error_code": 4100,
|
||||
"error_message": "INVALID_ISSUE_DATES",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
for issue in issues:
|
||||
|
||||
# Priority
|
||||
if properties.get("priority", False):
|
||||
bulk_issue_activities.append(
|
||||
{
|
||||
"type": "issue.activity.updated",
|
||||
"requested_data": json.dumps(
|
||||
{"priority": properties.get("priority")}
|
||||
),
|
||||
"current_instance": json.dumps(
|
||||
{"priority": (issue.priority)}
|
||||
),
|
||||
"issue_id": str(issue.id),
|
||||
"actor_id": str(request.user.id),
|
||||
"project_id": str(project_id),
|
||||
"epoch": epoch,
|
||||
}
|
||||
)
|
||||
issue.priority = properties.get("priority")
|
||||
|
||||
# State
|
||||
if properties.get("state_id", False):
|
||||
bulk_issue_activities.append(
|
||||
{
|
||||
"type": "issue.activity.updated",
|
||||
"requested_data": json.dumps(
|
||||
{"state": properties.get("state")}
|
||||
),
|
||||
"current_instance": json.dumps(
|
||||
{"state": str(issue.state_id)}
|
||||
),
|
||||
"issue_id": str(issue.id),
|
||||
"actor_id": str(request.user.id),
|
||||
"project_id": str(project_id),
|
||||
"epoch": epoch,
|
||||
}
|
||||
)
|
||||
issue.state_id = properties.get("state_id")
|
||||
|
||||
# Start date
|
||||
if properties.get("start_date", False):
|
||||
if (
|
||||
issue.target_date
|
||||
and not properties.get("target_date", False)
|
||||
and issue.target_date
|
||||
<= datetime.strptime(
|
||||
properties.get("start_date"), "%Y-%m-%d"
|
||||
).date()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error_code": 4101,
|
||||
"error_message": "INVALID_ISSUE_START_DATE",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
bulk_issue_activities.append(
|
||||
{
|
||||
"type": "issue.activity.updated",
|
||||
"requested_data": json.dumps(
|
||||
{"start_date": properties.get("start_date")}
|
||||
),
|
||||
"current_instance": json.dumps(
|
||||
{"start_date": str(issue.start_date)}
|
||||
),
|
||||
"issue_id": str(issue.id),
|
||||
"actor_id": str(request.user.id),
|
||||
"project_id": str(project_id),
|
||||
"epoch": epoch,
|
||||
}
|
||||
)
|
||||
issue.start_date = properties.get("start_date")
|
||||
|
||||
# Target date
|
||||
if properties.get("target_date", False):
|
||||
if (
|
||||
issue.start_date
|
||||
and not properties.get("start_date", False)
|
||||
and issue.start_date
|
||||
>= datetime.strptime(
|
||||
properties.get("target_date"), "%Y-%m-%d"
|
||||
).date()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error_code": 4102,
|
||||
"error_message": "INVALID_ISSUE_TARGET_DATE",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
bulk_issue_activities.append(
|
||||
{
|
||||
"type": "issue.activity.updated",
|
||||
"requested_data": json.dumps(
|
||||
{"target_date": properties.get("target_date")}
|
||||
),
|
||||
"current_instance": json.dumps(
|
||||
{"target_date": str(issue.target_date)}
|
||||
),
|
||||
"issue_id": str(issue.id),
|
||||
"actor_id": str(request.user.id),
|
||||
"project_id": str(project_id),
|
||||
"epoch": epoch,
|
||||
}
|
||||
)
|
||||
issue.target_date = properties.get("target_date")
|
||||
|
||||
bulk_update_issues.append(issue)
|
||||
|
||||
# Labels
|
||||
if properties.get("label_ids", []):
|
||||
for label_id in properties.get("label_ids", []):
|
||||
bulk_update_issue_labels.append(
|
||||
IssueLabel(
|
||||
issue=issue,
|
||||
label_id=label_id,
|
||||
created_by=request.user,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
)
|
||||
bulk_issue_activities.append(
|
||||
{
|
||||
"type": "issue.activity.updated",
|
||||
"requested_data": json.dumps(
|
||||
{"label_ids": properties.get("label_ids", [])}
|
||||
),
|
||||
"current_instance": json.dumps(
|
||||
{
|
||||
"label_ids": [
|
||||
str(label.id)
|
||||
for label in issue.labels.all()
|
||||
]
|
||||
}
|
||||
),
|
||||
"issue_id": str(issue.id),
|
||||
"actor_id": str(request.user.id),
|
||||
"project_id": str(project_id),
|
||||
"epoch": epoch,
|
||||
}
|
||||
)
|
||||
|
||||
# Assignees
|
||||
if properties.get("assignee_ids", []):
|
||||
for assignee_id in properties.get(
|
||||
"assignee_ids", issue.assignees
|
||||
):
|
||||
bulk_update_issue_assignees.append(
|
||||
IssueAssignee(
|
||||
issue=issue,
|
||||
assignee_id=assignee_id,
|
||||
created_by=request.user,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
)
|
||||
bulk_issue_activities.append(
|
||||
{
|
||||
"type": "issue.activity.updated",
|
||||
"requested_data": json.dumps(
|
||||
{
|
||||
"assignee_ids": properties.get(
|
||||
"assignee_ids", []
|
||||
)
|
||||
}
|
||||
),
|
||||
"current_instance": json.dumps(
|
||||
{
|
||||
"assignee_ids": [
|
||||
str(assignee.id)
|
||||
for assignee in issue.assignees.all()
|
||||
]
|
||||
}
|
||||
),
|
||||
"issue_id": str(issue.id),
|
||||
"actor_id": str(request.user.id),
|
||||
"project_id": str(project_id),
|
||||
"epoch": epoch,
|
||||
}
|
||||
)
|
||||
|
||||
# Bulk update all the objects
|
||||
Issue.objects.bulk_update(
|
||||
bulk_update_issues,
|
||||
[
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"state",
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
# Create new labels
|
||||
IssueLabel.objects.bulk_create(
|
||||
bulk_update_issue_labels,
|
||||
ignore_conflicts=True,
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
# Create new assignees
|
||||
IssueAssignee.objects.bulk_create(
|
||||
bulk_update_issue_assignees,
|
||||
ignore_conflicts=True,
|
||||
batch_size=100,
|
||||
)
|
||||
# update the issue activity
|
||||
[
|
||||
issue_activity.delay(**activity)
|
||||
for activity in bulk_issue_activities
|
||||
]
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1,8 +1,10 @@
|
||||
# Python imports
|
||||
import json
|
||||
import base64
|
||||
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import (
|
||||
@@ -393,3 +395,48 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class DraftIssueDescriptionViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
issue = Issue.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
binary_data = issue.description_binary
|
||||
|
||||
def stream_data():
|
||||
if binary_data:
|
||||
yield binary_data
|
||||
else:
|
||||
yield b""
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
stream_data(), content_type="application/octet-stream"
|
||||
)
|
||||
response["Content-Disposition"] = (
|
||||
'attachment; filename="issue_description.bin"'
|
||||
)
|
||||
return response
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
issue = Issue.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
base64_data = request.data.get("description_binary")
|
||||
|
||||
if base64_data:
|
||||
# Decode the base64 data to bytes
|
||||
new_binary_data = base64.b64decode(base64_data)
|
||||
|
||||
# Store the updated binary data
|
||||
issue.description_binary = new_binary_data
|
||||
issue.description_html = request.data.get("description_html")
|
||||
issue.save()
|
||||
return Response({"message": "Updated successfully"})
|
||||
else:
|
||||
return Response({"error": "No binary data provided"})
|
||||
|
||||
@@ -54,7 +54,11 @@
|
||||
"react-moveable": "^0.54.2",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.9"
|
||||
"tiptap-markdown": "^0.8.9",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-prosemirror": "^1.2.5",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "18.15.3",
|
||||
|
||||
78
packages/editor/core/src/hooks/use-conflict-free-editor.ts
Normal file
78
packages/editor/core/src/hooks/use-conflict-free-editor.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useLayoutEffect, useMemo } from "react";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TFileHandler, useEditor } from "src";
|
||||
// custom provider
|
||||
import { CollaborationProvider } from "src/providers/collaboration-provider";
|
||||
|
||||
type DocumentEditorProps = {
|
||||
id: string;
|
||||
fileHandler: TFileHandler;
|
||||
value: Uint8Array;
|
||||
extensions?: any;
|
||||
editorClassName: string;
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
editorProps?: EditorProps;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const useConflictFreeEditor = ({
|
||||
id,
|
||||
editorProps = {},
|
||||
value,
|
||||
extensions,
|
||||
editorClassName,
|
||||
fileHandler,
|
||||
onChange,
|
||||
forwardedRef,
|
||||
tabIndex,
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
}: DocumentEditorProps) => {
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
new CollaborationProvider({
|
||||
name: id,
|
||||
onChange,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[id]
|
||||
);
|
||||
|
||||
// update document on value change
|
||||
useEffect(() => {
|
||||
if (value.byteLength > 0) Y.applyUpdate(provider.document, value);
|
||||
}, [value, provider.document]);
|
||||
|
||||
// indexedDB provider
|
||||
useLayoutEffect(() => {
|
||||
const localProvider = new IndexeddbPersistence(id, provider.document);
|
||||
return () => {
|
||||
localProvider?.destroy();
|
||||
};
|
||||
}, [provider, id]);
|
||||
|
||||
const editor = useEditor({
|
||||
id,
|
||||
editorProps,
|
||||
editorClassName,
|
||||
fileHandler,
|
||||
handleEditorReady,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
extensions,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
});
|
||||
|
||||
return editor;
|
||||
};
|
||||
@@ -28,6 +28,7 @@ export interface CustomEditorProps {
|
||||
// undefined when prop is not passed, null if intentionally passed to stop
|
||||
// swr syncing
|
||||
value?: string | null | undefined;
|
||||
provider?: any;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
@@ -53,45 +54,49 @@ export const useEditor = ({
|
||||
forwardedRef,
|
||||
tabIndex,
|
||||
handleEditorReady,
|
||||
provider,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
}: CustomEditorProps) => {
|
||||
const editor = useCustomEditor({
|
||||
editorProps: {
|
||||
...CoreEditorProps(editorClassName),
|
||||
...editorProps,
|
||||
const editor = useCustomEditor(
|
||||
{
|
||||
editorProps: {
|
||||
...CoreEditorProps(editorClassName),
|
||||
...editorProps,
|
||||
},
|
||||
extensions: [
|
||||
...CoreEditorExtensions({
|
||||
mentionConfig: {
|
||||
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
|
||||
mentionHighlights: mentionHandler.highlights ?? [],
|
||||
},
|
||||
fileConfig: {
|
||||
uploadFile: fileHandler.upload,
|
||||
deleteFile: fileHandler.delete,
|
||||
restoreFile: fileHandler.restore,
|
||||
cancelUploadImage: fileHandler.cancel,
|
||||
},
|
||||
placeholder,
|
||||
tabIndex,
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||
onCreate: async () => {
|
||||
handleEditorReady?.(true);
|
||||
},
|
||||
onTransaction: async ({ editor }) => {
|
||||
setSavedSelection(editor.state.selection);
|
||||
},
|
||||
onUpdate: async ({ editor }) => {
|
||||
onChange?.(editor.getJSON(), editor.getHTML());
|
||||
},
|
||||
onDestroy: async () => {
|
||||
handleEditorReady?.(false);
|
||||
},
|
||||
},
|
||||
extensions: [
|
||||
...CoreEditorExtensions({
|
||||
mentionConfig: {
|
||||
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
|
||||
mentionHighlights: mentionHandler.highlights ?? [],
|
||||
},
|
||||
fileConfig: {
|
||||
uploadFile: fileHandler.upload,
|
||||
deleteFile: fileHandler.delete,
|
||||
restoreFile: fileHandler.restore,
|
||||
cancelUploadImage: fileHandler.cancel,
|
||||
},
|
||||
placeholder,
|
||||
tabIndex,
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||
onCreate: async () => {
|
||||
handleEditorReady?.(true);
|
||||
},
|
||||
onTransaction: async ({ editor }) => {
|
||||
setSavedSelection(editor.state.selection);
|
||||
},
|
||||
onUpdate: async ({ editor }) => {
|
||||
onChange?.(editor.getJSON(), editor.getHTML());
|
||||
},
|
||||
onDestroy: async () => {
|
||||
handleEditorReady?.(false);
|
||||
},
|
||||
});
|
||||
[id]
|
||||
);
|
||||
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
|
||||
@@ -132,7 +137,7 @@ export const useEditor = ({
|
||||
editorRef.current?.commands.clearContent();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content);
|
||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
},
|
||||
setEditorValueAtCursorPosition: (content: string) => {
|
||||
if (savedSelection) {
|
||||
@@ -174,6 +179,16 @@ export const useEditor = ({
|
||||
editorRef.current?.off("transaction");
|
||||
};
|
||||
},
|
||||
setSynced: () => {
|
||||
if (provider) {
|
||||
provider.setSynced();
|
||||
}
|
||||
},
|
||||
hasUnsyncedChanges: () => {
|
||||
if (provider) {
|
||||
return provider.hasUnsyncedChanges();
|
||||
}
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||
return markdownOutput;
|
||||
|
||||
@@ -52,7 +52,7 @@ export const useReadOnlyEditor = ({
|
||||
// for syncing swr data on tab refocus etc
|
||||
useEffect(() => {
|
||||
if (initialValue === null || initialValue === undefined) return;
|
||||
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue);
|
||||
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
|
||||
}, [editor, initialValue]);
|
||||
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
@@ -62,7 +62,7 @@ export const useReadOnlyEditor = ({
|
||||
editorRef.current?.commands.clearContent();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content);
|
||||
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||
|
||||
@@ -17,6 +17,7 @@ export { EditorContentWrapper } from "src/ui/components/editor-content";
|
||||
|
||||
// hooks
|
||||
export { useEditor } from "src/hooks/use-editor";
|
||||
export { useConflictFreeEditor } from "src/hooks/use-conflict-free-editor";
|
||||
export { useReadOnlyEditor } from "src/hooks/use-read-only-editor";
|
||||
|
||||
// helper items
|
||||
|
||||
60
packages/editor/core/src/providers/collaboration-provider.ts
Normal file
60
packages/editor/core/src/providers/collaboration-provider.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as Y from "yjs";
|
||||
|
||||
export interface CompleteCollaboratorProviderConfiguration {
|
||||
/**
|
||||
* The identifier/name of your document
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The actual Y.js document
|
||||
*/
|
||||
document: Y.Doc;
|
||||
/**
|
||||
* onChange callback
|
||||
*/
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
}
|
||||
|
||||
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
|
||||
Partial<CompleteCollaboratorProviderConfiguration>;
|
||||
|
||||
export class CollaborationProvider {
|
||||
public configuration: CompleteCollaboratorProviderConfiguration = {
|
||||
name: "",
|
||||
// @ts-expect-error cannot be undefined
|
||||
document: undefined,
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
constructor(configuration: CollaborationProviderConfiguration) {
|
||||
this.setConfiguration(configuration);
|
||||
|
||||
this.configuration.document = configuration.document ?? new Y.Doc();
|
||||
this.document.on("update", this.documentUpdateHandler.bind(this));
|
||||
this.document.on("destroy", this.documentDestroyHandler.bind(this));
|
||||
}
|
||||
|
||||
public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
}
|
||||
|
||||
get document() {
|
||||
return this.configuration.document;
|
||||
}
|
||||
|
||||
documentUpdateHandler(update: Uint8Array, origin: any) {
|
||||
// return if the update is from the provider itself
|
||||
if (origin === this) return;
|
||||
|
||||
// call onChange with the update
|
||||
this.configuration.onChange?.(update);
|
||||
}
|
||||
|
||||
documentDestroyHandler() {
|
||||
this.document.off("update", this.documentUpdateHandler);
|
||||
this.document.off("destroy", this.documentDestroyHandler);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ export type EditorReadOnlyRefApi = {
|
||||
|
||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
setEditorValueAtCursorPosition: (content: string) => void;
|
||||
setSynced: () => void;
|
||||
hasUnsyncedChanges: () => boolean;
|
||||
executeMenuItemCommand: (itemName: EditorMenuItemNames) => void;
|
||||
isMenuItemActive: (itemName: EditorMenuItemNames) => boolean;
|
||||
onStateChange: (callback: () => void) => () => void;
|
||||
|
||||
@@ -71,6 +71,7 @@ export const CoreEditorExtensions = ({
|
||||
},
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
history: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
dropcursor: {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useEffect, useLayoutEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
// editor-core
|
||||
import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TFileHandler, useEditor } from "@plane/editor-core";
|
||||
// custom provider
|
||||
import { CollaborationProvider } from "src/providers/collaboration-provider";
|
||||
// extensions
|
||||
import { DocumentEditorExtensions } from "src/ui/extensions";
|
||||
// yjs
|
||||
import * as Y from "yjs";
|
||||
|
||||
type DocumentEditorProps = {
|
||||
id: string;
|
||||
fileHandler: TFileHandler;
|
||||
value: Uint8Array;
|
||||
editorClassName: string;
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
onChange: (update: Uint8Array, source?: string) => void;
|
||||
editorProps?: EditorProps;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
mentionHandler: {
|
||||
@@ -51,21 +51,27 @@ export const useDocumentEditor = ({
|
||||
[id]
|
||||
);
|
||||
|
||||
// update document on value change
|
||||
const [isIndexedDbSynced, setIndexedDbIsSynced] = useState(false);
|
||||
|
||||
// update document on value change from server
|
||||
useEffect(() => {
|
||||
if (value.byteLength > 0) Y.applyUpdate(provider.document, value);
|
||||
if (value.length > 0) {
|
||||
Y.applyUpdate(provider.document, value);
|
||||
}
|
||||
}, [value, provider.document]);
|
||||
|
||||
// indexedDB provider
|
||||
useLayoutEffect(() => {
|
||||
const localProvider = new IndexeddbPersistence(id, provider.document);
|
||||
localProvider.on("synced", () => {
|
||||
provider.setHasIndexedDBSynced(true);
|
||||
});
|
||||
// watch for indexedDb to complete syncing, only after which the editor is
|
||||
// rendered
|
||||
useEffect(() => {
|
||||
async function checkIndexDbSynced() {
|
||||
const hasSynced = await provider.hasIndexedDBSynced();
|
||||
setIndexedDbIsSynced(hasSynced);
|
||||
}
|
||||
checkIndexDbSynced();
|
||||
return () => {
|
||||
localProvider?.destroy();
|
||||
setIndexedDbIsSynced(false);
|
||||
};
|
||||
}, [provider, id]);
|
||||
}, [provider]);
|
||||
|
||||
const editor = useEditor({
|
||||
id,
|
||||
@@ -80,9 +86,10 @@ export const useDocumentEditor = ({
|
||||
setHideDragHandle: setHideDragHandleFunction,
|
||||
provider,
|
||||
}),
|
||||
provider,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
});
|
||||
|
||||
return editor;
|
||||
return { editor, isIndexedDbSynced };
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
|
||||
export interface CompleteCollaboratorProviderConfiguration {
|
||||
@@ -12,7 +13,7 @@ export interface CompleteCollaboratorProviderConfiguration {
|
||||
/**
|
||||
* onChange callback
|
||||
*/
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
onChange: (updates: Uint8Array, source?: string) => void;
|
||||
/**
|
||||
* Whether connection to the database has been established and all available content has been loaded or not.
|
||||
*/
|
||||
@@ -25,20 +26,28 @@ export type CollaborationProviderConfiguration = Required<Pick<CompleteCollabora
|
||||
export class CollaborationProvider {
|
||||
public configuration: CompleteCollaboratorProviderConfiguration = {
|
||||
name: "",
|
||||
// @ts-expect-error cannot be undefined
|
||||
document: undefined,
|
||||
document: new Y.Doc(),
|
||||
onChange: () => {},
|
||||
hasIndexedDBSynced: false,
|
||||
};
|
||||
|
||||
unsyncedChanges = 0;
|
||||
|
||||
private initialSync = false;
|
||||
|
||||
constructor(configuration: CollaborationProviderConfiguration) {
|
||||
this.setConfiguration(configuration);
|
||||
|
||||
this.configuration.document = configuration.document ?? new Y.Doc();
|
||||
this.indexeddbProvider = new IndexeddbPersistence(`page-${this.configuration.name}`, this.document);
|
||||
this.indexeddbProvider.on("synced", () => {
|
||||
this.configuration.hasIndexedDBSynced = true;
|
||||
});
|
||||
this.document.on("update", this.documentUpdateHandler.bind(this));
|
||||
this.document.on("destroy", this.documentDestroyHandler.bind(this));
|
||||
}
|
||||
|
||||
private indexeddbProvider: IndexeddbPersistence;
|
||||
|
||||
public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
@@ -50,17 +59,49 @@ export class CollaborationProvider {
|
||||
return this.configuration.document;
|
||||
}
|
||||
|
||||
setHasIndexedDBSynced(hasIndexedDBSynced: boolean) {
|
||||
this.configuration.hasIndexedDBSynced = hasIndexedDBSynced;
|
||||
public hasUnsyncedChanges(): boolean {
|
||||
return this.unsyncedChanges > 0;
|
||||
}
|
||||
|
||||
documentUpdateHandler(update: Uint8Array, origin: any) {
|
||||
if (!this.configuration.hasIndexedDBSynced) return;
|
||||
private resetUnsyncedChanges() {
|
||||
this.unsyncedChanges = 0;
|
||||
}
|
||||
|
||||
private incrementUnsyncedChanges() {
|
||||
this.unsyncedChanges += 1;
|
||||
}
|
||||
|
||||
public setSynced() {
|
||||
this.resetUnsyncedChanges();
|
||||
}
|
||||
|
||||
public async hasIndexedDBSynced() {
|
||||
await this.indexeddbProvider.whenSynced;
|
||||
return this.configuration.hasIndexedDBSynced;
|
||||
}
|
||||
|
||||
async documentUpdateHandler(_update: Uint8Array, origin: any) {
|
||||
await this.indexeddbProvider.whenSynced;
|
||||
|
||||
// return if the update is from the provider itself
|
||||
if (origin === this) return;
|
||||
|
||||
// call onChange with the update
|
||||
this.configuration.onChange?.(update);
|
||||
const stateVector = Y.encodeStateAsUpdate(this.document);
|
||||
|
||||
if (!this.initialSync) {
|
||||
this.configuration.onChange?.(stateVector, "initialSync");
|
||||
this.initialSync = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.configuration.onChange?.(stateVector);
|
||||
this.incrementUnsyncedChanges();
|
||||
}
|
||||
|
||||
getUpdateFromIndexedDB(): Uint8Array {
|
||||
const update = Y.encodeStateAsUpdate(this.document);
|
||||
return update;
|
||||
}
|
||||
|
||||
documentDestroyHandler() {
|
||||
|
||||
@@ -19,7 +19,7 @@ interface IDocumentEditor {
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
onChange: (update: Uint8Array, source?: string) => void;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
@@ -52,7 +52,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
|
||||
};
|
||||
|
||||
// use document editor
|
||||
const editor = useDocumentEditor({
|
||||
const { editor, isIndexedDbSynced } = useDocumentEditor({
|
||||
id,
|
||||
editorClassName,
|
||||
fileHandler,
|
||||
@@ -72,7 +72,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
if (!editor || !isIndexedDbSynced) return null;
|
||||
|
||||
return (
|
||||
<PageRenderer
|
||||
|
||||
@@ -31,8 +31,13 @@
|
||||
"dependencies": {
|
||||
"@plane/editor-core": "*",
|
||||
"@plane/editor-extensions": "*",
|
||||
"@tiptap/extension-collaboration": "^2.3.2",
|
||||
"@tiptap/core": "^2.1.13",
|
||||
"lucide-react": "^0.378.0"
|
||||
"lucide-react": "^0.378.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-prosemirror": "^1.2.5",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "18.15.3",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
// editor-core
|
||||
import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TFileHandler, useEditor } from "@plane/editor-core";
|
||||
// custom provider
|
||||
import { CollaborationProvider } from "src/providers/collaboration-provider";
|
||||
// extensions
|
||||
import { RichTextEditorExtensions } from "src/ui/extensions";
|
||||
// yjs
|
||||
import * as Y from "yjs";
|
||||
|
||||
type DocumentEditorProps = {
|
||||
id: string;
|
||||
fileHandler: TFileHandler;
|
||||
value: { descriptionYJS: Uint8Array; updateId: string };
|
||||
editorClassName: string;
|
||||
onChange: (update: Uint8Array, source?: string) => void;
|
||||
editorProps?: EditorProps;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
setHideDragHandleFunction: (hideDragHandlerFromDragDrop: () => void) => void;
|
||||
tabIndex?: number;
|
||||
dragDropEnabled?: boolean;
|
||||
indexedDBPrefix: string;
|
||||
};
|
||||
|
||||
export const useRichTextEditor = ({
|
||||
id,
|
||||
editorProps = {},
|
||||
value,
|
||||
editorClassName,
|
||||
fileHandler,
|
||||
onChange,
|
||||
forwardedRef,
|
||||
tabIndex,
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
setHideDragHandleFunction,
|
||||
dragDropEnabled,
|
||||
indexedDBPrefix,
|
||||
}: DocumentEditorProps) => {
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
new CollaborationProvider({
|
||||
name: id,
|
||||
onChange,
|
||||
indexedDBPrefix,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[id]
|
||||
);
|
||||
|
||||
const [isIndexedDbSynced, setIndexedDbIsSynced] = useState(false);
|
||||
|
||||
// update document on value change from server
|
||||
useEffect(() => {
|
||||
console.log("id in useEffect", provider.configuration.name === value.updateId);
|
||||
if (value.descriptionYJS.length > 0) {
|
||||
Y.applyUpdate(provider.document, value.descriptionYJS);
|
||||
}
|
||||
}, [value, provider.document, id]);
|
||||
// console.log("id out useEffect", id);
|
||||
|
||||
// watch for indexedDb to complete syncing, only after which the editor is
|
||||
// rendered
|
||||
useEffect(() => {
|
||||
async function checkIndexDbSynced() {
|
||||
const hasSynced = await provider.hasIndexedDBSynced();
|
||||
setIndexedDbIsSynced(hasSynced);
|
||||
}
|
||||
checkIndexDbSynced();
|
||||
return () => {
|
||||
setIndexedDbIsSynced(false);
|
||||
};
|
||||
}, [provider]);
|
||||
|
||||
const editor = useEditor({
|
||||
id,
|
||||
editorProps,
|
||||
editorClassName,
|
||||
fileHandler,
|
||||
handleEditorReady,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
extensions: RichTextEditorExtensions({
|
||||
uploadFile: fileHandler.upload,
|
||||
dragDropEnabled,
|
||||
setHideDragHandle: setHideDragHandleFunction,
|
||||
provider,
|
||||
}),
|
||||
provider,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
});
|
||||
|
||||
return { editor, isIndexedDbSynced };
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
|
||||
export interface CompleteCollaboratorProviderConfiguration {
|
||||
/**
|
||||
* The identifier/name of your document
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The actual Y.js document
|
||||
*/
|
||||
document: Y.Doc;
|
||||
/**
|
||||
* onChange callback
|
||||
*/
|
||||
onChange: (updates: Uint8Array, source?: string) => void;
|
||||
/**
|
||||
* Whether connection to the database has been established and all available content has been loaded or not.
|
||||
*/
|
||||
hasIndexedDBSynced: boolean;
|
||||
/**
|
||||
* The prefix to use for the indexedDB database values
|
||||
*/
|
||||
indexedDBPrefix: string;
|
||||
}
|
||||
|
||||
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
|
||||
Partial<CompleteCollaboratorProviderConfiguration>;
|
||||
|
||||
export class CollaborationProvider {
|
||||
public configuration: CompleteCollaboratorProviderConfiguration = {
|
||||
name: "",
|
||||
// @ts-expect-error cannot be undefined
|
||||
document: undefined,
|
||||
onChange: () => {},
|
||||
hasIndexedDBSynced: false,
|
||||
indexedDBPrefix: "",
|
||||
};
|
||||
|
||||
unsyncedChanges = 0;
|
||||
|
||||
private initialSync = false;
|
||||
|
||||
constructor(configuration: CollaborationProviderConfiguration) {
|
||||
this.setConfiguration(configuration);
|
||||
|
||||
this.configuration.document = configuration.document ?? new Y.Doc();
|
||||
this.indexeddbProvider = new IndexeddbPersistence(
|
||||
`${this.configuration.indexedDBPrefix}-${this.configuration.name}`,
|
||||
this.document
|
||||
);
|
||||
this.indexeddbProvider.on("synced", () => {
|
||||
this.configuration.hasIndexedDBSynced = true;
|
||||
});
|
||||
this.document.on("update", this.documentUpdateHandler.bind(this));
|
||||
this.document.on("destroy", this.documentDestroyHandler.bind(this));
|
||||
}
|
||||
|
||||
private indexeddbProvider: IndexeddbPersistence;
|
||||
|
||||
public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
}
|
||||
|
||||
get document() {
|
||||
return this.configuration.document;
|
||||
}
|
||||
|
||||
public hasUnsyncedChanges(): boolean {
|
||||
return this.unsyncedChanges > 0;
|
||||
}
|
||||
|
||||
private resetUnsyncedChanges() {
|
||||
this.unsyncedChanges = 0;
|
||||
}
|
||||
|
||||
private incrementUnsyncedChanges() {
|
||||
this.unsyncedChanges += 1;
|
||||
}
|
||||
|
||||
public setSynced() {
|
||||
this.resetUnsyncedChanges();
|
||||
}
|
||||
|
||||
public async hasIndexedDBSynced() {
|
||||
await this.indexeddbProvider.whenSynced;
|
||||
return this.configuration.hasIndexedDBSynced;
|
||||
}
|
||||
|
||||
async documentUpdateHandler(_update: Uint8Array, origin: any) {
|
||||
await this.indexeddbProvider.whenSynced;
|
||||
|
||||
// return if the update is from the provider itself
|
||||
if (origin === this) return;
|
||||
|
||||
// call onChange with the update
|
||||
const stateVector = Y.encodeStateAsUpdate(this.document);
|
||||
|
||||
if (!this.initialSync) {
|
||||
this.configuration.onChange?.(stateVector, "initialSync");
|
||||
this.initialSync = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.configuration.onChange?.(stateVector);
|
||||
this.incrementUnsyncedChanges();
|
||||
}
|
||||
|
||||
getUpdateFromIndexedDB(): Uint8Array {
|
||||
const update = Y.encodeStateAsUpdate(this.document);
|
||||
return update;
|
||||
}
|
||||
|
||||
documentDestroyHandler() {
|
||||
this.document.off("update", this.documentUpdateHandler);
|
||||
this.document.off("destroy", this.documentDestroyHandler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// import * as Y from "yjs";
|
||||
|
||||
export interface CompleteCollaboratorProviderConfiguration {
|
||||
/**
|
||||
* The identifier/name of your document
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The actual Y.js document
|
||||
*/
|
||||
// document: Y.Doc;
|
||||
/**
|
||||
* onChange callback
|
||||
*/
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
}
|
||||
|
||||
export type CollaborationProviderConfiguration = Required<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
|
||||
Partial<CompleteCollaboratorProviderConfiguration>;
|
||||
|
||||
export class CollaborationProvider {
|
||||
public configuration: CompleteCollaboratorProviderConfiguration = {
|
||||
name: "",
|
||||
// @ts-expect-error cannot be undefined
|
||||
document: undefined,
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
constructor(configuration: CollaborationProviderConfiguration) {
|
||||
this.setConfiguration(configuration);
|
||||
|
||||
// this.configuration.document = configuration.document ?? new Y.Doc();
|
||||
// this.document.on("update", this.documentUpdateHandler.bind(this));
|
||||
// this.document.on("destroy", this.documentDestroyHandler.bind(this));
|
||||
}
|
||||
|
||||
public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
}
|
||||
|
||||
// get document() {
|
||||
// // return this.configuration.document;
|
||||
// }
|
||||
|
||||
documentUpdateHandler(update: Uint8Array, origin: any) {
|
||||
// return if the update is from the provider itself
|
||||
if (origin === this) return;
|
||||
|
||||
// call onChange with the update
|
||||
this.configuration.onChange?.(update);
|
||||
}
|
||||
|
||||
documentDestroyHandler() {
|
||||
// this.document.off("update", this.documentUpdateHandler);
|
||||
// this.document.off("destroy", this.documentDestroyHandler);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
import { UploadImage } from "@plane/editor-core";
|
||||
import { DragAndDrop, SlashCommand } from "@plane/editor-extensions";
|
||||
import { EnterKeyExtension } from "./enter-key-extension";
|
||||
import { CollaborationProvider } from "src/providers/collaboration-provider";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
|
||||
type TArguments = {
|
||||
uploadFile: UploadImage;
|
||||
dragDropEnabled?: boolean;
|
||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
|
||||
onEnterKeyPress?: () => void;
|
||||
provider: CollaborationProvider;
|
||||
};
|
||||
|
||||
export const RichTextEditorExtensions = ({
|
||||
uploadFile,
|
||||
dragDropEnabled,
|
||||
setHideDragHandle,
|
||||
onEnterKeyPress,
|
||||
}: TArguments) => [
|
||||
export const RichTextEditorExtensions = ({ uploadFile, dragDropEnabled, setHideDragHandle, provider }: TArguments) => [
|
||||
SlashCommand(uploadFile),
|
||||
dragDropEnabled === true && DragAndDrop(setHideDragHandle),
|
||||
// TODO; add the extension conditionally for forms that don't require it
|
||||
// EnterKeyExtension(onEnterKeyPress),
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -7,24 +7,22 @@ import {
|
||||
getEditorClassNames,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
useEditor,
|
||||
EditorRefApi,
|
||||
TFileHandler,
|
||||
} from "@plane/editor-core";
|
||||
// extensions
|
||||
import { RichTextEditorExtensions } from "src/ui/extensions";
|
||||
// components
|
||||
import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
|
||||
import { useRichTextEditor } from "src/hooks/use-rich-text-editor";
|
||||
|
||||
export type IRichTextEditor = {
|
||||
initialValue: string;
|
||||
value?: string | null;
|
||||
value: { descriptionYJS: Uint8Array; updateId: string };
|
||||
dragDropEnabled?: boolean;
|
||||
fileHandler: TFileHandler;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id?: string;
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
onChange: (updates: Uint8Array) => void;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
mentionHandler: {
|
||||
@@ -34,13 +32,13 @@ export type IRichTextEditor = {
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
onEnterKeyPress?: (e?: any) => void;
|
||||
indexedDBPrefix: string;
|
||||
};
|
||||
|
||||
const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const {
|
||||
onChange,
|
||||
dragDropEnabled,
|
||||
initialValue,
|
||||
value,
|
||||
fileHandler,
|
||||
containerClassName,
|
||||
@@ -51,7 +49,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
placeholder,
|
||||
tabIndex,
|
||||
mentionHandler,
|
||||
onEnterKeyPress,
|
||||
handleEditorReady,
|
||||
} = props;
|
||||
|
||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
|
||||
@@ -61,25 +59,21 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
|
||||
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
||||
};
|
||||
|
||||
const editor = useEditor({
|
||||
// use document editor
|
||||
const { editor, isIndexedDbSynced } = useRichTextEditor({
|
||||
id,
|
||||
indexedDBPrefix,
|
||||
editorClassName,
|
||||
fileHandler,
|
||||
onChange,
|
||||
initialValue,
|
||||
value,
|
||||
onChange,
|
||||
handleEditorReady,
|
||||
forwardedRef,
|
||||
// rerenderOnPropsChange,
|
||||
extensions: RichTextEditorExtensions({
|
||||
uploadFile: fileHandler.upload,
|
||||
dragDropEnabled,
|
||||
setHideDragHandle: setHideDragHandleFunction,
|
||||
onEnterKeyPress,
|
||||
}),
|
||||
tabIndex,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
setHideDragHandleFunction,
|
||||
tabIndex,
|
||||
dragDropEnabled,
|
||||
});
|
||||
|
||||
const editorContainerClassName = getEditorClassNames({
|
||||
@@ -88,7 +82,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
if (!editor || !isIndexedDbSynced) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer
|
||||
|
||||
13
packages/types/src/issues/issue.d.ts
vendored
13
packages/types/src/issues/issue.d.ts
vendored
@@ -3,9 +3,13 @@ import { TIssueAttachment } from "./issue_attachment";
|
||||
import { TIssueLink } from "./issue_link";
|
||||
import { TIssueReaction } from "./issue_reaction";
|
||||
|
||||
// new issue structure types
|
||||
export type TIssueDescription = {
|
||||
description_binary: string;
|
||||
description_html: string;
|
||||
};
|
||||
|
||||
export type TBaseIssue = {
|
||||
// new issue structure types
|
||||
export type TBaseIssue = TIssueDescription & {
|
||||
id: string;
|
||||
sequence_id: number;
|
||||
name: string;
|
||||
@@ -40,10 +44,9 @@ export type TBaseIssue = {
|
||||
};
|
||||
|
||||
export type TIssue = TBaseIssue & {
|
||||
description_html?: string;
|
||||
is_subscribed?: boolean;
|
||||
|
||||
parent?: partial<TIssue>;
|
||||
parent?: Partial<TIssue>;
|
||||
|
||||
issue_reactions?: TIssueReaction[];
|
||||
issue_attachment?: TIssueAttachment[];
|
||||
@@ -84,7 +87,7 @@ export type TIssuesResponse = {
|
||||
total_pages: number;
|
||||
extra_stats: null;
|
||||
results: TIssueResponseResults;
|
||||
}
|
||||
};
|
||||
|
||||
export type TBulkIssueProperties = Pick<
|
||||
TIssue,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { PageEditorBody, PageEditorHeaderRoot } from "@/components/pages";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { usePage, useProjectPages } from "@/hooks/store";
|
||||
import { usePageDescription } from "@/hooks/use-page-description";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
const PageDetailsPage = observer(() => {
|
||||
@@ -50,6 +51,16 @@ const PageDetailsPage = observer(() => {
|
||||
}
|
||||
);
|
||||
|
||||
// project-description
|
||||
const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS, handleSaveDescription } = usePageDescription(
|
||||
{
|
||||
editorRef,
|
||||
page,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
}
|
||||
);
|
||||
|
||||
if ((!page || !id) && !pageDetailsError)
|
||||
return (
|
||||
<div className="size-full grid place-items-center">
|
||||
@@ -104,6 +115,7 @@ const PageDetailsPage = observer(() => {
|
||||
editorReady={editorReady}
|
||||
readOnlyEditorReady={readOnlyEditorReady}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
markings={markings}
|
||||
page={page}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
@@ -118,6 +130,9 @@ const PageDetailsPage = observer(() => {
|
||||
page={page}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
updateMarkings={updateMarkings}
|
||||
handleDescriptionChange={handleDescriptionChange}
|
||||
isDescriptionReady={isDescriptionReady}
|
||||
pageDescriptionYJS={pageDescriptionYJS}
|
||||
/>
|
||||
<IssuePeekOverview />
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction, useEffect, useMemo } from "react";
|
||||
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { TIssue, TIssueDescription } from "@plane/types";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { TIssue } from "@plane/types";
|
||||
import { Loader, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { InboxIssueContentProperties } from "@/components/inbox/content";
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
IssueAttachmentRoot,
|
||||
} from "@/components/issues";
|
||||
// hooks
|
||||
import { useEventTracker, useProjectInbox, useUser } from "@/hooks/store";
|
||||
import { useEventTracker, useInboxIssues, useIssueDetail, useProjectInbox, useUser } from "@/hooks/store";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
// store types
|
||||
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
|
||||
@@ -32,6 +32,7 @@ type Props = {
|
||||
|
||||
export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props;
|
||||
// hooks
|
||||
const { data: currentUser } = useUser();
|
||||
@@ -59,6 +60,13 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||
fetch: async (_workspaceSlug: string, _projectId: string, _issueId: string) => {
|
||||
return;
|
||||
},
|
||||
fetchDescription: async (_workspaceSlug: string, _projectId: string, _issueId: string) => {
|
||||
try {
|
||||
return await inboxIssue.fetchIssueDescription();
|
||||
} catch (error) {
|
||||
console.error("Error fetching the parent issue");
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars, arrow-body-style
|
||||
remove: async (_workspaceSlug: string, _projectId: string, _issueId: string) => {
|
||||
return;
|
||||
@@ -92,12 +100,42 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
}
|
||||
},
|
||||
updateDescription: async (workspaceSlug: string, projectId: string, issueId: string, data: TIssueDescription) => {
|
||||
try {
|
||||
await inboxIssue.updateIssueDescription(data);
|
||||
captureIssueEvent({
|
||||
eventName: "Inbox issue updated",
|
||||
payload: { ...data, state: "SUCCESS", element: "Inbox" },
|
||||
updates: {
|
||||
changed_property: Object.keys(data).join(","),
|
||||
change_details: Object.values(data).join(","),
|
||||
},
|
||||
path: pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
title: "Issue update failed",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Issue update failed",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: "Inbox issue updated",
|
||||
payload: { state: "SUCCESS", element: "Inbox" },
|
||||
updates: {
|
||||
changed_property: Object.keys(data).join(","),
|
||||
change_details: Object.values(data).join(","),
|
||||
},
|
||||
path: pathname,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[inboxIssue]
|
||||
[captureIssueEvent, inboxIssue, pathname]
|
||||
);
|
||||
|
||||
if (!issue?.project_id || !issue?.id) return <></>;
|
||||
|
||||
// store hooks
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg space-y-4 pl-3">
|
||||
@@ -113,23 +151,18 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||
containerClassName="-ml-3"
|
||||
/>
|
||||
|
||||
{loader === "issue-loading" ? (
|
||||
<Loader className="min-h-[6rem] rounded-md border border-custom-border-200">
|
||||
<Loader.Item width="100%" height="140px" />
|
||||
</Loader>
|
||||
) : (
|
||||
<IssueDescriptionInput
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
issueId={issue.id}
|
||||
swrIssueDescription={issue.description_html ?? "<p></p>"}
|
||||
initialValue={issue.description_html ?? "<p></p>"}
|
||||
disabled={!isEditable}
|
||||
issueOperations={issueOperations}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
containerClassName="-ml-3 !mb-6 border-none"
|
||||
/>
|
||||
)}
|
||||
<IssueDescriptionInput
|
||||
indexedDBPrefix={"inbox-issue-"}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
issueId={issue.id}
|
||||
disabled={!isEditable}
|
||||
issueOperations={issueOperations}
|
||||
issueDescriptionHTML={issue.description_html as string}
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
containerClassName="-ml-3 !mb-6 border-none"
|
||||
/>
|
||||
|
||||
{currentUser && (
|
||||
<IssueReaction
|
||||
|
||||
@@ -85,14 +85,14 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
|
||||
const handleFormSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!descriptionEditorRef.current?.isEditorReadyToDiscard()) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Editor is still processing changes. Please wait before proceeding.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// if (!descriptionEditorRef.current?.isEditorReadyToDiscard()) {
|
||||
// setToast({
|
||||
// type: TOAST_TYPE.ERROR,
|
||||
// title: "Error!",
|
||||
// message: "Editor is still processing changes. Please wait before proceeding.",
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
|
||||
const payload: Partial<TIssue> = {
|
||||
name: formData.name || "",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EditorRefApi } from "@plane/rich-text-editor";
|
||||
import { TIssue } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor";
|
||||
import { RichTextEditor } from "@/components/editor";
|
||||
// helpers
|
||||
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
@@ -37,6 +37,7 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
|
||||
</Loader>
|
||||
);
|
||||
|
||||
return null;
|
||||
return (
|
||||
<RichTextEditor
|
||||
initialValue={!data?.description_html || data?.description_html === "" ? "<p></p>" : data?.description_html}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useCallback, useEffect, useState } from "react";
|
||||
import debounce from "lodash/debounce";
|
||||
import { FC, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
|
||||
import { EditorRefApi } from "@plane/rich-text-editor";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
@@ -16,118 +14,80 @@ import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
import { useIssueDescription } from "@/hooks/use-issue-description";
|
||||
|
||||
export type IssueDescriptionInputProps = {
|
||||
containerClassName?: string;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
initialValue: string | undefined;
|
||||
disabled?: boolean;
|
||||
issueOperations: TIssueOperations;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
isSubmitting: "submitting" | "submitted" | "saved";
|
||||
setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void;
|
||||
swrIssueDescription: string | null | undefined;
|
||||
issueDescriptionHTML: string;
|
||||
indexedDBPrefix: string;
|
||||
};
|
||||
|
||||
export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((props) => {
|
||||
const {
|
||||
indexedDBPrefix,
|
||||
containerClassName,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
disabled,
|
||||
swrIssueDescription,
|
||||
initialValue,
|
||||
issueOperations,
|
||||
issueDescriptionHTML,
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
placeholder,
|
||||
} = props;
|
||||
|
||||
const { handleSubmit, reset, control } = useForm<TIssue>({
|
||||
defaultValues: {
|
||||
description_html: initialValue,
|
||||
},
|
||||
});
|
||||
|
||||
const [localIssueDescription, setLocalIssueDescription] = useState({
|
||||
id: issueId,
|
||||
description_html: initialValue,
|
||||
});
|
||||
|
||||
const handleDescriptionFormSubmit = useCallback(
|
||||
async (formData: Partial<TIssue>) => {
|
||||
await issueOperations.update(workspaceSlug, projectId, issueId, {
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, issueId, issueOperations]
|
||||
);
|
||||
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
// computed values
|
||||
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string;
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
|
||||
// reset form values
|
||||
useEffect(() => {
|
||||
if (!issueId) return;
|
||||
reset({
|
||||
id: issueId,
|
||||
description_html: initialValue === "" ? "<p></p>" : initialValue,
|
||||
});
|
||||
setLocalIssueDescription({
|
||||
id: issueId,
|
||||
description_html: initialValue === "" ? "<p></p>" : initialValue,
|
||||
});
|
||||
}, [initialValue, issueId, reset]);
|
||||
const { handleDescriptionChange, isDescriptionReady, issueDescriptionYJS } = useIssueDescription({
|
||||
issueDescriptionHTML,
|
||||
editorRef,
|
||||
projectId,
|
||||
updateIssueDescription: issueOperations.updateDescription,
|
||||
fetchIssueDescription: issueOperations.fetchDescription,
|
||||
issueId,
|
||||
setIsSubmitting,
|
||||
isSubmitting,
|
||||
canUpdateDescription: true,
|
||||
workspaceSlug,
|
||||
});
|
||||
|
||||
// ADDING handleDescriptionFormSubmit TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS
|
||||
// TODO: Verify the exhaustive-deps warning
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedFormSave = useCallback(
|
||||
debounce(async () => {
|
||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
|
||||
}, 1500),
|
||||
[handleSubmit, issueId]
|
||||
);
|
||||
if (!isDescriptionReady || !issueDescriptionYJS)
|
||||
return (
|
||||
<Loader>
|
||||
<Loader.Item height="150px" />
|
||||
</Loader>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{localIssueDescription.description_html ? (
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { onChange } }) =>
|
||||
!disabled ? (
|
||||
<RichTextEditor
|
||||
id={issueId}
|
||||
initialValue={localIssueDescription.description_html ?? "<p></p>"}
|
||||
value={swrIssueDescription ?? null}
|
||||
workspaceSlug={workspaceSlug}
|
||||
workspaceId={workspaceId}
|
||||
projectId={projectId}
|
||||
dragDropEnabled
|
||||
onChange={(_description: object, description_html: string) => {
|
||||
setIsSubmitting("submitting");
|
||||
onChange(description_html);
|
||||
debouncedFormSave();
|
||||
}}
|
||||
placeholder={
|
||||
placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value)
|
||||
}
|
||||
containerClassName={containerClassName}
|
||||
/>
|
||||
) : (
|
||||
<RichTextReadOnlyEditor
|
||||
initialValue={localIssueDescription.description_html ?? ""}
|
||||
containerClassName={containerClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{!disabled ? (
|
||||
<RichTextEditor
|
||||
ref={editorRef}
|
||||
id={issueId}
|
||||
value={{ descriptionYJS: issueDescriptionYJS, updateId: issueId }}
|
||||
indexedDBPrefix={indexedDBPrefix}
|
||||
workspaceSlug={workspaceSlug}
|
||||
workspaceId={workspaceId}
|
||||
projectId={projectId}
|
||||
dragDropEnabled
|
||||
onChange={handleDescriptionChange}
|
||||
placeholder={placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value)}
|
||||
containerClassName={containerClassName}
|
||||
/>
|
||||
) : (
|
||||
<Loader>
|
||||
<Loader.Item height="150px" />
|
||||
</Loader>
|
||||
<RichTextReadOnlyEditor initialValue={""} containerClassName={containerClassName} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -92,11 +92,12 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||
|
||||
{/* {issue?.description_html === issueDescription && ( */}
|
||||
<IssueDescriptionInput
|
||||
swrIssueDescription={swrIssueDetails?.description_html}
|
||||
indexedDBPrefix={"issue"}
|
||||
issueDescriptionHTML={issue.description_html}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
isSubmitting={isSubmitting}
|
||||
issueId={issue.id}
|
||||
initialValue={issue.description_html}
|
||||
disabled={!isEditable}
|
||||
issueOperations={issueOperations}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FC, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
import { TIssue, TIssueDescription } from "@plane/types";
|
||||
// ui
|
||||
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
|
||||
// components
|
||||
@@ -25,7 +25,14 @@ import { IssueDetailsSidebar } from "./sidebar";
|
||||
|
||||
export type TIssueOperations = {
|
||||
fetch: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
fetchDescription: (workspaceSlug: string, projectId: string, issueId: string) => Promise<string>;
|
||||
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
updateDescription: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
data: TIssueDescription
|
||||
) => Promise<void>;
|
||||
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
archive?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
restore?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
@@ -64,7 +71,9 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
fetchIssue,
|
||||
fetchIssueDescription,
|
||||
updateIssue,
|
||||
updateIssueDescription,
|
||||
removeIssue,
|
||||
archiveIssue,
|
||||
addCycleToIssue,
|
||||
@@ -74,9 +83,11 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
removeIssueFromModule,
|
||||
removeModulesFromIssue,
|
||||
} = useIssueDetail();
|
||||
|
||||
const {
|
||||
issues: { removeIssue: removeArchivedIssue },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
|
||||
const { captureIssueEvent } = useEventTracker();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
@@ -92,6 +103,13 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
console.error("Error fetching the parent issue");
|
||||
}
|
||||
},
|
||||
fetchDescription: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
return await fetchIssueDescription(workspaceSlug, projectId, issueId);
|
||||
} catch (error) {
|
||||
console.error("Error fetching the parent issue");
|
||||
}
|
||||
},
|
||||
update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
||||
try {
|
||||
await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
@@ -121,6 +139,35 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
});
|
||||
}
|
||||
},
|
||||
updateDescription: async (workspaceSlug: string, projectId: string, issueId: string, data: TIssueDescription) => {
|
||||
try {
|
||||
await updateIssueDescription(workspaceSlug, projectId, issueId, data);
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { ...data, issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: Object.keys(data).join(","),
|
||||
change_details: Object.values(data).join(","),
|
||||
},
|
||||
path: pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
payload: { state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: Object.keys(data).join(","),
|
||||
change_details: Object.values(data).join(","),
|
||||
},
|
||||
path: pathname,
|
||||
});
|
||||
setToast({
|
||||
title: "Error!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Issue update failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
if (is_archived) await removeArchivedIssue(workspaceSlug, projectId, issueId);
|
||||
@@ -363,6 +410,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
|
||||
// issue details
|
||||
const issue = getIssueById(issueId);
|
||||
console.log("=================issue", issue);
|
||||
// checking if issue is editable, based on user role
|
||||
const isEditable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
|
||||
@@ -184,15 +184,15 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
const issueName = watch("name");
|
||||
|
||||
const handleFormSubmit = async (formData: Partial<TIssue>, is_draft_issue = false) => {
|
||||
// Check if the editor is ready to discard
|
||||
if (!editorRef.current?.isEditorReadyToDiscard()) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Editor is not ready to discard changes.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// // Check if the editor is ready to discard
|
||||
// if (!editorRef.current?.isEditorReadyToDiscard()) {
|
||||
// setToast({
|
||||
// type: TOAST_TYPE.ERROR,
|
||||
// title: "Error!",
|
||||
// message: "Editor is not ready to discard changes.",
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
|
||||
const submitData = !data?.id
|
||||
? formData
|
||||
@@ -436,28 +436,28 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
</Loader>
|
||||
) : (
|
||||
<>
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<RichTextEditor
|
||||
initialValue={value ?? ""}
|
||||
value={data.description_html}
|
||||
workspaceSlug={workspaceSlug?.toString() as string}
|
||||
workspaceId={workspaceId}
|
||||
projectId={projectId}
|
||||
onChange={(_description: object, description_html: string) => {
|
||||
onChange(description_html);
|
||||
handleFormChange();
|
||||
}}
|
||||
onEnterKeyPress={() => submitBtnRef?.current?.click()}
|
||||
ref={editorRef}
|
||||
tabIndex={getTabIndex("description_html")}
|
||||
placeholder={getDescriptionPlaceholder}
|
||||
containerClassName="pt-3 min-h-[150px] max-h-64 overflow-y-auto vertical-scrollbar scrollbar-sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* <Controller */}
|
||||
{/* name="description_html" */}
|
||||
{/* control={control} */}
|
||||
{/* render={({ field: { value, onChange } }) => ( */}
|
||||
{/* <RichTextEditor */}
|
||||
{/* // initialValue={value ?? ""} */}
|
||||
{/* // value={data.description_html} */}
|
||||
{/* workspaceSlug={workspaceSlug?.toString() as string} */}
|
||||
{/* workspaceId={workspaceId} */}
|
||||
{/* projectId={projectId} */}
|
||||
{/* // onChange={(_description: object, description_html: string) => { */}
|
||||
{/* // onChange(description_html); */}
|
||||
{/* // handleFormChange(); */}
|
||||
{/* // }} */}
|
||||
{/* onEnterKeyPress={() => submitBtnRef?.current?.click()} */}
|
||||
{/* ref={editorRef} */}
|
||||
{/* tabIndex={getTabIndex("description_html")} */}
|
||||
{/* placeholder={getDescriptionPlaceholder} */}
|
||||
{/* containerClassName="pt-3 min-h-[150px] max-h-64 overflow-y-auto vertical-scrollbar scrollbar-sm" */}
|
||||
{/* /> */}
|
||||
{/* )} */}
|
||||
{/* /> */}
|
||||
<div className="border-0.5 z-10 flex items-center justify-end gap-2 p-3">
|
||||
{issueName && issueName.trim() !== "" && config?.has_openai_configured && (
|
||||
<button
|
||||
@@ -769,15 +769,15 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (editorRef.current?.isEditorReadyToDiscard()) {
|
||||
onClose();
|
||||
} else {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Editor is still processing changes. Please wait before proceeding.",
|
||||
});
|
||||
}
|
||||
// if (editorRef.current?.isEditorReadyToDiscard()) {
|
||||
// onClose();
|
||||
// } else {
|
||||
// setToast({
|
||||
// type: TOAST_TYPE.ERROR,
|
||||
// title: "Error!",
|
||||
// message: "Editor is still processing changes. Please wait before proceeding.",
|
||||
// });
|
||||
// }
|
||||
}}
|
||||
tabIndex={getTabIndex("discard_button")}
|
||||
>
|
||||
|
||||
@@ -76,9 +76,6 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
issueId={issue.id}
|
||||
initialValue={issueDescription}
|
||||
// for now peek overview doesn't have live syncing while tab changes
|
||||
swrIssueDescription={issueDescription}
|
||||
disabled={disabled}
|
||||
issueOperations={issueOperations}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/compon
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
|
||||
import { usePageDescription } from "@/hooks/use-page-description";
|
||||
// import { usePageDescription } from "@/hooks/use-page-description";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
@@ -35,6 +35,9 @@ type Props = {
|
||||
handleEditorReady: (value: boolean) => void;
|
||||
handleReadOnlyEditorReady: (value: boolean) => void;
|
||||
updateMarkings: (description_html: string) => void;
|
||||
handleDescriptionChange: (update: Uint8Array, source?: string | undefined) => void;
|
||||
isDescriptionReady: boolean;
|
||||
pageDescriptionYJS: Uint8Array | undefined;
|
||||
};
|
||||
|
||||
export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
@@ -47,6 +50,9 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
page,
|
||||
sidePeekVisible,
|
||||
updateMarkings,
|
||||
isDescriptionReady,
|
||||
pageDescriptionYJS,
|
||||
handleDescriptionChange,
|
||||
} = props;
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
@@ -65,13 +71,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
const { isContentEditable, updateTitle, setIsSubmitting } = page;
|
||||
const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : [];
|
||||
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
|
||||
// project-description
|
||||
const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS } = usePageDescription({
|
||||
editorRef,
|
||||
page,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
});
|
||||
|
||||
// use-mention
|
||||
const { mentionHighlights, mentionSuggestions } = useMention({
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
|
||||
@@ -22,10 +22,11 @@ type Props = {
|
||||
handleDuplicatePage: () => void;
|
||||
page: IPage;
|
||||
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
|
||||
handleSaveDescription: (initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
};
|
||||
|
||||
export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, handleDuplicatePage, page, readOnlyEditorRef } = props;
|
||||
const { editorRef, handleDuplicatePage, page, readOnlyEditorRef, handleSaveDescription } = props;
|
||||
// states
|
||||
const [gptModalOpen, setGptModal] = useState(false);
|
||||
// store hooks
|
||||
@@ -79,6 +80,7 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
<PageOptionsDropdown
|
||||
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
page={page}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ type Props = {
|
||||
sidePeekVisible: boolean;
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
editorReady: boolean;
|
||||
handleSaveDescription: (initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
readOnlyEditorReady: boolean;
|
||||
};
|
||||
|
||||
@@ -30,6 +31,7 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
page,
|
||||
sidePeekVisible,
|
||||
setSidePeekVisible,
|
||||
handleSaveDescription,
|
||||
} = props;
|
||||
// derived values
|
||||
const { isContentEditable } = page;
|
||||
@@ -52,6 +54,7 @@ export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
<PageExtraOptions
|
||||
editorRef={editorRef}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
page={page}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
|
||||
@@ -18,10 +18,11 @@ type Props = {
|
||||
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
|
||||
handleDuplicatePage: () => void;
|
||||
page: IPage;
|
||||
handleSaveDescription: (initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
};
|
||||
|
||||
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, handleDuplicatePage, page } = props;
|
||||
const { editorRef, handleDuplicatePage, page, handleSaveDescription } = props;
|
||||
// store values
|
||||
const {
|
||||
archived_at,
|
||||
@@ -75,10 +76,15 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
})
|
||||
);
|
||||
|
||||
const saveDescriptionYJSAndPerformAction = (action: () => void) => async () => {
|
||||
await handleSaveDescription();
|
||||
action();
|
||||
};
|
||||
|
||||
// menu items list
|
||||
const MENU_ITEMS: {
|
||||
key: string;
|
||||
action: () => void;
|
||||
action: (() => void) | (() => Promise<void>);
|
||||
label: string;
|
||||
icon: React.FC<any>;
|
||||
shouldRender: boolean;
|
||||
@@ -116,21 +122,21 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
action: handleDuplicatePage,
|
||||
action: saveDescriptionYJSAndPerformAction(handleDuplicatePage),
|
||||
label: "Make a copy",
|
||||
icon: Copy,
|
||||
shouldRender: canCurrentUserDuplicatePage,
|
||||
},
|
||||
{
|
||||
key: "lock-unlock-page",
|
||||
action: is_locked ? handleUnlockPage : handleLockPage,
|
||||
action: is_locked ? handleUnlockPage : saveDescriptionYJSAndPerformAction(handleLockPage),
|
||||
label: is_locked ? "Unlock page" : "Lock page",
|
||||
icon: is_locked ? LockOpen : Lock,
|
||||
shouldRender: canCurrentUserLockPage,
|
||||
},
|
||||
{
|
||||
key: "archive-restore-page",
|
||||
action: archived_at ? handleRestorePage : handleArchivePage,
|
||||
action: archived_at ? handleRestorePage : saveDescriptionYJSAndPerformAction(handleArchivePage),
|
||||
label: archived_at ? "Restore page" : "Archive page",
|
||||
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
|
||||
shouldRender: canCurrentUserArchivePage,
|
||||
|
||||
@@ -19,6 +19,7 @@ type Props = {
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
editorReady: boolean;
|
||||
readOnlyEditorReady: boolean;
|
||||
handleSaveDescription: (initSyncVectorAsUpdate?: Uint8Array | undefined) => Promise<void>;
|
||||
};
|
||||
|
||||
export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
@@ -32,6 +33,7 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
page,
|
||||
sidePeekVisible,
|
||||
setSidePeekVisible,
|
||||
handleSaveDescription,
|
||||
} = props;
|
||||
// derived values
|
||||
const { isContentEditable } = page;
|
||||
@@ -63,12 +65,14 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
<PageExtraOptions
|
||||
editorRef={editorRef}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
page={page}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:hidden">
|
||||
<PageEditorMobileHeaderRoot
|
||||
handleSaveDescription={handleSaveDescription}
|
||||
editorRef={editorRef}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
editorReady={editorReady}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const PageContentLoader = () => (
|
||||
<div className="relative w-full h-full flex flex-col">
|
||||
{/* header */}
|
||||
<div className="px-16 flex-shrink-0 relative flex items-center justify-between h-12 border-b border-custom-border-100">
|
||||
<div className="px-4 flex-shrink-0 relative flex items-center justify-between h-12 border-b border-custom-border-100">
|
||||
{/* left options */}
|
||||
<Loader className="flex-shrink-0 w-[280px]">
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
@@ -47,7 +45,34 @@ export const PageContentLoader = () => (
|
||||
</div>
|
||||
|
||||
{/* content */}
|
||||
<div className="px-16 w-full h-full overflow-hidden relative flex">
|
||||
<div className="px-4 w-full h-full overflow-hidden relative flex">
|
||||
{/* table of content loader */}
|
||||
<div className="flex-shrink-0 w-[280px] pr-5 py-5">
|
||||
<Loader className="w-full space-y-4">
|
||||
<Loader.Item width="100%" height="24px" />
|
||||
<div className="space-y-2">
|
||||
<Loader.Item width="60%" height="12px" />
|
||||
<div className="ml-6 space-y-2">
|
||||
<Loader.Item width="80%" height="12px" />
|
||||
<Loader.Item width="100%" height="12px" />
|
||||
</div>
|
||||
<Loader.Item width="60%" height="12px" />
|
||||
<div className="ml-6 space-y-2">
|
||||
<Loader.Item width="80%" height="12px" />
|
||||
<Loader.Item width="100%" height="12px" />
|
||||
</div>
|
||||
<Loader.Item width="100%" height="12px" />
|
||||
<Loader.Item width="60%" height="12px" />
|
||||
<div className="ml-6 space-y-2">
|
||||
<Loader.Item width="80%" height="12px" />
|
||||
<Loader.Item width="100%" height="12px" />
|
||||
</div>
|
||||
<Loader.Item width="80%" height="12px" />
|
||||
<Loader.Item width="100%" height="12px" />
|
||||
</div>
|
||||
</Loader>
|
||||
</div>
|
||||
|
||||
{/* editor loader */}
|
||||
<div className="w-full h-full py-5">
|
||||
<Loader className="relative space-y-4">
|
||||
|
||||
51
web/core/hooks/use-auto-save.ts
Normal file
51
web/core/hooks/use-auto-save.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { debounce } from "lodash"; // You can use lodash or implement your own debounce function
|
||||
|
||||
const AUTO_SAVE_TIME = 10000;
|
||||
|
||||
const useAutoSave = (handleSaveDescription: () => void) => {
|
||||
const intervalIdRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
intervalIdRef.current = setInterval(handleSaveDescription, AUTO_SAVE_TIME);
|
||||
|
||||
return () => {
|
||||
if (intervalIdRef.current) {
|
||||
clearInterval(intervalIdRef.current);
|
||||
}
|
||||
};
|
||||
}, [handleSaveDescription]);
|
||||
|
||||
useEffect(() => {
|
||||
// debounce the function so that excesive calls to handleSaveDescription don't cause multiple calls to the server
|
||||
const debouncedSave = debounce(() => {
|
||||
handleSaveDescription();
|
||||
|
||||
if (intervalIdRef.current) {
|
||||
// clear the interval after saving manually
|
||||
clearInterval(intervalIdRef.current);
|
||||
// then reset the interval for auto-save to keep working
|
||||
intervalIdRef.current = setInterval(handleSaveDescription, AUTO_SAVE_TIME);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
const handleSave = (e: KeyboardEvent) => {
|
||||
const { ctrlKey, metaKey, key } = e;
|
||||
const cmdClicked = ctrlKey || metaKey;
|
||||
|
||||
if (cmdClicked && key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
debouncedSave();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleSave);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleSave);
|
||||
};
|
||||
}, [handleSaveDescription]);
|
||||
};
|
||||
|
||||
export default useAutoSave;
|
||||
63
web/core/hooks/use-auto-save.tsx
Normal file
63
web/core/hooks/use-auto-save.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { debounce } from "lodash"; // You can use lodash or implement your own debounce function
|
||||
|
||||
const AUTO_SAVE_TIME = 10000;
|
||||
|
||||
const useAutoSave = (handleSaveDescription: () => void) => {
|
||||
const intervalIdRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const handleSaveDescriptionRef = useRef(handleSaveDescription);
|
||||
|
||||
// Update the ref to the latest handleSaveDescription function
|
||||
useEffect(() => {
|
||||
handleSaveDescriptionRef.current = handleSaveDescription;
|
||||
}, [handleSaveDescription]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalCallback = () => {
|
||||
handleSaveDescriptionRef.current();
|
||||
};
|
||||
|
||||
intervalIdRef.current = setInterval(intervalCallback, AUTO_SAVE_TIME);
|
||||
|
||||
return () => {
|
||||
if (intervalIdRef.current) {
|
||||
clearInterval(intervalIdRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// debounce the function so that excessive calls to handleSaveDescription don't cause multiple calls to the server
|
||||
const debouncedSave = debounce(() => {
|
||||
handleSaveDescriptionRef.current();
|
||||
|
||||
if (intervalIdRef.current) {
|
||||
// clear the interval after saving manually
|
||||
clearInterval(intervalIdRef.current);
|
||||
// then reset the interval for auto-save to keep working
|
||||
intervalIdRef.current = setInterval(() => {
|
||||
handleSaveDescriptionRef.current();
|
||||
}, AUTO_SAVE_TIME);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
const handleSave = (e: KeyboardEvent) => {
|
||||
const { ctrlKey, metaKey, key } = e;
|
||||
const cmdClicked = ctrlKey || metaKey;
|
||||
|
||||
if (cmdClicked && key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
debouncedSave();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleSave);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleSave);
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
export default useAutoSave;
|
||||
186
web/core/hooks/use-issue-description.ts
Normal file
186
web/core/hooks/use-issue-description.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
// editor
|
||||
import { applyUpdates, proseMirrorJSONToBinaryString } from "@plane/document-editor";
|
||||
import { EditorRefApi, generateJSONfromHTML } from "@plane/editor-core";
|
||||
// hooks
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
// services
|
||||
import useAutoSave from "./use-auto-save";
|
||||
|
||||
type TIssueDescription = {
|
||||
description_binary: string;
|
||||
description_html: string;
|
||||
};
|
||||
type Props = {
|
||||
canUpdateDescription: boolean;
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
isSubmitting: "submitting" | "submitted" | "saved";
|
||||
issueId: string | string[] | undefined;
|
||||
projectId: string | string[] | undefined;
|
||||
setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void;
|
||||
issueDescriptionHTML: string;
|
||||
updateIssueDescription: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
data: TIssueDescription
|
||||
) => Promise<void>;
|
||||
fetchIssueDescription: (workspaceSlug: string, projectId: string, issueId: string) => Promise<string | undefined>;
|
||||
workspaceSlug: string | string[] | undefined;
|
||||
};
|
||||
|
||||
export const useIssueDescription = (props: Props) => {
|
||||
const {
|
||||
indexedDBPrefix,
|
||||
canUpdateDescription,
|
||||
editorRef,
|
||||
isSubmitting,
|
||||
issueId,
|
||||
projectId,
|
||||
setIsSubmitting,
|
||||
issueDescriptionHTML,
|
||||
updateIssueDescription,
|
||||
fetchIssueDescription,
|
||||
workspaceSlug,
|
||||
} = props;
|
||||
// states
|
||||
const [isDescriptionReady, setIsDescriptionReady] = useState(false);
|
||||
const [localDescriptionYJS, setLocalDescriptionYJS] = useState<Uint8Array>();
|
||||
|
||||
const { data: issueDescriptionYJS, mutate: mutateDescriptionYJS } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? `ISSUE_DESCRIPTION_BINARY_${workspaceSlug}_${projectId}_${issueId}` : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? async () => {
|
||||
const encodedDescription = await fetchIssueDescription(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issueId.toString()
|
||||
);
|
||||
// @ts-expect-error - TODO: fix this
|
||||
const decodedDescription = new Uint8Array(encodedDescription);
|
||||
return decodedDescription;
|
||||
}
|
||||
: null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
}
|
||||
);
|
||||
|
||||
// push the new updates to the updates array
|
||||
const handleDescriptionChange = useCallback((update: Uint8Array, source?: string) => {
|
||||
setLocalDescriptionYJS(() => {
|
||||
// handle the initial sync case where indexeddb gives extra update, in
|
||||
// this case we need to save the update to the DB
|
||||
if (source && source === "initialSync") {
|
||||
handleSaveDescription(update);
|
||||
}
|
||||
|
||||
return update;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// if description_binary field is empty, convert description_html to yDoc and update the DB
|
||||
// TODO: this is a one-time operation, and needs to be removed once all the issues are updated
|
||||
useEffect(() => {
|
||||
const changeHTMLToBinary = async () => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
if (!issueDescriptionYJS || !issueDescriptionHTML) return;
|
||||
|
||||
if (issueDescriptionYJS.length === 0 && !isDescriptionReady) {
|
||||
const { contentJSON, editorSchema } = generateJSONfromHTML(issueDescriptionHTML ?? "<p></p>");
|
||||
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
|
||||
|
||||
// TODO - make sure mobx is also taken care of
|
||||
await updateIssueDescription(workspaceSlug.toString(), projectId.toString(), issueId.toString(), {
|
||||
description_binary: yDocBinaryString,
|
||||
description_html: issueDescriptionHTML ?? "<p></p>",
|
||||
});
|
||||
|
||||
await mutateDescriptionYJS();
|
||||
|
||||
setIsDescriptionReady(true);
|
||||
} else setIsDescriptionReady(true);
|
||||
};
|
||||
changeHTMLToBinary();
|
||||
}, [
|
||||
isDescriptionReady,
|
||||
mutateDescriptionYJS,
|
||||
issueDescriptionHTML,
|
||||
updateIssueDescription,
|
||||
issueDescriptionYJS,
|
||||
issueId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
]);
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef?.current?.hasUnsyncedChanges() || isSubmitting === "submitting") {
|
||||
setShowAlert(true);
|
||||
} else {
|
||||
setShowAlert(false);
|
||||
}
|
||||
}, [setShowAlert, isSubmitting, editorRef, localDescriptionYJS]);
|
||||
|
||||
// merge the description from remote to local state and only save if there are local changes
|
||||
const handleSaveDescription = useCallback(
|
||||
async (initSyncVectorAsUpdate?: Uint8Array) => {
|
||||
const update = localDescriptionYJS ?? initSyncVectorAsUpdate;
|
||||
|
||||
if (update == null) return;
|
||||
|
||||
if (!canUpdateDescription) return;
|
||||
|
||||
const applyUpdatesAndSave = async (latestDescription: any, update: Uint8Array) => {
|
||||
if (!workspaceSlug || !projectId || !issueId || !latestDescription || !update) return;
|
||||
|
||||
const combinedBinaryString = applyUpdates(latestDescription, update);
|
||||
const descriptionHTML = editorRef.current?.getHTML() ?? "<p></p>";
|
||||
await updateIssueDescription(workspaceSlug.toString(), projectId.toString(), issueId.toString(), {
|
||||
description_binary: combinedBinaryString,
|
||||
description_html: descriptionHTML ?? "<p></p>",
|
||||
}).finally(() => {
|
||||
editorRef.current?.setSynced();
|
||||
setShowAlert(false);
|
||||
setIsSubmitting("saved");
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
setIsSubmitting("submitting");
|
||||
const latestDescription = await mutateDescriptionYJS();
|
||||
if (latestDescription) {
|
||||
await applyUpdatesAndSave(latestDescription, update);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsSubmitting("saved");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[
|
||||
localDescriptionYJS,
|
||||
setShowAlert,
|
||||
canUpdateDescription,
|
||||
updateIssueDescription,
|
||||
editorRef,
|
||||
issueId,
|
||||
mutateDescriptionYJS,
|
||||
projectId,
|
||||
setIsSubmitting,
|
||||
workspaceSlug,
|
||||
]
|
||||
);
|
||||
|
||||
useAutoSave(handleSaveDescription);
|
||||
|
||||
return {
|
||||
handleDescriptionChange,
|
||||
isDescriptionReady,
|
||||
issueDescriptionYJS,
|
||||
};
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
// editor
|
||||
import { applyUpdates, mergeUpdates, proseMirrorJSONToBinaryString } from "@plane/document-editor";
|
||||
import { applyUpdates, proseMirrorJSONToBinaryString } from "@plane/document-editor";
|
||||
import { EditorRefApi, generateJSONfromHTML } from "@plane/editor-core";
|
||||
// hooks
|
||||
import useAutoSave from "@/hooks/use-auto-save";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
|
||||
// services
|
||||
import { ProjectPageService } from "@/services/page";
|
||||
import { IPage } from "@/store/pages/page";
|
||||
@@ -17,22 +17,27 @@ type Props = {
|
||||
workspaceSlug: string | string[] | undefined;
|
||||
};
|
||||
|
||||
const AUTO_SAVE_TIME = 10000;
|
||||
|
||||
export const usePageDescription = (props: Props) => {
|
||||
const { editorRef, page, projectId, workspaceSlug } = props;
|
||||
// states
|
||||
const [isDescriptionReady, setIsDescriptionReady] = useState(false);
|
||||
const [descriptionUpdates, setDescriptionUpdates] = useState<Uint8Array[]>([]);
|
||||
// derived values
|
||||
const [localDescriptionYJS, setLocalDescriptionYJS] = useState<Uint8Array>();
|
||||
const { isContentEditable, isSubmitting, updateDescription, setIsSubmitting } = page;
|
||||
|
||||
const pageDescription = page.description_html;
|
||||
const pageId = page.id;
|
||||
|
||||
const { data: descriptionYJS, mutate: mutateDescriptionYJS } = useSWR(
|
||||
const { data: pageDescriptionYJS, mutate: mutateDescriptionYJS } = useSWR(
|
||||
workspaceSlug && projectId && pageId ? `PAGE_DESCRIPTION_${workspaceSlug}_${projectId}_${pageId}` : null,
|
||||
workspaceSlug && projectId && pageId
|
||||
? () => projectPageService.fetchDescriptionYJS(workspaceSlug.toString(), projectId.toString(), pageId.toString())
|
||||
? async () => {
|
||||
const encodedDescription = await projectPageService.fetchDescriptionYJS(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
pageId.toString()
|
||||
);
|
||||
const decodedDescription = new Uint8Array(encodedDescription);
|
||||
return decodedDescription;
|
||||
}
|
||||
: null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
@@ -40,15 +45,19 @@ export const usePageDescription = (props: Props) => {
|
||||
revalidateIfStale: false,
|
||||
}
|
||||
);
|
||||
// description in Uint8Array format
|
||||
const pageDescriptionYJS = useMemo(
|
||||
() => (descriptionYJS ? new Uint8Array(descriptionYJS) : undefined),
|
||||
[descriptionYJS]
|
||||
);
|
||||
|
||||
// push the new updates to the updates array
|
||||
const handleDescriptionChange = useCallback((updates: Uint8Array) => {
|
||||
setDescriptionUpdates((prev) => [...prev, updates]);
|
||||
// set the merged local doc by the provider to the react local state
|
||||
const handleDescriptionChange = useCallback((update: Uint8Array, source?: string) => {
|
||||
setLocalDescriptionYJS(() => {
|
||||
// handle the initial sync case where indexeddb gives extra update, in
|
||||
// this case we need to save the update to the DB
|
||||
if (source && source === "initialSync") {
|
||||
handleSaveDescription(update);
|
||||
}
|
||||
|
||||
return update;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// if description_binary field is empty, convert description_html to yDoc and update the DB
|
||||
@@ -56,98 +65,87 @@ export const usePageDescription = (props: Props) => {
|
||||
useEffect(() => {
|
||||
const changeHTMLToBinary = async () => {
|
||||
if (!pageDescriptionYJS || !pageDescription) return;
|
||||
if (pageDescriptionYJS.byteLength === 0) {
|
||||
if (pageDescriptionYJS.length === 0) {
|
||||
const { contentJSON, editorSchema } = generateJSONfromHTML(pageDescription ?? "<p></p>");
|
||||
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
|
||||
|
||||
await updateDescription(yDocBinaryString, pageDescription ?? "<p></p>");
|
||||
|
||||
await mutateDescriptionYJS();
|
||||
|
||||
setIsDescriptionReady(true);
|
||||
} else setIsDescriptionReady(true);
|
||||
};
|
||||
changeHTMLToBinary();
|
||||
}, [mutateDescriptionYJS, pageDescription, pageDescriptionYJS, updateDescription]);
|
||||
|
||||
const handleSaveDescription = useCallback(async () => {
|
||||
if (!isContentEditable) return;
|
||||
const { setShowAlert } = useReloadConfirmations(true);
|
||||
|
||||
const applyUpdatesAndSave = async (latestDescription: any, updates: Uint8Array) => {
|
||||
if (!workspaceSlug || !projectId || !pageId || !latestDescription) return;
|
||||
// convert description to Uint8Array
|
||||
const descriptionArray = new Uint8Array(latestDescription);
|
||||
// apply the updates to the description
|
||||
const combinedBinaryString = applyUpdates(descriptionArray, updates);
|
||||
// get the latest html content
|
||||
const descriptionHTML = editorRef.current?.getHTML() ?? "<p></p>";
|
||||
// make a request to update the descriptions
|
||||
await updateDescription(combinedBinaryString, descriptionHTML).finally(() => setIsSubmitting("saved"));
|
||||
};
|
||||
|
||||
try {
|
||||
setIsSubmitting("submitting");
|
||||
// fetch the latest description
|
||||
const latestDescription = await mutateDescriptionYJS();
|
||||
// return if there are no updates
|
||||
if (descriptionUpdates.length <= 0) {
|
||||
setIsSubmitting("saved");
|
||||
return;
|
||||
}
|
||||
// merge the updates array into one single update
|
||||
const mergedUpdates = mergeUpdates(descriptionUpdates);
|
||||
await applyUpdatesAndSave(latestDescription, mergedUpdates);
|
||||
// reset the updates array to empty
|
||||
setDescriptionUpdates([]);
|
||||
} catch (error) {
|
||||
setIsSubmitting("saved");
|
||||
throw error;
|
||||
useEffect(() => {
|
||||
if (editorRef?.current?.hasUnsyncedChanges() || isSubmitting === "submitting") {
|
||||
setShowAlert(true);
|
||||
} else {
|
||||
setShowAlert(false);
|
||||
}
|
||||
}, [
|
||||
descriptionUpdates,
|
||||
editorRef,
|
||||
isContentEditable,
|
||||
mutateDescriptionYJS,
|
||||
pageId,
|
||||
projectId,
|
||||
setIsSubmitting,
|
||||
updateDescription,
|
||||
workspaceSlug,
|
||||
]);
|
||||
}, [setShowAlert, isSubmitting, editorRef, localDescriptionYJS]);
|
||||
|
||||
// auto-save updates every 10 seconds
|
||||
// handle ctrl/cmd + S to save the description
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(handleSaveDescription, AUTO_SAVE_TIME);
|
||||
// merge the description from remote to local state and only save if there are local changes
|
||||
const handleSaveDescription = useCallback(
|
||||
async (initSyncVectorAsUpdate?: Uint8Array) => {
|
||||
const update = localDescriptionYJS ?? initSyncVectorAsUpdate;
|
||||
|
||||
const handleSave = (e: KeyboardEvent) => {
|
||||
const { ctrlKey, metaKey, key } = e;
|
||||
const cmdClicked = ctrlKey || metaKey;
|
||||
if (update == null) return;
|
||||
|
||||
if (cmdClicked && key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSaveDescription();
|
||||
if (!isContentEditable) return;
|
||||
|
||||
// reset interval timer
|
||||
clearInterval(intervalId);
|
||||
const applyUpdatesAndSave = async (latestDescription: Uint8Array, update: Uint8Array | undefined) => {
|
||||
if (!workspaceSlug || !projectId || !pageId || !latestDescription || !update) return;
|
||||
|
||||
if (!editorRef.current?.hasUnsyncedChanges()) {
|
||||
setIsSubmitting("saved");
|
||||
return;
|
||||
}
|
||||
|
||||
const combinedBinaryString = applyUpdates(latestDescription, update);
|
||||
const descriptionHTML = editorRef.current?.getHTML() ?? "<p></p>";
|
||||
await updateDescription(combinedBinaryString, descriptionHTML).finally(() => {
|
||||
editorRef.current?.setSynced();
|
||||
setShowAlert(false);
|
||||
setIsSubmitting("saved");
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
setIsSubmitting("submitting");
|
||||
const latestDescription = await mutateDescriptionYJS();
|
||||
if (latestDescription) {
|
||||
await applyUpdatesAndSave(latestDescription, update);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsSubmitting("saved");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleSave);
|
||||
},
|
||||
[
|
||||
localDescriptionYJS,
|
||||
setShowAlert,
|
||||
editorRef,
|
||||
isContentEditable,
|
||||
mutateDescriptionYJS,
|
||||
pageId,
|
||||
projectId,
|
||||
setIsSubmitting,
|
||||
updateDescription,
|
||||
workspaceSlug,
|
||||
]
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
window.removeEventListener("keydown", handleSave);
|
||||
};
|
||||
}, [handleSaveDescription]);
|
||||
|
||||
// show a confirm dialog if there are any unsaved changes, or saving is going on
|
||||
const { setShowAlert } = useReloadConfirmations(descriptionUpdates.length > 0 || isSubmitting === "submitting");
|
||||
useEffect(() => {
|
||||
if (descriptionUpdates.length > 0 || isSubmitting === "submitting") setShowAlert(true);
|
||||
else setShowAlert(false);
|
||||
}, [descriptionUpdates, isSubmitting, setShowAlert]);
|
||||
useAutoSave(handleSaveDescription);
|
||||
|
||||
return {
|
||||
handleDescriptionChange,
|
||||
isDescriptionReady,
|
||||
pageDescriptionYJS,
|
||||
handleSaveDescription,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
//TODO: remove temp flag isActive later and use showAlert as the source of truth
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// types
|
||||
import type { TInboxIssue, TIssue, TInboxIssueWithPagination } from "@plane/types";
|
||||
import type { TInboxIssue, TIssue, TInboxIssueWithPagination, TIssueDescription } from "@plane/types";
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
import { APIService } from "@/services/api.service";
|
||||
// helpers
|
||||
@@ -68,6 +68,38 @@ export class InboxIssueService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async fetchDescriptionBinary(workspaceSlug: string, projectId: string, inboxIssueId: string): Promise<any> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/description/`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
},
|
||||
responseType: "arraybuffer",
|
||||
}
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async updateDescriptionBinary(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
inboxIssueId: string,
|
||||
data: TIssueDescription
|
||||
): Promise<any> {
|
||||
return this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/description/`,
|
||||
data
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async destroy(workspaceSlug: string, projectId: string, inboxIssueId: string): Promise<void> {
|
||||
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/`)
|
||||
.then((response) => response?.data)
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
// types
|
||||
import type { TIssue, IIssueDisplayProperties, TIssueLink, TIssueSubIssues, TIssueActivity, TIssuesResponse, TBulkOperationsPayload } from "@plane/types";
|
||||
import type {
|
||||
TIssue,
|
||||
IIssueDisplayProperties,
|
||||
TIssueLink,
|
||||
TIssueSubIssues,
|
||||
TIssueActivity,
|
||||
TIssuesResponse,
|
||||
TBulkOperationsPayload,
|
||||
TIssueDescription,
|
||||
} from "@plane/types";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// services
|
||||
@@ -56,6 +65,36 @@ export class IssueService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async fetchDescriptionBinary(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/description/`, {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
},
|
||||
responseType: "arraybuffer",
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async updateDescriptionBinary(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
data: TIssueDescription
|
||||
): Promise<any> {
|
||||
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/description/`, data)
|
||||
.then((response) => {
|
||||
console.log("response", response);
|
||||
return response?.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("error", error);
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async retrieveIssues(workspaceSlug: string, projectId: string, issueIds: string[]): Promise<TIssue[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/list/`, {
|
||||
params: { issues: issueIds.join(",") },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import clone from "lodash/clone";
|
||||
import set from "lodash/set";
|
||||
import { makeObservable, observable, runInAction, action } from "mobx";
|
||||
import { TIssue, TInboxIssue, TInboxIssueStatus, TInboxDuplicateIssueDetails } from "@plane/types";
|
||||
import { TIssue, TInboxIssue, TInboxIssueStatus, TInboxDuplicateIssueDetails, TIssueDescription } from "@plane/types";
|
||||
// helpers
|
||||
import { EInboxIssueStatus } from "@/helpers/inbox.helper";
|
||||
// services
|
||||
@@ -24,6 +24,8 @@ export interface IInboxIssueStore {
|
||||
updateInboxIssueDuplicateTo: (issueId: string) => Promise<void>; // connecting the inbox issue to the project existing issue
|
||||
updateInboxIssueSnoozeTill: (date: Date | undefined) => Promise<void>; // snooze the issue
|
||||
updateIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue
|
||||
updateIssueDescription: (issue: TIssueDescription) => Promise<void>; // updating the issue
|
||||
fetchIssueDescription: () => Promise<string>; // fetching the issue description
|
||||
updateProjectIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue
|
||||
fetchIssueActivity: () => Promise<void>; // fetching the issue activity
|
||||
}
|
||||
@@ -76,6 +78,7 @@ export class InboxIssueStore implements IInboxIssueStore {
|
||||
updateInboxIssueDuplicateTo: action,
|
||||
updateInboxIssueSnoozeTill: action,
|
||||
updateIssue: action,
|
||||
updateIssueDescription: action,
|
||||
updateProjectIssue: action,
|
||||
fetchIssueActivity: action,
|
||||
});
|
||||
@@ -167,6 +170,39 @@ export class InboxIssueStore implements IInboxIssueStore {
|
||||
}
|
||||
};
|
||||
|
||||
updateIssueDescription = async (issue: TIssueDescription) => {
|
||||
const inboxIssue = clone(this.issue);
|
||||
try {
|
||||
if (!this.issue.id) return;
|
||||
runInAction(() => {
|
||||
this.issue.description_binary = issue.description_binary;
|
||||
this.issue.description_html = issue.description_html;
|
||||
});
|
||||
await this.inboxIssueService.updateDescriptionBinary(this.workspaceSlug, this.projectId, this.issue.id, issue);
|
||||
// fetching activity
|
||||
this.fetchIssueActivity();
|
||||
} catch {
|
||||
runInAction(() => {
|
||||
this.issue.description_binary = inboxIssue.description_binary;
|
||||
this.issue.description_html = inboxIssue.description_html;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchIssueDescription = async () => {
|
||||
try {
|
||||
if (!this.issue.id) return;
|
||||
const issueDescriptionBinary = await this.inboxIssueService.fetchDescriptionBinary(
|
||||
this.workspaceSlug,
|
||||
this.projectId,
|
||||
this.issue.id
|
||||
);
|
||||
return issueDescriptionBinary;
|
||||
} catch {
|
||||
console.error("Failed to fetch issue description");
|
||||
}
|
||||
};
|
||||
|
||||
updateProjectIssue = async (issue: Partial<TIssue>) => {
|
||||
const inboxIssue = clone(this.issue);
|
||||
try {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
TGroupedIssueCount,
|
||||
TPaginationData,
|
||||
TBulkOperationsPayload,
|
||||
TIssueDescription,
|
||||
} from "@plane/types";
|
||||
import { ALL_ISSUES, EIssueLayoutTypes, ISSUE_PRIORITIES } from "@/constants/issue";
|
||||
import { convertToISODateString } from "@/helpers/date-time.helper";
|
||||
@@ -221,6 +222,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore {
|
||||
|
||||
createIssue: action,
|
||||
updateIssue: action,
|
||||
updateIssueDescription: action,
|
||||
createDraftIssue: action,
|
||||
updateDraftIssue: action,
|
||||
issueQuickAdd: action.bound,
|
||||
@@ -569,6 +571,19 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore {
|
||||
}
|
||||
}
|
||||
|
||||
updateIssueDescription = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
data: TIssueDescription
|
||||
) => {
|
||||
try {
|
||||
this.updateIssue(workspaceSlug, projectId, issueId, data, false);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Similar to Create Issue but for creating Draft issues
|
||||
* @param workspaceSlug
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { makeObservable } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
import { TIssue, TIssueDescription } from "@plane/types";
|
||||
// services
|
||||
import { IssueArchiveService, IssueDraftService, IssueService } from "@/services/issue";
|
||||
// types
|
||||
@@ -15,7 +15,18 @@ export interface IIssueStoreActions {
|
||||
issueId: string,
|
||||
issueType?: "DEFAULT" | "DRAFT" | "ARCHIVED"
|
||||
) => Promise<TIssue>;
|
||||
fetchIssueDescription: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string
|
||||
) => Promise<TIssueDescription["description_binary"]>;
|
||||
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
updateIssueDescription: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
data: TIssueDescription
|
||||
) => Promise<void>;
|
||||
removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
addCycleToIssue: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
|
||||
@@ -152,11 +163,39 @@ export class IssueStore implements IIssueStore {
|
||||
}
|
||||
};
|
||||
|
||||
fetchIssueDescription = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
const description = await this.issueService.fetchDescriptionBinary(workspaceSlug, projectId, issueId);
|
||||
return description;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
||||
await this.rootIssueDetailStore.rootIssueStore.projectIssues.updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description update the issue description
|
||||
* @param {string} binaryString
|
||||
* @param {string} descriptionHTML
|
||||
*/
|
||||
updateIssueDescription = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
data: TIssueDescription
|
||||
) => {
|
||||
await this.issueService.updateDescriptionBinary(workspaceSlug, projectId, issueId, data);
|
||||
this.rootIssueDetailStore.rootIssueStore.projectIssues.updateIssueDescription(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
data
|
||||
);
|
||||
};
|
||||
removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
this.rootIssueDetailStore.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
TIssueAttachment,
|
||||
TIssueComment,
|
||||
TIssueCommentReaction,
|
||||
TIssueDescription,
|
||||
TIssueLink,
|
||||
TIssueReaction,
|
||||
TIssueRelationTypes,
|
||||
@@ -186,8 +187,12 @@ export class IssueDetail implements IIssueDetail {
|
||||
issueId: string,
|
||||
issueType: "DEFAULT" | "ARCHIVED" | "DRAFT" = "DEFAULT"
|
||||
) => this.issue.fetchIssue(workspaceSlug, projectId, issueId, issueType);
|
||||
fetchIssueDescription = async (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
this.issue.fetchIssueDescription(workspaceSlug, projectId, issueId);
|
||||
updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) =>
|
||||
this.issue.updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
updateIssueDescription = async (workspaceSlug: string, projectId: string, issueId: string, data: TIssueDescription) =>
|
||||
this.issue.updateIssueDescription(workspaceSlug, projectId, issueId, data);
|
||||
removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
this.issue.removeIssue(workspaceSlug, projectId, issueId);
|
||||
archiveIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { action, makeObservable, runInAction, } from "mobx";
|
||||
// types
|
||||
import { TIssue, TLoader, ViewFlags, IssuePaginationOptions, TIssuesResponse, TBulkOperationsPayload } from "@plane/types";
|
||||
import { TIssue, TLoader, ViewFlags, IssuePaginationOptions, TIssuesResponse, TBulkOperationsPayload, TIssueDescription } from "@plane/types";
|
||||
// helpers
|
||||
// base class
|
||||
import { BaseIssuesStore, IBaseIssuesStore } from "../helpers/base-issues.store";
|
||||
@@ -31,6 +31,12 @@ export interface IProjectIssues extends IBaseIssuesStore {
|
||||
|
||||
createIssue: (workspaceSlug: string, projectId: string, data: Partial<TIssue>) => Promise<TIssue>;
|
||||
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
updateIssueDescription: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
data: TIssueDescription
|
||||
) => Promise<void>;
|
||||
archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
quickAddIssue: (workspaceSlug: string, projectId: string, data: TIssue) => Promise<TIssue | undefined>;
|
||||
removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise<void>;
|
||||
|
||||
@@ -4453,7 +4453,7 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@^16.8.0 || ^17.0.0 || ^18.0.0", "@types/react@^18.2.42", "@types/react@^18.2.48":
|
||||
"@types/react@*", "@types/react@18.2.48", "@types/react@^16.8.0 || ^17.0.0 || ^18.0.0", "@types/react@^18.2.42", "@types/react@^18.2.48":
|
||||
version "18.2.48"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.48.tgz#11df5664642d0bd879c1f58bc1d37205b064e8f1"
|
||||
integrity sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==
|
||||
|
||||
Reference in New Issue
Block a user