Revert "[WEB-1435] dev: conflict free issue descriptions (#5912)" (#6000)

This reverts commit e9680cab74.
This commit is contained in:
Aaryan Khandelwal 2024-11-15 17:13:31 +05:30 committed by GitHub
parent e9680cab74
commit 9408e92e44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 361 additions and 1469 deletions

View file

@ -284,11 +284,9 @@ class DraftIssueSerializer(BaseSerializer):
class DraftIssueDetailSerializer(DraftIssueSerializer): class DraftIssueDetailSerializer(DraftIssueSerializer):
description_html = serializers.CharField() description_html = serializers.CharField()
description_binary = serializers.CharField()
class Meta(DraftIssueSerializer.Meta): class Meta(DraftIssueSerializer.Meta):
fields = DraftIssueSerializer.Meta.fields + [ fields = DraftIssueSerializer.Meta.fields + [
"description_html", "description_html",
"description_binary",
] ]
read_only_fields = fields read_only_fields = fields

View file

@ -1,6 +1,3 @@
# Python imports
import base64
# Django imports # Django imports
from django.utils import timezone from django.utils import timezone
from django.core.validators import URLValidator from django.core.validators import URLValidator
@ -735,31 +732,14 @@ class IssueLiteSerializer(DynamicBaseSerializer):
read_only_fields = fields read_only_fields = fields
class Base64BinaryField(serializers.CharField):
def to_representation(self, value):
# Encode the binary data to base64 string for JSON response
if value:
return base64.b64encode(value).decode("utf-8")
return None
def to_internal_value(self, data):
# Decode the base64 string to binary data when saving
try:
return base64.b64decode(data)
except (TypeError, ValueError):
raise serializers.ValidationError("Invalid base64-encoded data")
class IssueDetailSerializer(IssueSerializer): class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField() description_html = serializers.CharField()
description_binary = Base64BinaryField()
is_subscribed = serializers.BooleanField(read_only=True) is_subscribed = serializers.BooleanField(read_only=True)
class Meta(IssueSerializer.Meta): class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [ fields = IssueSerializer.Meta.fields + [
"description_html", "description_html",
"is_subscribed", "is_subscribed",
"description_binary",
] ]
read_only_fields = fields read_only_fields = fields

View file

@ -66,7 +66,6 @@ class WorkSpaceMemberSerializer(DynamicBaseSerializer):
class WorkspaceMemberMeSerializer(BaseSerializer): class WorkspaceMemberMeSerializer(BaseSerializer):
draft_issue_count = serializers.IntegerField(read_only=True) draft_issue_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = WorkspaceMember model = WorkspaceMember
fields = "__all__" fields = "__all__"

View file

@ -92,14 +92,4 @@ urlpatterns = [
), ),
name="inbox-issue", name="inbox-issue",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:pk>/description/",
IntakeIssueViewSet.as_view(
{
"get": "retrieve_description",
"post": "update_description",
}
),
name="inbox-issue-description",
),
] ]

View file

@ -66,16 +66,6 @@ urlpatterns = [
), ),
name="project-issue", name="project-issue",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/description/",
IssueViewSet.as_view(
{
"get": "retrieve_description",
"post": "update_description",
}
),
name="project-issue-description",
),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/", "workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
LabelViewSet.as_view( LabelViewSet.as_view(
@ -298,15 +288,6 @@ urlpatterns = [
), ),
name="project-issue-archive-unarchive", name="project-issue-archive-unarchive",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/<uuid:pk>/description/",
IssueArchiveViewSet.as_view(
{
"get": "retrieve_description",
}
),
name="archive-issue-description",
),
## End Issue Archives ## End Issue Archives
## Issue Relation ## Issue Relation
path( path(

View file

@ -276,16 +276,6 @@ urlpatterns = [
), ),
name="workspace-drafts-issues", name="workspace-drafts-issues",
), ),
path(
"workspaces/<str:slug>/draft-issues/<uuid:pk>/description/",
WorkspaceDraftIssueViewSet.as_view(
{
"get": "retrieve_description",
"post": "update_description",
}
),
name="workspace-drafts-issues",
),
path( path(
"workspaces/<str:slug>/draft-to-issue/<uuid:draft_id>/", "workspaces/<str:slug>/draft-to-issue/<uuid:draft_id>/",
WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}), WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}),

View file

@ -1,7 +1,5 @@
# Python imports # Python imports
import json import json
import requests
import base64
# Django import # Django import
from django.utils import timezone from django.utils import timezone
@ -11,9 +9,6 @@ from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.http import StreamingHttpResponse
from django.conf import settings
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
@ -45,6 +40,7 @@ from plane.bgtasks.issue_activities_task import issue_activity
class IntakeViewSet(BaseViewSet): class IntakeViewSet(BaseViewSet):
serializer_class = IntakeSerializer serializer_class = IntakeSerializer
model = Intake model = Intake
@ -93,6 +89,7 @@ class IntakeViewSet(BaseViewSet):
class IntakeIssueViewSet(BaseViewSet): class IntakeIssueViewSet(BaseViewSet):
serializer_class = IntakeIssueSerializer serializer_class = IntakeIssueSerializer
model = IntakeIssue model = IntakeIssue
@ -643,82 +640,3 @@ class IntakeIssueViewSet(BaseViewSet):
intake_issue.delete() intake_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve_description(self, request, slug, project_id, pk):
issue = Issue.objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id
).first()
if issue is None:
return Response(
{"error": "Issue not found"},
status=404,
)
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
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def update_description(self, request, slug, project_id, pk):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
base64_description = issue.description_binary
# convert to base64 string
if base64_description:
base64_description = base64.b64encode(base64_description).decode(
"utf-8"
)
data = {
"original_document": base64_description,
"updates": request.data.get("description_binary"),
}
base_url = f"{settings.LIVE_BASE_URL}/resolve-document-conflicts/"
try:
response = requests.post(base_url, json=data, headers=None)
except requests.RequestException:
return Response(
{"error": "Failed to connect to the external service"},
status=status.HTTP_502_BAD_GATEWAY,
)
if response.status_code == 200:
issue.description = response.json().get(
"description", issue.description
)
issue.description_html = response.json().get("description_html")
response_description_binary = response.json().get(
"description_binary"
)
issue.description_binary = base64.b64decode(
response_description_binary
)
issue.save()
def stream_data():
if issue.description_binary:
yield issue.description_binary
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="issue_description.bin"'
)
return response
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View file

@ -7,8 +7,6 @@ from django.db.models import F, Func, OuterRef, Q, Prefetch, Exists, Subquery
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.http import StreamingHttpResponse
# Third Party imports # Third Party imports
from rest_framework import status from rest_framework import status
@ -29,7 +27,7 @@ from plane.db.models import (
IssueLink, IssueLink,
IssueSubscriber, IssueSubscriber,
IssueReaction, IssueReaction,
CycleIssue, CycleIssue
) )
from plane.utils.grouper import ( from plane.utils.grouper import (
issue_group_values, issue_group_values,
@ -329,32 +327,6 @@ class IssueArchiveViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve_description(self, request, slug, project_id, pk):
issue = Issue.objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id
).first()
if issue is None:
return Response(
{"error": "Issue not found"},
status=404,
)
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
class BulkArchiveIssuesEndpoint(BaseAPIView): class BulkArchiveIssuesEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [

View file

@ -1,7 +1,5 @@
# Python imports # Python imports
import json import json
import requests
import base64
# Django imports # Django imports
from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.aggregates import ArrayAgg
@ -22,10 +20,8 @@ from django.db.models import (
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils import timezone from django.utils import timezone
from django.http import StreamingHttpResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.conf import settings
# Third Party imports # Third Party imports
from rest_framework import status from rest_framework import status
@ -729,84 +725,6 @@ class IssueViewSet(BaseViewSet):
) )
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve_description(self, request, slug, project_id, pk):
issue = Issue.issue_objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id
).first()
if issue is None:
return Response(
{"error": "Issue not found"},
status=404,
)
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 update_description(self, request, slug, project_id, pk):
issue = Issue.issue_objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
base64_description = issue.description_binary
# convert to base64 string
if base64_description:
base64_description = base64.b64encode(base64_description).decode(
"utf-8"
)
data = {
"original_document": base64_description,
"updates": request.data.get("description_binary"),
}
base_url = f"{settings.LIVE_BASE_URL}/resolve-document-conflicts/"
try:
response = requests.post(base_url, json=data, headers=None)
except requests.RequestException:
return Response(
{"error": "Failed to connect to the external service"},
status=status.HTTP_502_BAD_GATEWAY,
)
if response.status_code == 200:
issue.description = response.json().get(
"description", issue.description
)
issue.description_html = response.json().get("description_html")
response_description_binary = response.json().get(
"description_binary"
)
issue.description_binary = base64.b64decode(
response_description_binary
)
issue.save()
def stream_data():
if issue.description_binary:
yield issue.description_binary
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="issue_description.bin"'
)
return response
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class IssueUserDisplayPropertyEndpoint(BaseAPIView): class IssueUserDisplayPropertyEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])

View file

@ -1,7 +1,5 @@
# Python imports # Python imports
import json import json
import requests
import base64
# Django imports # Django imports
from django.utils import timezone from django.utils import timezone
@ -9,7 +7,6 @@ from django.core import serializers
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.http import StreamingHttpResponse
from django.db.models import ( from django.db.models import (
Q, Q,
UUIDField, UUIDField,
@ -20,7 +17,6 @@ from django.db.models import (
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.conf import settings
# Third Party imports # Third Party imports
from rest_framework import status from rest_framework import status
@ -354,78 +350,3 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve_description(self, request, slug, pk):
issue = DraftIssue.objects.filter(pk=pk, workspace__slug=slug).first()
if issue is None:
return Response(
{"error": "Issue not found"},
status=404,
)
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="draft_issue_description.bin"'
)
return response
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def update_description(self, request, slug, pk):
issue = DraftIssue.objects.get(workspace__slug=slug, pk=pk)
base64_description = issue.description_binary
# convert to base64 string
if base64_description:
base64_description = base64.b64encode(base64_description).decode(
"utf-8"
)
data = {
"original_document": base64_description,
"updates": request.data.get("description_binary"),
}
base_url = f"{settings.LIVE_BASE_URL}/resolve-document-conflicts/"
try:
response = requests.post(base_url, json=data, headers=None)
except requests.RequestException:
return Response(
{"error": "Failed to connect to the external service"},
status=status.HTTP_502_BAD_GATEWAY,
)
if response.status_code == 200:
issue.description = response.json().get(
"description", issue.description
)
issue.description_html = response.json().get("description_html")
response_description_binary = response.json().get(
"description_binary"
)
issue.description_binary = base64.b64decode(
response_description_binary
)
issue.save()
def stream_data():
if issue.description_binary:
yield issue.description_binary
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="issue_description.bin"'
)
return response
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View file

@ -1,7 +1,9 @@
# Generated by Django 4.2.15 on 2024-11-06 08:41 # Generated by Django 4.2.15 on 2024-11-06 08:41
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import uuid
class Migration(migrations.Migration): class Migration(migrations.Migration):

View file

@ -381,7 +381,6 @@ CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure"
ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None) ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None) SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
APP_BASE_URL = os.environ.get("APP_BASE_URL") APP_BASE_URL = os.environ.get("APP_BASE_URL")
LIVE_BASE_URL = os.environ.get("LIVE_BASE_URL")
HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60)) HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60))

View file

@ -3,6 +3,7 @@ from django.urls import path
from plane.space.views import ( from plane.space.views import (
IntakeIssuePublicViewSet, IntakeIssuePublicViewSet,
IssueVotePublicViewSet,
WorkspaceProjectDeployBoardEndpoint, WorkspaceProjectDeployBoardEndpoint,
) )

View file

@ -1,6 +0,0 @@
.next
.vercel
.tubro
out/
dist/
node_modules/

View file

@ -1,5 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}

View file

@ -0,0 +1,59 @@
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs"
// plane editor
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
const DOCUMENT_EDITOR_EXTENSIONS = [
...CoreEditorExtensionsWithoutProps,
...DocumentEditorExtensionsWithoutProps,
];
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
} => {
// encode binary description data
const base64Data = Buffer.from(description).toString("base64");
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, description);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(
type,
documentEditorSchema
).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
}
export const getBinaryDataFromHTMLString = (descriptionHTML: string): {
contentBinary: Uint8Array
} => {
// convert HTML to JSON
const contentJSON = generateJSON(
descriptionHTML ?? "<p></p>",
DOCUMENT_EDITOR_EXTENSIONS
);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(
documentEditorSchema,
contentJSON,
"default"
);
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return {
contentBinary: encodedData
}
}

View file

@ -1,8 +1,8 @@
// plane editor // helpers
import { import {
getAllDocumentFormatsFromDocumentEditorBinaryData, getAllDocumentFormatsFromBinaryData,
getBinaryDataFromDocumentEditorHTMLString, getBinaryDataFromHTMLString,
} from "@plane/editor/lib"; } from "@/core/helpers/page.js";
// services // services
import { PageService } from "@/core/services/page.service.js"; import { PageService } from "@/core/services/page.service.js";
import { manualLogger } from "../helpers/logger.js"; import { manualLogger } from "../helpers/logger.js";
@ -12,10 +12,12 @@ export const updatePageDescription = async (
params: URLSearchParams, params: URLSearchParams,
pageId: string, pageId: string,
updatedDescription: Uint8Array, updatedDescription: Uint8Array,
cookie: string | undefined cookie: string | undefined,
) => { ) => {
if (!(updatedDescription instanceof Uint8Array)) { if (!(updatedDescription instanceof Uint8Array)) {
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array"); throw new Error(
"Invalid updatedDescription: must be an instance of Uint8Array",
);
} }
const workspaceSlug = params.get("workspaceSlug")?.toString(); const workspaceSlug = params.get("workspaceSlug")?.toString();
@ -23,7 +25,7 @@ export const updatePageDescription = async (
if (!workspaceSlug || !projectId || !cookie) return; if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } = const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(updatedDescription); getAllDocumentFormatsFromBinaryData(updatedDescription);
try { try {
const payload = { const payload = {
description_binary: contentBinaryEncoded, description_binary: contentBinaryEncoded,
@ -31,7 +33,13 @@ export const updatePageDescription = async (
description: contentJSON, description: contentJSON,
}; };
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie); await pageService.updateDescription(
workspaceSlug,
projectId,
pageId,
payload,
cookie,
);
} catch (error) { } catch (error) {
manualLogger.error("Update error:", error); manualLogger.error("Update error:", error);
throw error; throw error;
@ -42,16 +50,26 @@ const fetchDescriptionHTMLAndTransform = async (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
pageId: string, pageId: string,
cookie: string cookie: string,
) => { ) => {
if (!workspaceSlug || !projectId || !cookie) return; if (!workspaceSlug || !projectId || !cookie) return;
try { try {
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie); const pageDetails = await pageService.fetchDetails(
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "<p></p>"); workspaceSlug,
projectId,
pageId,
cookie,
);
const { contentBinary } = getBinaryDataFromHTMLString(
pageDetails.description_html ?? "<p></p>",
);
return contentBinary; return contentBinary;
} catch (error) { } catch (error) {
manualLogger.error("Error while transforming from HTML to Uint8Array", error); manualLogger.error(
"Error while transforming from HTML to Uint8Array",
error,
);
throw error; throw error;
} }
}; };
@ -59,18 +77,28 @@ const fetchDescriptionHTMLAndTransform = async (
export const fetchPageDescriptionBinary = async ( export const fetchPageDescriptionBinary = async (
params: URLSearchParams, params: URLSearchParams,
pageId: string, pageId: string,
cookie: string | undefined cookie: string | undefined,
) => { ) => {
const workspaceSlug = params.get("workspaceSlug")?.toString(); const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString(); const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return null; if (!workspaceSlug || !projectId || !cookie) return null;
try { try {
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie); const response = await pageService.fetchDescriptionBinary(
workspaceSlug,
projectId,
pageId,
cookie,
);
const binaryData = new Uint8Array(response); const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) { if (binaryData.byteLength === 0) {
const binary = await fetchDescriptionHTMLAndTransform(workspaceSlug, projectId, pageId, cookie); const binary = await fetchDescriptionHTMLAndTransform(
workspaceSlug,
projectId,
pageId,
cookie,
);
if (binary) { if (binary) {
return binary; return binary;
} }

View file

@ -1,49 +0,0 @@
// plane editor
import {
applyUpdates,
convertBase64StringToBinaryData,
getAllDocumentFormatsFromRichTextEditorBinaryData,
} from "@plane/editor/lib";
export type TResolveConflictsRequestBody = {
original_document: string;
updates: string;
};
export type TResolveConflictsResponse = {
description_binary: string;
description_html: string;
description: object;
};
export const resolveDocumentConflicts = (body: TResolveConflictsRequestBody): TResolveConflictsResponse => {
const { original_document, updates } = body;
try {
// convert from base64 to buffer
const originalDocumentBuffer = original_document ? convertBase64StringToBinaryData(original_document) : null;
const updatesBuffer = updates ? convertBase64StringToBinaryData(updates) : null;
// decode req.body
const decodedOriginalDocument = originalDocumentBuffer ? new Uint8Array(originalDocumentBuffer) : new Uint8Array();
const decodedUpdates = updatesBuffer ? new Uint8Array(updatesBuffer) : new Uint8Array();
// resolve conflicts
let resolvedDocument: Uint8Array;
if (decodedOriginalDocument.length === 0) {
// use updates to create the document id original_description is null
resolvedDocument = applyUpdates(decodedUpdates);
} else {
// use original document and updates to resolve conflicts
resolvedDocument = applyUpdates(decodedOriginalDocument, decodedUpdates);
}
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(resolvedDocument);
return {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
} catch (error) {
throw new Error("Internal server error");
}
};

View file

@ -5,13 +5,16 @@ import expressWs from "express-ws";
import * as Sentry from "@sentry/node"; import * as Sentry from "@sentry/node";
import compression from "compression"; import compression from "compression";
import helmet from "helmet"; import helmet from "helmet";
// cors
import cors from "cors"; import cors from "cors";
// core hocuspocus server // core hocuspocus server
import { getHocusPocusServer } from "@/core/hocuspocus-server.js"; import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers // helpers
import { errorHandler } from "@/core/helpers/error-handler.js";
import { logger, manualLogger } from "@/core/helpers/logger.js"; import { logger, manualLogger } from "@/core/helpers/logger.js";
import { resolveDocumentConflicts, TResolveConflictsRequestBody } from "@/core/resolve-conflicts.js"; import { errorHandler } from "@/core/helpers/error-handler.js";
const app = express(); const app = express();
expressWs(app); expressWs(app);
@ -26,7 +29,7 @@ app.use(
compression({ compression({
level: 6, level: 6,
threshold: 5 * 1000, threshold: 5 * 1000,
}) }),
); );
// Logging middleware // Logging middleware
@ -59,25 +62,6 @@ router.ws("/collaboration", (ws, req) => {
} }
}); });
app.post("/resolve-document-conflicts", (req, res) => {
const { original_document, updates } = req.body as TResolveConflictsRequestBody;
try {
if (original_document === undefined || updates === undefined) {
res.status(400).send({
message: "Missing required fields",
});
return;
}
const resolvedDocument = resolveDocumentConflicts(req.body);
res.status(200).json(resolvedDocument);
} catch (error) {
manualLogger.error("Error in /resolve-document-conflicts endpoint:", error);
res.status(500).send({
message: "Internal server error",
});
}
});
app.use(process.env.LIVE_BASE_PATH || "/live", router); app.use(process.env.LIVE_BASE_PATH || "/live", router);
app.use((_req, res) => { app.use((_req, res) => {
@ -98,7 +82,9 @@ const gracefulShutdown = async () => {
try { try {
// Close the HocusPocus server WebSocket connections // Close the HocusPocus server WebSocket connections
await HocusPocusServer.destroy(); await HocusPocusServer.destroy();
manualLogger.info("HocusPocus server WebSocket connections closed gracefully."); manualLogger.info(
"HocusPocus server WebSocket connections closed gracefully.",
);
// Close the Express server // Close the Express server
liveServer.close(() => { liveServer.close(() => {

View file

@ -8,7 +8,7 @@ import { IssueWidget } from "@/extensions";
// helpers // helpers
import { getEditorClassNames } from "@/helpers/common"; import { getEditorClassNames } from "@/helpers/common";
// hooks // hooks
import { useCollaborativeDocumentEditor } from "@/hooks/use-collaborative-document-editor"; import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
// types // types
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types"; import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
@ -43,7 +43,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
} }
// use document editor // use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeDocumentEditor({ const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
onTransaction, onTransaction,
disabledExtensions, disabledExtensions,
editorClassName, editorClassName,

View file

@ -8,7 +8,7 @@ import { IssueWidget } from "@/extensions";
// helpers // helpers
import { getEditorClassNames } from "@/helpers/common"; import { getEditorClassNames } from "@/helpers/common";
// hooks // hooks
import { useCollaborativeDocumentReadOnlyEditor } from "@/hooks/use-collaborative-document-read-only-editor"; import { useReadOnlyCollaborativeEditor } from "@/hooks/use-read-only-collaborative-editor";
// types // types
import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/types"; import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/types";
@ -36,7 +36,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
); );
} }
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeDocumentReadOnlyEditor({ const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
editorClassName, editorClassName,
extensions, extensions,
fileHandler, fileHandler,

View file

@ -1,4 +1,4 @@
import { AnyExtension, Editor } from "@tiptap/core"; import { Editor, Extension } from "@tiptap/core";
// components // components
import { EditorContainer } from "@/components/editors"; import { EditorContainer } from "@/components/editors";
// constants // constants
@ -12,7 +12,7 @@ import { EditorContentWrapper } from "./editor-content";
type Props = IEditorProps & { type Props = IEditorProps & {
children?: (editor: Editor) => React.ReactNode; children?: (editor: Editor) => React.ReactNode;
extensions: AnyExtension[]; extensions: Extension<any, any>[];
}; };
export const EditorWrapper: React.FC<Props> = (props) => { export const EditorWrapper: React.FC<Props> = (props) => {

View file

@ -1,72 +0,0 @@
import React from "react";
// components
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
import { EditorBubbleMenu } from "@/components/menus";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeRichTextEditor } from "@/hooks/use-collaborative-rich-text-editor";
// types
import { EditorRefApi, ICollaborativeRichTextEditor } from "@/types";
const CollaborativeRichTextEditor = (props: ICollaborativeRichTextEditor) => {
const {
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName,
fileHandler,
forwardedRef,
id,
mentionHandler,
onChange,
placeholder,
tabIndex,
value,
} = props;
const { editor } = useCollaborativeRichTextEditor({
editorClassName,
fileHandler,
forwardedRef,
id,
mentionHandler,
onChange,
placeholder,
tabIndex,
value,
});
const editorContainerClassName = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
containerClassName,
});
if (!editor) return null;
return (
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
>
<EditorBubbleMenu editor={editor} />
<div className="flex flex-col">
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
</div>
</EditorContainer>
);
};
const CollaborativeRichTextEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeRichTextEditor>(
(props, ref) => (
<CollaborativeRichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
)
);
CollaborativeRichTextEditorWithRef.displayName = "CollaborativeRichTextEditorWithRef";
export { CollaborativeRichTextEditorWithRef };

View file

@ -1,70 +0,0 @@
import React from "react";
// components
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
import { EditorBubbleMenu } from "@/components/menus";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeRichTextReadOnlyEditor } from "@/hooks/use-collaborative-rich-text-read-only-editor";
// types
import { EditorReadOnlyRefApi, ICollaborativeRichTextReadOnlyEditor } from "@/types";
const CollaborativeRichTextReadOnlyEditor = (props: ICollaborativeRichTextReadOnlyEditor) => {
const {
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName,
fileHandler,
forwardedRef,
id,
mentionHandler,
value,
} = props;
const { editor } = useCollaborativeRichTextReadOnlyEditor({
editorClassName,
fileHandler,
forwardedRef,
id,
mentionHandler,
value,
});
const editorContainerClassName = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
containerClassName,
});
if (!editor) return null;
return (
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
>
<EditorBubbleMenu editor={editor} />
<div className="flex flex-col">
<EditorContentWrapper editor={editor} id={id} />
</div>
</EditorContainer>
);
};
const CollaborativeRichTextReadOnlyEditorWithRef = React.forwardRef<
EditorReadOnlyRefApi,
ICollaborativeRichTextReadOnlyEditor
>((props, ref) => (
<CollaborativeRichTextReadOnlyEditor
{...props}
forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>}
/>
));
CollaborativeRichTextReadOnlyEditorWithRef.displayName = "CollaborativeRichTextReadOnlyEditorWithRef";
export { CollaborativeRichTextReadOnlyEditorWithRef };

View file

@ -1,4 +1,2 @@
export * from "./collaborative-editor";
export * from "./collaborative-read-only-editor";
export * from "./editor"; export * from "./editor";
export * from "./read-only-editor"; export * from "./read-only-editor";

View file

@ -1,132 +0,0 @@
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@/extensions";
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs";
// editor extension configs
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
// editor schemas
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
/**
* @description apply updates to a doc and return the updated doc in binary format
* @param {Uint8Array} document
* @param {Uint8Array} updates
* @returns {Uint8Array}
*/
export const applyUpdates = (document: Uint8Array, updates?: Uint8Array): Uint8Array => {
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, document);
if (updates) {
Y.applyUpdate(yDoc, updates);
}
const encodedDoc = Y.encodeStateAsUpdate(yDoc);
return encodedDoc;
};
/**
* @description this function encodes binary data to base64 string
* @param {Uint8Array} document
* @returns {string}
*/
export const convertBinaryDataToBase64String = (document: Uint8Array): string =>
Buffer.from(document).toString("base64");
/**
* @description this function decodes base64 string to binary data
* @param {string} document
* @returns {ArrayBuffer}
*/
export const convertBase64StringToBinaryData = (document: string): ArrayBuffer => Buffer.from(document, "base64");
/**
* @description this function generates the binary equivalent of html content for the rich text editor
* @param {string} descriptionHTML
* @returns {Uint8Array}
*/
export const getBinaryDataFromRichTextEditorHTMLString = (descriptionHTML: string): Uint8Array => {
// convert HTML to JSON
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", RICH_TEXT_EDITOR_EXTENSIONS);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(richTextEditorSchema, contentJSON, "default");
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return encodedData;
};
/**
* @description this function generates the binary equivalent of html content for the document editor
* @param {string} descriptionHTML
* @returns {Uint8Array}
*/
export const getBinaryDataFromDocumentEditorHTMLString = (descriptionHTML: string): Uint8Array => {
// convert HTML to JSON
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return encodedData;
};
/**
* @description this function generates all document formats for the provided binary data for the rich text editor
* @param {Uint8Array} description
* @returns
*/
export const getAllDocumentFormatsFromRichTextEditorBinaryData = (
description: Uint8Array
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
} => {
// encode binary description data
const base64Data = convertBinaryDataToBase64String(description);
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, description);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, richTextEditorSchema).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, RICH_TEXT_EDITOR_EXTENSIONS);
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
};
/**
* @description this function generates all document formats for the provided binary data for the document editor
* @param {Uint8Array} description
* @returns
*/
export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
description: Uint8Array
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
} => {
// encode binary description data
const base64Data = convertBinaryDataToBase64String(description);
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, description);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
};

View file

@ -0,0 +1,16 @@
import * as Y from "yjs";
/**
* @description apply updates to a doc and return the updated doc in base64(binary) format
* @param {Uint8Array} document
* @param {Uint8Array} updates
* @returns {string} base64(binary) form of the updated doc
*/
export const applyUpdates = (document: Uint8Array, updates: Uint8Array): Uint8Array => {
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, document);
Y.applyUpdate(yDoc, updates);
const encodedDoc = Y.encodeStateAsUpdate(yDoc);
return encodedDoc;
};

View file

@ -9,9 +9,9 @@ import { useEditor } from "@/hooks/use-editor";
// plane editor extensions // plane editor extensions
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions"; import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types // types
import { TCollaborativeDocumentEditorHookProps } from "@/types"; import { TCollaborativeEditorProps } from "@/types";
export const useCollaborativeDocumentEditor = (props: TCollaborativeDocumentEditorHookProps) => { export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
const { const {
onTransaction, onTransaction,
disabledExtensions, disabledExtensions,
@ -102,7 +102,7 @@ export const useCollaborativeDocumentEditor = (props: TCollaborativeDocumentEdit
forwardedRef, forwardedRef,
mentionHandler, mentionHandler,
placeholder, placeholder,
providerDocument: provider.document, provider,
tabIndex, tabIndex,
}); });

View file

@ -1,78 +0,0 @@
import { useEffect, useMemo } from "react";
import Collaboration from "@tiptap/extension-collaboration";
import * as Y from "yjs";
// extensions
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
// hooks
import { useEditor } from "@/hooks/use-editor";
// providers
import { CustomCollaborationProvider } from "@/providers";
// types
import { TCollaborativeRichTextEditorHookProps } from "@/types";
export const useCollaborativeRichTextEditor = (props: TCollaborativeRichTextEditorHookProps) => {
const {
editorClassName,
editorProps = {},
extensions,
fileHandler,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
onChange,
placeholder,
tabIndex,
value,
} = props;
// initialize custom collaboration provider
const provider = useMemo(
() =>
new CustomCollaborationProvider({
name: id,
onChange,
}),
[id]
);
useEffect(() => {
if (provider.hasSynced) return;
if (value && value.length > 0) {
try {
Y.applyUpdate(provider.document, value);
provider.hasSynced = true;
} catch (error) {
console.error("Error applying binary updates to the description", error);
}
}
}, [value, provider.document]);
const editor = useEditor({
id,
editorProps,
editorClassName,
enableHistory: false,
extensions: [
SideMenuExtension({
aiEnabled: false,
dragDropEnabled: true,
}),
HeadingListExtension,
Collaboration.configure({
document: provider.document,
}),
...(extensions ?? []),
],
fileHandler,
handleEditorReady,
forwardedRef,
mentionHandler,
placeholder,
providerDocument: provider.document,
tabIndex,
});
return {
editor,
};
};

View file

@ -1,64 +0,0 @@
import { useEffect, useMemo } from "react";
import Collaboration from "@tiptap/extension-collaboration";
import * as Y from "yjs";
// extensions
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// providers
import { CustomCollaborationProvider } from "@/providers";
// types
import { TCollaborativeRichTextReadOnlyEditorHookProps } from "@/types";
export const useCollaborativeRichTextReadOnlyEditor = (props: TCollaborativeRichTextReadOnlyEditorHookProps) => {
const {
editorClassName,
editorProps = {},
extensions,
fileHandler,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
value,
} = props;
// initialize custom collaboration provider
const provider = useMemo(
() =>
new CustomCollaborationProvider({
name: id,
}),
[id]
);
useEffect(() => {
if (value.length > 0) {
Y.applyUpdate(provider.document, value);
}
}, [value, provider.document]);
const editor = useReadOnlyEditor({
editorProps,
editorClassName,
extensions: [
SideMenuExtension({
aiEnabled: false,
dragDropEnabled: true,
}),
HeadingListExtension,
Collaboration.configure({
document: provider.document,
}),
...(extensions ?? []),
],
fileHandler,
handleEditorReady,
forwardedRef,
mentionHandler,
providerDocument: provider.document,
});
return {
editor,
};
};

View file

@ -1,4 +1,5 @@
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react"; import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { DOMSerializer } from "@tiptap/pm/model"; import { DOMSerializer } from "@tiptap/pm/model";
import { Selection } from "@tiptap/pm/state"; import { Selection } from "@tiptap/pm/state";
import { EditorProps } from "@tiptap/pm/view"; import { EditorProps } from "@tiptap/pm/view";
@ -35,7 +36,7 @@ export interface CustomEditorProps {
onTransaction?: () => void; onTransaction?: () => void;
autofocus?: boolean; autofocus?: boolean;
placeholder?: string | ((isFocused: boolean, value: string) => string); placeholder?: string | ((isFocused: boolean, value: string) => string);
providerDocument?: Y.Doc; provider?: HocuspocusProvider;
tabIndex?: number; tabIndex?: number;
// undefined when prop is not passed, null if intentionally passed to stop // undefined when prop is not passed, null if intentionally passed to stop
// swr syncing // swr syncing
@ -57,7 +58,7 @@ export const useEditor = (props: CustomEditorProps) => {
onChange, onChange,
onTransaction, onTransaction,
placeholder, placeholder,
providerDocument, provider,
tabIndex, tabIndex,
value, value,
autofocus = false, autofocus = false,
@ -205,7 +206,7 @@ export const useEditor = (props: CustomEditorProps) => {
return markdownOutput; return markdownOutput;
}, },
getDocument: () => { getDocument: () => {
const documentBinary = providerDocument ? Y.encodeStateAsUpdate(providerDocument) : null; const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>"; const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
const documentJSON = editorRef.current?.getJSON() ?? null; const documentJSON = editorRef.current?.getJSON() ?? null;
@ -283,7 +284,7 @@ export const useEditor = (props: CustomEditorProps) => {
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0, words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
}), }),
setProviderDocument: (value) => { setProviderDocument: (value) => {
const document = providerDocument; const document = provider?.document;
if (!document) return; if (!document) return;
Y.applyUpdate(document, value); Y.applyUpdate(document, value);
}, },

View file

@ -7,9 +7,9 @@ import { HeadingListExtension } from "@/extensions";
// hooks // hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types // types
import { TCollaborativeDocumentReadOnlyEditorHookProps } from "@/types"; import { TReadOnlyCollaborativeEditorProps } from "@/types";
export const useCollaborativeDocumentReadOnlyEditor = (props: TCollaborativeDocumentReadOnlyEditorHookProps) => { export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEditorProps) => {
const { const {
editorClassName, editorClassName,
editorProps = {}, editorProps = {},
@ -79,7 +79,7 @@ export const useCollaborativeDocumentReadOnlyEditor = (props: TCollaborativeDocu
forwardedRef, forwardedRef,
handleEditorReady, handleEditorReady,
mentionHandler, mentionHandler,
providerDocument: provider.document, provider,
}); });
return { return {

View file

@ -1,4 +1,5 @@
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react"; import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { EditorProps } from "@tiptap/pm/view"; import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import * as Y from "yjs"; import * as Y from "yjs";
@ -23,7 +24,7 @@ interface CustomReadOnlyEditorProps {
mentionHandler: { mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>; highlights: () => Promise<IMentionHighlight[]>;
}; };
providerDocument?: Y.Doc; provider?: HocuspocusProvider;
} }
export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
@ -36,7 +37,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
fileHandler, fileHandler,
handleEditorReady, handleEditorReady,
mentionHandler, mentionHandler,
providerDocument, provider,
} = props; } = props;
const editor = useCustomEditor({ const editor = useCustomEditor({
@ -85,7 +86,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
return markdownOutput; return markdownOutput;
}, },
getDocument: () => { getDocument: () => {
const documentBinary = providerDocument ? Y.encodeStateAsUpdate(providerDocument) : null; const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>"; const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
const documentJSON = editorRef.current?.getJSON() ?? null; const documentJSON = editorRef.current?.getJSON() ?? null;

View file

@ -1,61 +0,0 @@
import * as Y from "yjs";
export interface CompleteCollaborationProviderConfiguration {
/**
* 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<CompleteCollaborationProviderConfiguration, "name">> &
Partial<CompleteCollaborationProviderConfiguration>;
export class CustomCollaborationProvider {
public hasSynced: boolean;
public configuration: CompleteCollaborationProviderConfiguration = {
name: "",
document: new Y.Doc(),
onChange: () => {},
};
constructor(configuration: CollaborationProviderConfiguration) {
this.hasSynced = false;
this.setConfiguration(configuration);
this.document.on("update", this.documentUpdateHandler.bind(this));
this.document.on("destroy", this.documentDestroyHandler.bind(this));
}
public setConfiguration(configuration: Partial<CompleteCollaborationProviderConfiguration> = {}): void {
this.configuration = {
...this.configuration,
...configuration,
};
}
get document() {
return this.configuration.document;
}
async documentUpdateHandler(_update: Uint8Array, origin: any) {
if (!this.hasSynced) return;
// return if the update is from the provider itself
if (origin === this) return;
// call onChange with the update
const stateVector = Y.encodeStateAsUpdate(this.document);
this.configuration.onChange?.(stateVector);
}
documentDestroyHandler() {
this.document.off("update", this.documentUpdateHandler);
this.document.off("destroy", this.documentDestroyHandler);
}
}

View file

@ -1 +0,0 @@
export * from "./custom-collaboration-provider";

View file

@ -19,7 +19,7 @@ export type TServerHandler = {
onServerError?: () => void; onServerError?: () => void;
}; };
type TCollaborativeEditorHookCommonProps = { type TCollaborativeEditorHookProps = {
disabledExtensions?: TExtensions[]; disabledExtensions?: TExtensions[];
editorClassName: string; editorClassName: string;
editorProps?: EditorProps; editorProps?: EditorProps;
@ -30,9 +30,12 @@ type TCollaborativeEditorHookCommonProps = {
highlights: () => Promise<IMentionHighlight[]>; highlights: () => Promise<IMentionHighlight[]>;
suggestions?: () => Promise<IMentionSuggestion[]>; suggestions?: () => Promise<IMentionSuggestion[]>;
}; };
realtimeConfig: TRealtimeConfig;
serverHandler?: TServerHandler;
user: TUserDetails;
}; };
type TCollaborativeEditorHookProps = TCollaborativeEditorHookCommonProps & { export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
onTransaction?: () => void; onTransaction?: () => void;
embedHandler?: TEmbedConfig; embedHandler?: TEmbedConfig;
fileHandler: TFileHandler; fileHandler: TFileHandler;
@ -41,29 +44,7 @@ type TCollaborativeEditorHookProps = TCollaborativeEditorHookCommonProps & {
tabIndex?: number; tabIndex?: number;
}; };
type TCollaborativeReadOnlyEditorHookProps = TCollaborativeEditorHookCommonProps & { export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
fileHandler: Pick<TFileHandler, "getAssetSrc">; fileHandler: Pick<TFileHandler, "getAssetSrc">;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>; forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
}; };
export type TCollaborativeRichTextEditorHookProps = TCollaborativeEditorHookProps & {
onChange: (updatedDescription: Uint8Array) => void;
value: Uint8Array;
};
export type TCollaborativeRichTextReadOnlyEditorHookProps = TCollaborativeReadOnlyEditorHookProps & {
value: Uint8Array;
};
export type TCollaborativeDocumentEditorHookProps = TCollaborativeEditorHookProps & {
embedHandler?: TEmbedConfig;
realtimeConfig: TRealtimeConfig;
serverHandler?: TServerHandler;
user: TUserDetails;
};
export type TCollaborativeDocumentReadOnlyEditorHookProps = TCollaborativeReadOnlyEditorHookProps & {
realtimeConfig: TRealtimeConfig;
serverHandler?: TServerHandler;
user: TUserDetails;
};

View file

@ -132,12 +132,6 @@ export interface IRichTextEditor extends IEditorProps {
dragDropEnabled?: boolean; dragDropEnabled?: boolean;
} }
export interface ICollaborativeRichTextEditor extends Omit<IEditorProps, "initialValue" | "onChange" | "value"> {
dragDropEnabled?: boolean;
onChange: (updatedDescription: Uint8Array) => void;
value: Uint8Array;
}
export interface ICollaborativeDocumentEditor export interface ICollaborativeDocumentEditor
extends Omit<IEditorProps, "initialValue" | "onChange" | "onEnterKeyPress" | "value"> { extends Omit<IEditorProps, "initialValue" | "onChange" | "onEnterKeyPress" | "value"> {
aiHandler?: TAIHandler; aiHandler?: TAIHandler;
@ -167,10 +161,6 @@ export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps;
export type IRichTextReadOnlyEditor = IReadOnlyEditorProps; export type IRichTextReadOnlyEditor = IReadOnlyEditorProps;
export type ICollaborativeRichTextReadOnlyEditor = Omit<IReadOnlyEditorProps, "initialValue"> & {
value: Uint8Array;
};
export interface ICollaborativeDocumentReadOnlyEditor extends Omit<IReadOnlyEditorProps, "initialValue"> { export interface ICollaborativeDocumentReadOnlyEditor extends Omit<IReadOnlyEditorProps, "initialValue"> {
embedHandler: TEmbedConfig; embedHandler: TEmbedConfig;
handleEditorReady?: (value: boolean) => void; handleEditorReady?: (value: boolean) => void;

View file

@ -1,5 +1,5 @@
export * from "./ai"; export * from "./ai";
export * from "./collaboration-hook"; export * from "./collaboration";
export * from "./config"; export * from "./config";
export * from "./editor"; export * from "./editor";
export * from "./embed"; export * from "./embed";

View file

@ -10,8 +10,6 @@ import "./styles/drag-drop.css";
export { export {
CollaborativeDocumentEditorWithRef, CollaborativeDocumentEditorWithRef,
CollaborativeDocumentReadOnlyEditorWithRef, CollaborativeDocumentReadOnlyEditorWithRef,
CollaborativeRichTextEditorWithRef,
CollaborativeRichTextReadOnlyEditorWithRef,
DocumentReadOnlyEditorWithRef, DocumentReadOnlyEditorWithRef,
LiteTextEditorWithRef, LiteTextEditorWithRef,
LiteTextReadOnlyEditorWithRef, LiteTextReadOnlyEditorWithRef,
@ -27,7 +25,7 @@ export * from "@/constants/common";
// helpers // helpers
export * from "@/helpers/common"; export * from "@/helpers/common";
export * from "@/helpers/editor-commands"; export * from "@/helpers/editor-commands";
export * from "@/helpers/yjs-utils"; export * from "@/helpers/yjs";
export * from "@/extensions/table/table"; export * from "@/extensions/table/table";
// components // components

View file

@ -1 +1 @@
export * from "@/helpers/yjs-utils"; export * from "@/extensions/core-without-props";

View file

@ -50,7 +50,6 @@ export type IssueRelation = {
}; };
export type TIssue = TBaseIssue & { export type TIssue = TBaseIssue & {
description_binary?: string;
description_html?: string; description_html?: string;
is_subscribed?: boolean; is_subscribed?: boolean;
parent?: Partial<TBaseIssue>; parent?: Partial<TBaseIssue>;

View file

@ -9,7 +9,7 @@ import { Popover, Transition } from "@headlessui/react";
// ui // ui
import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { RichTextReadOnlyEditor } from "@/components/editor"; import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-editor";
// services // services
import { AIService } from "@/services/ai.service"; import { AIService } from "@/services/ai.service";

View file

@ -1,63 +0,0 @@
import React, { forwardRef } from "react";
// editor
import { CollaborativeRichTextEditorWithRef, EditorRefApi, ICollaborativeRichTextEditor } from "@plane/editor";
// types
import { IUserLite } from "@plane/types";
// helpers
import { cn } from "@/helpers/common.helper";
import { getEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useMember, useMention, useUser } from "@/hooks/store";
// plane web hooks
import { useFileSize } from "@/plane-web/hooks/use-file-size";
interface Props extends Omit<ICollaborativeRichTextEditor, "fileHandler" | "mentionHandler"> {
key: string;
projectId: string;
uploadFile: (file: File) => Promise<string>;
workspaceId: string;
workspaceSlug: string;
}
export const CollaborativeRichTextEditor = forwardRef<EditorRefApi, Props>((props, ref) => {
const { containerClassName, workspaceSlug, workspaceId, projectId, uploadFile, ...rest } = props;
// store hooks
const { data: currentUser } = useUser();
const {
getUserDetails,
project: { getProjectMemberIds },
} = useMember();
// derived values
const projectMemberIds = getProjectMemberIds(projectId);
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
// use-mention
const { mentionHighlights, mentionSuggestions } = useMention({
workspaceSlug,
projectId,
members: projectMemberDetails,
user: currentUser,
});
// file size
const { maxFileSize } = useFileSize();
return (
<CollaborativeRichTextEditorWithRef
ref={ref}
fileHandler={getEditorFileHandlers({
maxFileSize,
projectId,
uploadFile,
workspaceId,
workspaceSlug,
})}
mentionHandler={{
highlights: mentionHighlights,
suggestions: mentionSuggestions,
}}
{...rest}
containerClassName={cn("relative pl-3", containerClassName)}
/>
);
});
CollaborativeRichTextEditor.displayName = "CollaborativeRichTextEditor";

View file

@ -1,63 +0,0 @@
import React from "react";
// editor
import {
CollaborativeRichTextReadOnlyEditorWithRef,
EditorReadOnlyRefApi,
ICollaborativeRichTextReadOnlyEditor,
} from "@plane/editor";
// plane ui
import { Loader } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useMention } from "@/hooks/store";
import { useIssueDescription } from "@/hooks/use-issue-description";
type RichTextReadOnlyEditorWrapperProps = Omit<
ICollaborativeRichTextReadOnlyEditor,
"fileHandler" | "mentionHandler" | "value"
> & {
descriptionBinary: string | null;
descriptionHTML: string;
projectId?: string;
workspaceSlug: string;
};
export const CollaborativeRichTextReadOnlyEditor = React.forwardRef<
EditorReadOnlyRefApi,
RichTextReadOnlyEditorWrapperProps
>(({ descriptionBinary: savedDescriptionBinary, descriptionHTML, projectId, workspaceSlug, ...props }, ref) => {
const { mentionHighlights } = useMention({});
const { descriptionBinary } = useIssueDescription({
descriptionBinary: savedDescriptionBinary,
descriptionHTML,
});
if (!descriptionBinary)
return (
<Loader>
<Loader.Item height="150px" />
</Loader>
);
return (
<CollaborativeRichTextReadOnlyEditorWithRef
ref={ref}
value={descriptionBinary}
fileHandler={getReadOnlyEditorFileHandlers({
projectId,
workspaceSlug,
})}
mentionHandler={{
highlights: mentionHighlights,
}}
{...props}
// overriding the containerClassName to add relative class passed
containerClassName={cn(props.containerClassName, "relative pl-3")}
/>
);
});
CollaborativeRichTextReadOnlyEditor.displayName = "CollaborativeRichTextReadOnlyEditor";

View file

@ -1,4 +1,2 @@
export * from "./collaborative-editor"; export * from "./rich-text-editor";
export * from "./collaborative-read-only-editor"; export * from "./rich-text-read-only-editor";
export * from "./editor";
export * from "./read-only-editor";

View file

@ -3,8 +3,10 @@
import { Dispatch, SetStateAction, useEffect, useMemo } from "react"; import { Dispatch, SetStateAction, useEffect, useMemo } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
// plane types
import { TIssue } from "@plane/types";
// plane ui // plane ui
import { TOAST_TYPE, setToast } from "@plane/ui"; import { Loader, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { InboxIssueContentProperties } from "@/components/inbox/content"; import { InboxIssueContentProperties } from "@/components/inbox/content";
import { import {
@ -20,12 +22,11 @@ import { ISSUE_ARCHIVED, ISSUE_DELETED } from "@/constants/event-tracker";
// helpers // helpers
import { getTextContent } from "@/helpers/editor.helper"; import { getTextContent } from "@/helpers/editor.helper";
// hooks // hooks
import { useEventTracker, useIssueDetail, useProject, useUser } from "@/hooks/store"; import { useEventTracker, useIssueDetail, useProject, useProjectInbox, useUser } from "@/hooks/store";
import useReloadConfirmations from "@/hooks/use-reload-confirmation"; import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// store types // store types
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe"; import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe";
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
// store
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
type Props = { type Props = {
@ -44,6 +45,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
const { captureIssueEvent } = useEventTracker(); const { captureIssueEvent } = useEventTracker();
const { loader } = useProjectInbox();
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const { removeIssue, archiveIssue } = useIssueDetail(); const { removeIssue, archiveIssue } = useIssueDetail();
@ -58,7 +60,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
} }
}, [isSubmitting, setShowAlert, setIsSubmitting]); }, [isSubmitting, setShowAlert, setIsSubmitting]);
// derived values // dervied values
const issue = inboxIssue.issue; const issue = inboxIssue.issue;
const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined; const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined;
@ -73,8 +75,12 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
const issueOperations: TIssueOperations = useMemo( const issueOperations: TIssueOperations = useMemo(
() => ({ () => ({
fetch: async () => {}, // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars, arrow-body-style
remove: async (_workspaceSlug, _projectId, _issueId) => { fetch: async (_workspaceSlug: string, _projectId: string, _issueId: string) => {
return;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars, arrow-body-style
remove: async (_workspaceSlug: string, _projectId: string, _issueId: string) => {
try { try {
await removeIssue(workspaceSlug, projectId, _issueId); await removeIssue(workspaceSlug, projectId, _issueId);
setToast({ setToast({
@ -101,7 +107,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
}); });
} }
}, },
update: async (_workspaceSlug, _projectId, _issueId, data) => { update: async (_workspaceSlug: string, _projectId: string, _issueId: string, data: Partial<TIssue>) => {
try { try {
await inboxIssue.updateIssue(data); await inboxIssue.updateIssue(data);
captureIssueEvent({ captureIssueEvent({
@ -113,7 +119,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
}, },
path: pathname, path: pathname,
}); });
} catch { } catch (error) {
setToast({ setToast({
title: "Issue update failed", title: "Issue update failed",
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
@ -130,14 +136,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
}); });
} }
}, },
updateDescription: async (_workspaceSlug, _projectId, _issueId, descriptionBinary) => { archive: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
return await inboxIssue.updateIssueDescription(descriptionBinary);
} catch {
throw new Error("Failed to update issue description");
}
},
archive: async (workspaceSlug, projectId, issueId) => {
try { try {
await archiveIssue(workspaceSlug, projectId, issueId); await archiveIssue(workspaceSlug, projectId, issueId);
captureIssueEvent({ captureIssueEvent({
@ -155,7 +154,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
} }
}, },
}), }),
[archiveIssue, captureIssueEvent, inboxIssue, pathname, projectId, removeIssue, workspaceSlug] [inboxIssue]
); );
if (!issue?.project_id || !issue?.id) return <></>; if (!issue?.project_id || !issue?.id) return <></>;
@ -185,20 +184,21 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
containerClassName="-ml-3" containerClassName="-ml-3"
/> />
{issue.description_binary !== undefined && ( {loader === "issue-loading" ? (
<Loader className="min-h-[6rem] rounded-md border border-custom-border-200">
<Loader.Item width="100%" height="140px" />
</Loader>
) : (
<IssueDescriptionInput <IssueDescriptionInput
key={issue.id}
containerClassName="-ml-3 border-none"
descriptionBinary={issue.description_binary}
descriptionHTML={issue.description_html ?? "<p></p>"}
disabled={!isEditable}
updateDescription={async (data) =>
await issueOperations.updateDescription(workspaceSlug, projectId, issue.id ?? "", data)
}
issueId={issue.id}
projectId={issue.project_id}
setIsSubmitting={(value) => setIsSubmitting(value)}
workspaceSlug={workspaceSlug} 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 border-none"
/> />
)} )}

View file

@ -10,7 +10,7 @@ import { EFileAssetType } from "@plane/types/src/enums";
// ui // ui
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
// components // components
import { RichTextEditor } from "@/components/editor"; import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor";
// constants // constants
import { ETabIndices } from "@/constants/tab-indices"; import { ETabIndices } from "@/constants/tab-indices";
// helpers // helpers

View file

@ -1,115 +1,124 @@
"use client"; "use client";
import { FC, useCallback, useRef } from "react"; import { FC, useCallback, useEffect, useState } from "react";
import debounce from "lodash/debounce"; import debounce from "lodash/debounce";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// plane editor import { Controller, useForm } from "react-hook-form";
import { convertBinaryDataToBase64String, EditorRefApi } from "@plane/editor";
// types // types
import { TIssue } from "@plane/types";
import { EFileAssetType } from "@plane/types/src/enums"; import { EFileAssetType } from "@plane/types/src/enums";
// plane ui // ui
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
// components // components
import { CollaborativeRichTextEditor, CollaborativeRichTextReadOnlyEditor } from "@/components/editor"; import { RichTextEditor, RichTextReadOnlyEditor } from "@/components/editor";
import { TIssueOperations } from "@/components/issues/issue-detail";
// helpers // helpers
import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
// hooks // hooks
import { useWorkspace } from "@/hooks/store"; import { useWorkspace } from "@/hooks/store";
import { useIssueDescription } from "@/hooks/use-issue-description";
// services // services
import { FileService } from "@/services/file.service"; import { FileService } from "@/services/file.service";
const fileService = new FileService(); const fileService = new FileService();
export type IssueDescriptionInputProps = { export type IssueDescriptionInputProps = {
containerClassName?: string; containerClassName?: string;
descriptionBinary: string | null;
descriptionHTML: string;
disabled?: boolean;
issueId: string;
key: string;
placeholder?: string | ((isFocused: boolean, value: string) => string);
projectId: string;
setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void;
updateDescription: (data: string) => Promise<ArrayBuffer>;
workspaceSlug: string; workspaceSlug: string;
projectId: string;
issueId: string;
initialValue: string | undefined;
disabled?: boolean;
issueOperations: TIssueOperations;
placeholder?: string | ((isFocused: boolean, value: string) => string);
setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void;
swrIssueDescription?: string | null | undefined;
}; };
export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((props) => { export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((props) => {
const { const {
containerClassName, containerClassName,
descriptionBinary: savedDescriptionBinary,
descriptionHTML,
disabled,
issueId,
placeholder,
projectId,
setIsSubmitting,
updateDescription,
workspaceSlug, workspaceSlug,
projectId,
issueId,
disabled,
swrIssueDescription,
initialValue,
issueOperations,
setIsSubmitting,
placeholder,
} = props; } = props;
// refs
const editorRef = useRef<EditorRefApi>(null); const { handleSubmit, reset, control } = useForm<TIssue>({
// store hooks defaultValues: {
const { getWorkspaceBySlug } = useWorkspace(); description_html: initialValue,
// derived values },
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id?.toString() ?? "";
// use issue description
const { descriptionBinary, resolveConflictsAndUpdateDescription } = useIssueDescription({
descriptionBinary: savedDescriptionBinary,
descriptionHTML,
updateDescription,
}); });
const debouncedDescriptionSave = useCallback( const [localIssueDescription, setLocalIssueDescription] = useState({
debounce(async (updatedDescription: Uint8Array) => { id: issueId,
const editor = editorRef.current; description_html: initialValue,
if (!editor) return; });
const encodedDescription = convertBinaryDataToBase64String(updatedDescription);
await resolveConflictsAndUpdateDescription(encodedDescription, editor); const handleDescriptionFormSubmit = useCallback(
setIsSubmitting("submitted"); async (formData: Partial<TIssue>) => {
}, 1500), await issueOperations.update(workspaceSlug, projectId, issueId, {
[] description_html: formData.description_html ?? "<p></p>",
});
},
[workspaceSlug, projectId, issueId, issueOperations]
); );
if (!descriptionBinary) const { getWorkspaceBySlug } = useWorkspace();
return ( // computed values
<Loader className="min-h-[120px] max-h-64 space-y-2 overflow-hidden rounded-md"> const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string;
<Loader.Item width="100%" height="26px" />
<div className="flex items-center gap-2"> // reset form values
<Loader.Item width="26px" height="26px" /> useEffect(() => {
<Loader.Item width="400px" height="26px" /> if (!issueId) return;
</div> reset({
<div className="flex items-center gap-2"> id: issueId,
<Loader.Item width="26px" height="26px" /> description_html: initialValue === "" ? "<p></p>" : initialValue,
<Loader.Item width="400px" height="26px" /> });
</div> setLocalIssueDescription({
<Loader.Item width="80%" height="26px" /> id: issueId,
<div className="flex items-center gap-2"> description_html: initialValue === "" ? "<p></p>" : initialValue,
<Loader.Item width="50%" height="26px" /> });
</div> }, [initialValue, issueId, reset]);
<div className="border-0.5 absolute bottom-2 right-3.5 z-10 flex items-center gap-2">
<Loader.Item width="100px" height="26px" /> // ADDING handleDescriptionFormSubmit TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS
<Loader.Item width="50px" height="26px" /> // TODO: Verify the exhaustive-deps warning
</div> // eslint-disable-next-line react-hooks/exhaustive-deps
</Loader> const debouncedFormSave = useCallback(
debounce(async () => {
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
}, 1500),
[handleSubmit, issueId]
); );
return ( return (
<> <>
{!disabled ? ( {localIssueDescription.description_html ? (
<CollaborativeRichTextEditor <Controller
key={issueId} name="description_html"
containerClassName={containerClassName} control={control}
value={descriptionBinary} render={({ field: { onChange } }) =>
onChange={(val) => { !disabled ? (
setIsSubmitting("submitting"); <RichTextEditor
debouncedDescriptionSave(val);
}}
dragDropEnabled
id={issueId} id={issueId}
placeholder={placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value)} initialValue={localIssueDescription.description_html ?? "<p></p>"}
value={swrIssueDescription ?? null}
workspaceSlug={workspaceSlug}
workspaceId={workspaceId}
projectId={projectId} projectId={projectId}
ref={editorRef} dragDropEnabled
onChange={(_description: object, description_html: string) => {
setIsSubmitting("submitting");
onChange(description_html);
debouncedFormSave();
}}
placeholder={
placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value)
}
containerClassName={containerClassName}
uploadFile={async (file) => { uploadFile={async (file) => {
try { try {
const { asset_id } = await fileService.uploadProjectAsset( const { asset_id } = await fileService.uploadProjectAsset(
@ -127,18 +136,22 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
throw new Error("Asset upload failed. Please try again later."); throw new Error("Asset upload failed. Please try again later.");
} }
}} }}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
/> />
) : ( ) : (
<CollaborativeRichTextReadOnlyEditor <RichTextReadOnlyEditor
containerClassName={containerClassName}
descriptionBinary={savedDescriptionBinary}
descriptionHTML={descriptionHTML}
id={issueId} id={issueId}
projectId={projectId} initialValue={localIssueDescription.description_html ?? ""}
containerClassName={containerClassName}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId}
/> />
)
}
/>
) : (
<Loader>
<Loader.Item height="150px" />
</Loader>
)} )}
</> </>
); );

View file

@ -22,7 +22,6 @@ import useSize from "@/hooks/use-window-size";
// plane web components // plane web components
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe"; import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe";
import { IssueTypeSwitcher } from "@/plane-web/components/issues"; import { IssueTypeSwitcher } from "@/plane-web/components/issues";
// plane web hooks
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
// types // types
import { TIssueOperations } from "./root"; import { TIssueOperations } from "./root";
@ -114,22 +113,16 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
containerClassName="-ml-3" containerClassName="-ml-3"
/> />
{issue.description_binary !== undefined && (
<IssueDescriptionInput <IssueDescriptionInput
key={issue.id}
containerClassName="-ml-3 border-none"
descriptionBinary={issue.description_binary}
descriptionHTML={issue.description_html ?? "<p></p>"}
disabled={!isEditable}
updateDescription={async (data) =>
await issueOperations.updateDescription(workspaceSlug, issue.project_id ?? "", issue.id, data)
}
issueId={issue.id}
projectId={issue.project_id}
setIsSubmitting={(value) => setIsSubmitting(value)}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
initialValue={issue.description_html}
disabled={!isEditable}
issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)}
containerClassName="-ml-3 border-none"
/> />
)}
{currentUser && ( {currentUser && (
<IssueReaction <IssueReaction

View file

@ -26,12 +26,6 @@ import { IssueDetailsSidebar } from "./sidebar";
export type TIssueOperations = { export type TIssueOperations = {
fetch: (workspaceSlug: string, projectId: string, issueId: string, loader?: boolean) => Promise<void>; fetch: (workspaceSlug: string, projectId: string, issueId: string, loader?: boolean) => Promise<void>;
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>; update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
updateDescription: (
workspaceSlug: string,
projectId: string,
issueId: string,
descriptionBinary: string
) => Promise<ArrayBuffer>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
archive?: (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>; restore?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
@ -70,7 +64,6 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
issue: { getIssueById }, issue: { getIssueById },
fetchIssue, fetchIssue,
updateIssue, updateIssue,
updateIssueDescription,
removeIssue, removeIssue,
archiveIssue, archiveIssue,
addCycleToIssue, addCycleToIssue,
@ -125,13 +118,6 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
}); });
} }
}, },
updateDescription: async (workspaceSlug, projectId, issueId, descriptionBinary) => {
try {
return await updateIssueDescription(workspaceSlug, projectId, issueId, descriptionBinary);
} catch {
throw new Error("Failed to update issue description");
}
},
remove: async (workspaceSlug: string, projectId: string, issueId: string) => { remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
try { try {
if (is_archived) await removeArchivedIssue(workspaceSlug, projectId, issueId); if (is_archived) await removeArchivedIssue(workspaceSlug, projectId, issueId);
@ -331,7 +317,6 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
is_archived, is_archived,
fetchIssue, fetchIssue,
updateIssue, updateIssue,
updateIssueDescription,
removeIssue, removeIssue,
archiveIssue, archiveIssue,
removeArchivedIssue, removeArchivedIssue,

View file

@ -12,9 +12,8 @@ import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// plane web components // plane web components
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe"; import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe";
import { IssueTypeSwitcher } from "@/plane-web/components/issues"; import { IssueTypeSwitcher } from "@/plane-web/components/issues";
// plane web hooks
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
// local components // local components
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
import { IssueDescriptionInput } from "../description-input"; import { IssueDescriptionInput } from "../description-input";
import { IssueReaction } from "../issue-detail/reactions"; import { IssueReaction } from "../issue-detail/reactions";
import { IssueTitleInput } from "../title-input"; import { IssueTitleInput } from "../title-input";
@ -64,6 +63,13 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
if (!issue || !issue.project_id) return <></>; if (!issue || !issue.project_id) return <></>;
const issueDescription =
issue.description_html !== undefined || issue.description_html !== null
? issue.description_html != ""
? issue.description_html
: "<p></p>"
: undefined;
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{issue.parent_id && ( {issue.parent_id && (
@ -99,22 +105,16 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
containerClassName="-ml-3" containerClassName="-ml-3"
/> />
{issue.description_binary !== undefined && (
<IssueDescriptionInput <IssueDescriptionInput
key={issue.id}
containerClassName="-ml-3 border-none"
descriptionBinary={issue.description_binary}
descriptionHTML={issue.description_html ?? "<p></p>"}
disabled={disabled}
updateDescription={async (data) =>
await issueOperations.updateDescription(workspaceSlug, issue.project_id ?? "", issue.id, data)
}
issueId={issue.id}
projectId={issue.project_id}
setIsSubmitting={(value) => setIsSubmitting(value)}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
initialValue={issueDescription}
disabled={disabled}
issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)}
containerClassName="-ml-3 border-none"
/> />
)}
{currentUser && ( {currentUser && (
<IssueReaction <IssueReaction

View file

@ -39,7 +39,6 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
setPeekIssue, setPeekIssue,
issue: { fetchIssue, getIsFetchingIssueDetails }, issue: { fetchIssue, getIsFetchingIssueDetails },
fetchActivities, fetchActivities,
updateIssueDescription,
} = useIssueDetail(); } = useIssueDetail();
const { issues } = useIssuesStore(); const { issues } = useIssuesStore();
@ -93,16 +92,6 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
}); });
} }
}, },
updateDescription: async (workspaceSlug, projectId, issueId, descriptionBinary) => {
if (!workspaceSlug || !projectId || !issueId) {
throw new Error("Required fields missing while updating binary description");
}
try {
return await updateIssueDescription(workspaceSlug, projectId, issueId, descriptionBinary);
} catch {
throw new Error("Failed to update issue description");
}
},
remove: async (workspaceSlug: string, projectId: string, issueId: string) => { remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
try { try {
return issues?.removeIssue(workspaceSlug, projectId, issueId).then(() => { return issues?.removeIssue(workspaceSlug, projectId, issueId).then(() => {
@ -329,17 +318,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
} }
}, },
}), }),
[ [fetchIssue, is_draft, issues, fetchActivities, captureIssueEvent, pathname, removeRoutePeekId, restoreIssue]
fetchIssue,
is_draft,
issues,
fetchActivities,
captureIssueEvent,
pathname,
removeRoutePeekId,
restoreIssue,
updateIssueDescription,
]
); );
useEffect(() => { useEffect(() => {

View file

@ -50,9 +50,7 @@ export const PageRoot = observer((props: TPageRootProps) => {
usePageFallback({ usePageFallback({
editorRef, editorRef,
fetchPageDescription: async () => { fetchPageDescription: async () => {
if (!page.id) { if (!page.id) return;
throw new Error("Required fields missing while fetching binary description");
}
return await projectPageService.fetchDescriptionBinary(workspaceSlug, projectId, page.id); return await projectPageService.fetchDescriptionBinary(workspaceSlug, projectId, page.id);
}, },
hasConnectionFailed, hasConnectionFailed,

View file

@ -8,7 +8,7 @@ import { IUserActivityResponse } from "@plane/types";
// components // components
import { ActivityIcon, ActivityMessage, IssueLink } from "@/components/core"; import { ActivityIcon, ActivityMessage, IssueLink } from "@/components/core";
// editor // editor
import { RichTextReadOnlyEditor } from "@/components/editor"; import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-editor";
// ui // ui
import { ActivitySettingsLoader } from "@/components/ui"; import { ActivitySettingsLoader } from "@/components/ui";
// helpers // helpers

View file

@ -7,7 +7,7 @@ import useSWR from "swr";
import { History, MessageSquare } from "lucide-react"; import { History, MessageSquare } from "lucide-react";
// hooks // hooks
import { ActivityIcon, ActivityMessage, IssueLink } from "@/components/core"; import { ActivityIcon, ActivityMessage, IssueLink } from "@/components/core";
import { RichTextReadOnlyEditor } from "@/components/editor"; import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-editor";
import { ActivitySettingsLoader } from "@/components/ui"; import { ActivitySettingsLoader } from "@/components/ui";
// constants // constants
import { USER_ACTIVITY } from "@/constants/fetch-keys"; import { USER_ACTIVITY } from "@/constants/fetch-keys";

View file

@ -1,54 +0,0 @@
import { useCallback, useEffect, useState } from "react";
// plane editor
import {
convertBase64StringToBinaryData,
EditorRefApi,
getBinaryDataFromRichTextEditorHTMLString,
} from "@plane/editor";
type TArgs = {
descriptionBinary: string | null;
descriptionHTML: string | null;
updateDescription?: (data: string) => Promise<ArrayBuffer>;
};
export const useIssueDescription = (args: TArgs) => {
const { descriptionBinary: savedDescriptionBinary, descriptionHTML, updateDescription } = args;
// states
const [descriptionBinary, setDescriptionBinary] = useState<Uint8Array | null>(null);
// update description
const resolveConflictsAndUpdateDescription = useCallback(
async (encodedDescription: string, editorRef: EditorRefApi | null) => {
if (!updateDescription) return;
try {
const conflictFreeEncodedDescription = await updateDescription(encodedDescription);
const decodedDescription = conflictFreeEncodedDescription
? new Uint8Array(conflictFreeEncodedDescription)
: new Uint8Array();
editorRef?.setProviderDocument(decodedDescription);
} catch (error) {
console.error("Error while updating description", error);
}
},
[updateDescription]
);
useEffect(() => {
if (descriptionBinary) return;
if (savedDescriptionBinary) {
const savedDescriptionBuffer = convertBase64StringToBinaryData(savedDescriptionBinary);
const decodedSavedDescription = savedDescriptionBuffer
? new Uint8Array(savedDescriptionBuffer)
: new Uint8Array();
setDescriptionBinary(decodedSavedDescription);
} else {
const decodedDescriptionHTML = getBinaryDataFromRichTextEditorHTMLString(descriptionHTML ?? "<p></p>");
setDescriptionBinary(decodedDescriptionHTML);
}
}, [descriptionBinary, descriptionHTML, savedDescriptionBinary]);
return {
descriptionBinary,
resolveConflictsAndUpdateDescription,
};
};

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
// plane editor // plane editor
import { convertBinaryDataToBase64String, EditorRefApi } from "@plane/editor"; import { EditorRefApi } from "@plane/editor";
// plane types // plane types
import { TDocumentPayload } from "@plane/types"; import { TDocumentPayload } from "@plane/types";
// hooks // hooks
@ -8,7 +8,7 @@ import useAutoSave from "@/hooks/use-auto-save";
type TArgs = { type TArgs = {
editorRef: React.RefObject<EditorRefApi>; editorRef: React.RefObject<EditorRefApi>;
fetchPageDescription: () => Promise<ArrayBuffer>; fetchPageDescription: () => Promise<any>;
hasConnectionFailed: boolean; hasConnectionFailed: boolean;
updatePageDescription: (data: TDocumentPayload) => Promise<void>; updatePageDescription: (data: TDocumentPayload) => Promise<void>;
}; };
@ -29,7 +29,7 @@ export const usePageFallback = (args: TArgs) => {
editor.setProviderDocument(latestDecodedDescription); editor.setProviderDocument(latestDecodedDescription);
const { binary, html, json } = editor.getDocument(); const { binary, html, json } = editor.getDocument();
if (!binary || !json) return; if (!binary || !json) return;
const encodedBinary = convertBinaryDataToBase64String(binary); const encodedBinary = Buffer.from(binary).toString("base64");
await updatePageDescription({ await updatePageDescription({
description_binary: encodedBinary, description_binary: encodedBinary,

View file

@ -1,5 +1,5 @@
// types // types
import type { TInboxIssue, TIssue, TInboxIssueWithPagination, TInboxForm, TDocumentPayload } from "@plane/types"; import type { TInboxIssue, TIssue, TInboxIssueWithPagination, TInboxForm } from "@plane/types";
import { API_BASE_URL } from "@/helpers/common.helper"; import { API_BASE_URL } from "@/helpers/common.helper";
import { APIService } from "@/services/api.service"; import { APIService } from "@/services/api.service";
// helpers // helpers
@ -76,25 +76,6 @@ export class InboxIssueService extends APIService {
}); });
} }
async updateDescriptionBinary(
workspaceSlug: string,
projectId: string,
inboxIssueId: string,
data: Pick<TDocumentPayload, "description_binary">
): Promise<ArrayBuffer> {
return this.post(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/description/`,
data,
{
responseType: "arraybuffer",
}
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async retrievePublishForm(workspaceSlug: string, projectId: string): Promise<TInboxForm> { async retrievePublishForm(workspaceSlug: string, projectId: string): Promise<TInboxForm> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/intake-settings/`) return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/intake-settings/`)
.then((response) => response?.data) .then((response) => response?.data)

View file

@ -4,7 +4,6 @@ import isEmpty from "lodash/isEmpty";
import type { import type {
IIssueDisplayProperties, IIssueDisplayProperties,
TBulkOperationsPayload, TBulkOperationsPayload,
TDocumentPayload,
TIssue, TIssue,
TIssueActivity, TIssueActivity,
TIssueLink, TIssueLink,
@ -389,19 +388,4 @@ export class IssueService extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
async updateDescriptionBinary(
workspaceSlug: string,
projectId: string,
issueId: string,
data: Pick<TDocumentPayload, "description_binary">
): Promise<ArrayBuffer> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/description/`, data, {
responseType: "arraybuffer",
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
} }

View file

@ -4,10 +4,15 @@ import { TDocumentPayload, TPage } from "@plane/types";
import { API_BASE_URL } from "@/helpers/common.helper"; import { API_BASE_URL } from "@/helpers/common.helper";
// services // services
import { APIService } from "@/services/api.service"; import { APIService } from "@/services/api.service";
import { FileUploadService } from "@/services/file-upload.service";
export class ProjectPageService extends APIService { export class ProjectPageService extends APIService {
private fileUploadService: FileUploadService;
constructor() { constructor() {
super(API_BASE_URL); super(API_BASE_URL);
// upload service
this.fileUploadService = new FileUploadService();
} }
async fetchAll(workspaceSlug: string, projectId: string): Promise<TPage[]> { async fetchAll(workspaceSlug: string, projectId: string): Promise<TPage[]> {
@ -128,7 +133,7 @@ export class ProjectPageService extends APIService {
}); });
} }
async fetchDescriptionBinary(workspaceSlug: string, projectId: string, pageId: string): Promise<ArrayBuffer> { async fetchDescriptionBinary(workspaceSlug: string, projectId: string, pageId: string): Promise<any> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, {
headers: { headers: {
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",

View file

@ -26,7 +26,6 @@ export interface IInboxIssueStore {
updateInboxIssueDuplicateTo: (issueId: string) => Promise<void>; // connecting the inbox issue to the project existing issue updateInboxIssueDuplicateTo: (issueId: string) => Promise<void>; // connecting the inbox issue to the project existing issue
updateInboxIssueSnoozeTill: (date: Date | undefined) => Promise<void>; // snooze the issue updateInboxIssueSnoozeTill: (date: Date | undefined) => Promise<void>; // snooze the issue
updateIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue updateIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue
updateIssueDescription: (descriptionBinary: string) => Promise<ArrayBuffer>; // updating the local issue description
updateProjectIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue updateProjectIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue
fetchIssueActivity: () => Promise<void>; // fetching the issue activity fetchIssueActivity: () => Promise<void>; // fetching the issue activity
} }
@ -79,7 +78,6 @@ export class InboxIssueStore implements IInboxIssueStore {
updateInboxIssueDuplicateTo: action, updateInboxIssueDuplicateTo: action,
updateInboxIssueSnoozeTill: action, updateInboxIssueSnoozeTill: action,
updateIssue: action, updateIssue: action,
updateIssueDescription: action,
updateProjectIssue: action, updateProjectIssue: action,
fetchIssueActivity: action, fetchIssueActivity: action,
}); });
@ -177,26 +175,6 @@ export class InboxIssueStore implements IInboxIssueStore {
} }
}; };
updateIssueDescription = async (descriptionBinary: string): Promise<ArrayBuffer> => {
try {
if (!this.issue.id) throw new Error("Issue id is missing");
const res = await this.inboxIssueService.updateDescriptionBinary(
this.workspaceSlug,
this.projectId,
this.issue.id,
{
description_binary: descriptionBinary,
}
);
set(this.issue, "description_binary", descriptionBinary);
// fetching activity
this.fetchIssueActivity();
return res;
} catch {
throw new Error("Failed to update local issue description");
}
};
updateProjectIssue = async (issue: Partial<TIssue>) => { updateProjectIssue = async (issue: Partial<TIssue>) => {
const inboxIssue = clone(this.issue); const inboxIssue = clone(this.issue);
try { try {

View file

@ -7,7 +7,6 @@ import { persistence } from "@/local-db/storage.sqlite";
// services // services
import { IssueArchiveService, IssueDraftService, IssueService } from "@/services/issue"; import { IssueArchiveService, IssueDraftService, IssueService } from "@/services/issue";
// types // types
import { IIssueRootStore } from "../root.store";
import { IIssueDetail } from "./root.store"; import { IIssueDetail } from "./root.store";
export interface IIssueStoreActions { export interface IIssueStoreActions {
@ -16,15 +15,9 @@ export interface IIssueStoreActions {
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
issueId: string, issueId: string,
issueStatus?: "DEFAULT" | "DRAFT" issueStatus?: "DEFAULT" | "DRAFT",
) => Promise<TIssue>; ) => Promise<TIssue>;
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
updateIssueDescription: (
workspaceSlug: string,
projectId: string,
issueId: string,
descriptionBinary: string
) => Promise<ArrayBuffer>;
removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
archiveIssue: (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>; addCycleToIssue: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
@ -51,21 +44,19 @@ export class IssueStore implements IIssueStore {
fetchingIssueDetails: string | undefined = undefined; fetchingIssueDetails: string | undefined = undefined;
localDBIssueDescription: string | undefined = undefined; localDBIssueDescription: string | undefined = undefined;
// root store // root store
rootIssueStore: IIssueRootStore;
rootIssueDetailStore: IIssueDetail; rootIssueDetailStore: IIssueDetail;
// services // services
issueService; issueService;
issueArchiveService; issueArchiveService;
issueDraftService; issueDraftService;
constructor(rootStore: IIssueRootStore, rootIssueDetailStore: IIssueDetail) { constructor(rootStore: IIssueDetail) {
makeObservable(this, { makeObservable(this, {
fetchingIssueDetails: observable.ref, fetchingIssueDetails: observable.ref,
localDBIssueDescription: observable.ref, localDBIssueDescription: observable.ref,
}); });
// root store // root store
this.rootIssueStore = rootStore; this.rootIssueDetailStore = rootStore;
this.rootIssueDetailStore = rootIssueDetailStore;
// services // services
this.issueService = new IssueService(); this.issueService = new IssueService();
this.issueArchiveService = new IssueArchiveService(); this.issueArchiveService = new IssueArchiveService();
@ -165,7 +156,6 @@ export class IssueStore implements IIssueStore {
id: issue?.id, id: issue?.id,
sequence_id: issue?.sequence_id, sequence_id: issue?.sequence_id,
name: issue?.name, name: issue?.name,
description_binary: issue?.description_binary,
description_html: issue?.description_html, description_html: issue?.description_html,
sort_order: issue?.sort_order, sort_order: issue?.sort_order,
state_id: issue?.state_id, state_id: issue?.state_id,
@ -204,20 +194,6 @@ export class IssueStore implements IIssueStore {
await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId); await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
}; };
updateIssueDescription = async (
workspaceSlug: string,
projectId: string,
issueId: string,
descriptionBinary: string
): Promise<ArrayBuffer> => {
const res = await this.issueService.updateDescriptionBinary(workspaceSlug, projectId, issueId, {
description_binary: descriptionBinary,
});
this.rootIssueStore.issues.updateIssue(issueId, { description_binary: descriptionBinary });
this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
return res;
};
removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>
this.rootIssueDetailStore.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); this.rootIssueDetailStore.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId);

View file

@ -192,7 +192,7 @@ export class IssueDetail implements IIssueDetail {
// store // store
this.rootIssueStore = rootStore; this.rootIssueStore = rootStore;
this.issue = new IssueStore(rootStore, this); this.issue = new IssueStore(this);
this.reaction = new IssueReactionStore(this); this.reaction = new IssueReactionStore(this);
this.attachment = new IssueAttachmentStore(rootStore); this.attachment = new IssueAttachmentStore(rootStore);
this.activity = new IssueActivityStore(rootStore.rootStore as RootStore); this.activity = new IssueActivityStore(rootStore.rootStore as RootStore);
@ -257,12 +257,6 @@ export class IssueDetail implements IIssueDetail {
) => this.issue.fetchIssue(workspaceSlug, projectId, issueId, issueStatus); ) => this.issue.fetchIssue(workspaceSlug, projectId, issueId, issueStatus);
updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) =>
this.issue.updateIssue(workspaceSlug, projectId, issueId, data); this.issue.updateIssue(workspaceSlug, projectId, issueId, data);
updateIssueDescription = async (
workspaceSlug: string,
projectId: string,
issueId: string,
descriptionBinary: string
) => this.issue.updateIssueDescription(workspaceSlug, projectId, issueId, descriptionBinary);
removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>
this.issue.removeIssue(workspaceSlug, projectId, issueId); this.issue.removeIssue(workspaceSlug, projectId, issueId);
archiveIssue = async (workspaceSlug: string, projectId: string, issueId: string) => archiveIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>