diff --git a/apiserver/plane/app/serializers/draft.py b/apiserver/plane/app/serializers/draft.py index e07e416a75d..b1df79f28e7 100644 --- a/apiserver/plane/app/serializers/draft.py +++ b/apiserver/plane/app/serializers/draft.py @@ -284,9 +284,11 @@ class Meta: class DraftIssueDetailSerializer(DraftIssueSerializer): description_html = serializers.CharField() + description_binary = serializers.CharField() class Meta(DraftIssueSerializer.Meta): fields = DraftIssueSerializer.Meta.fields + [ "description_html", + "description_binary", ] read_only_fields = fields diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index b0904834f7b..ad1f9606b8a 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -1,3 +1,6 @@ +# Python imports +import base64 + # Django imports from django.utils import timezone from django.core.validators import URLValidator @@ -732,14 +735,31 @@ class Meta: 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): description_html = serializers.CharField() + description_binary = Base64BinaryField() is_subscribed = serializers.BooleanField(read_only=True) class Meta(IssueSerializer.Meta): fields = IssueSerializer.Meta.fields + [ "description_html", "is_subscribed", + "description_binary", ] read_only_fields = fields diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index 1a2b89bba61..4f106022606 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -66,6 +66,7 @@ class Meta: class WorkspaceMemberMeSerializer(BaseSerializer): draft_issue_count = serializers.IntegerField(read_only=True) + class Meta: model = WorkspaceMember fields = "__all__" diff --git a/apiserver/plane/app/urls/intake.py b/apiserver/plane/app/urls/intake.py index be2f3a053f4..d4f160577e5 100644 --- a/apiserver/plane/app/urls/intake.py +++ b/apiserver/plane/app/urls/intake.py @@ -92,4 +92,14 @@ ), name="inbox-issue", ), + path( + "workspaces//projects//inbox-issues//description/", + IntakeIssueViewSet.as_view( + { + "get": "retrieve_description", + "post": "update_description", + } + ), + name="inbox-issue-description", + ), ] diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index e8ad4408dfb..e20643546da 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -66,6 +66,16 @@ ), name="project-issue", ), + path( + "workspaces//projects//issues//description/", + IssueViewSet.as_view( + { + "get": "retrieve_description", + "post": "update_description", + } + ), + name="project-issue-description", + ), path( "workspaces//projects//issue-labels/", LabelViewSet.as_view( @@ -288,6 +298,15 @@ ), name="project-issue-archive-unarchive", ), + path( + "workspaces//projects//archived-issues//description/", + IssueArchiveViewSet.as_view( + { + "get": "retrieve_description", + } + ), + name="archive-issue-description", + ), ## End Issue Archives ## Issue Relation path( diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index fb6f4c13acc..6481f5691cd 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -276,6 +276,16 @@ ), name="workspace-drafts-issues", ), + path( + "workspaces//draft-issues//description/", + WorkspaceDraftIssueViewSet.as_view( + { + "get": "retrieve_description", + "post": "update_description", + } + ), + name="workspace-drafts-issues", + ), path( "workspaces//draft-to-issue//", WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}), diff --git a/apiserver/plane/app/views/intake/base.py b/apiserver/plane/app/views/intake/base.py index 9e420c00720..394957884c9 100644 --- a/apiserver/plane/app/views/intake/base.py +++ b/apiserver/plane/app/views/intake/base.py @@ -1,5 +1,7 @@ # Python imports import json +import requests +import base64 # Django import from django.utils import timezone @@ -9,6 +11,9 @@ from django.contrib.postgres.fields import ArrayField from django.db.models import Value, UUIDField from django.db.models.functions import Coalesce +from django.http import StreamingHttpResponse +from django.conf import settings + # Third party imports from rest_framework import status @@ -40,7 +45,6 @@ class IntakeViewSet(BaseViewSet): - serializer_class = IntakeSerializer model = Intake @@ -89,7 +93,6 @@ def destroy(self, request, slug, project_id, pk): class IntakeIssueViewSet(BaseViewSet): - serializer_class = IntakeIssueSerializer model = IntakeIssue @@ -640,3 +643,82 @@ def destroy(self, request, slug, project_id, pk): intake_issue.delete() 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) diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index 292d9d61734..3fa4e3c480d 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -7,6 +7,8 @@ from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page +from django.http import StreamingHttpResponse + # Third Party imports from rest_framework import status @@ -27,7 +29,7 @@ IssueLink, IssueSubscriber, IssueReaction, - CycleIssue + CycleIssue, ) from plane.utils.grouper import ( issue_group_values, @@ -327,6 +329,32 @@ def unarchive(self, request, slug, project_id, pk=None): 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): permission_classes = [ diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index c30f889ba01..09b23f11a47 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -1,5 +1,7 @@ # Python imports import json +import requests +import base64 # Django imports from django.contrib.postgres.aggregates import ArrayAgg @@ -20,8 +22,10 @@ ) from django.db.models.functions import Coalesce from django.utils import timezone +from django.http import StreamingHttpResponse from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page +from django.conf import settings # Third Party imports from rest_framework import status @@ -725,6 +729,84 @@ def destroy(self, request, slug, project_id, pk=None): ) 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): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) diff --git a/apiserver/plane/app/views/workspace/draft.py b/apiserver/plane/app/views/workspace/draft.py index b2cb529fca6..1d68256c0ca 100644 --- a/apiserver/plane/app/views/workspace/draft.py +++ b/apiserver/plane/app/views/workspace/draft.py @@ -1,5 +1,7 @@ # Python imports import json +import requests +import base64 # Django imports from django.utils import timezone @@ -7,6 +9,7 @@ from django.core.serializers.json import DjangoJSONEncoder from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField +from django.http import StreamingHttpResponse from django.db.models import ( Q, UUIDField, @@ -17,6 +20,7 @@ from django.db.models.functions import Coalesce from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page +from django.conf import settings # Third Party imports from rest_framework import status @@ -350,3 +354,78 @@ def create_draft_to_issue(self, request, slug, draft_id): return Response(serializer.data, status=status.HTTP_201_CREATED) 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) diff --git a/apiserver/plane/db/migrations/0085_intake_intakeissue_remove_inboxissue_created_by_and_more.py b/apiserver/plane/db/migrations/0085_intake_intakeissue_remove_inboxissue_created_by_and_more.py index 36cf73bc541..16c4167cf66 100644 --- a/apiserver/plane/db/migrations/0085_intake_intakeissue_remove_inboxissue_created_by_and_more.py +++ b/apiserver/plane/db/migrations/0085_intake_intakeissue_remove_inboxissue_created_by_and_more.py @@ -1,9 +1,7 @@ # Generated by Django 4.2.15 on 2024-11-06 08:41 -from django.conf import settings from django.db import migrations, models import django.db.models.deletion -import uuid class Migration(migrations.Migration): diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 1b878e6b8d6..1f150363e1a 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -381,6 +381,7 @@ ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None) SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None) 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)) diff --git a/apiserver/plane/space/urls/intake.py b/apiserver/plane/space/urls/intake.py index 9f43a28098b..350157c950d 100644 --- a/apiserver/plane/space/urls/intake.py +++ b/apiserver/plane/space/urls/intake.py @@ -3,7 +3,6 @@ from plane.space.views import ( IntakeIssuePublicViewSet, - IssueVotePublicViewSet, WorkspaceProjectDeployBoardEndpoint, ) diff --git a/live/.prettierignore b/live/.prettierignore new file mode 100644 index 00000000000..09a5bb19de2 --- /dev/null +++ b/live/.prettierignore @@ -0,0 +1,6 @@ +.next +.vercel +.tubro +out/ +dist/ +node_modules/ \ No newline at end of file diff --git a/live/.prettierrc b/live/.prettierrc new file mode 100644 index 00000000000..87d988f1b26 --- /dev/null +++ b/live/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/live/src/core/helpers/page.ts b/live/src/core/helpers/page.ts deleted file mode 100644 index 4e79afe6b88..00000000000 --- a/live/src/core/helpers/page.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 ?? "

", - 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 - } -} \ No newline at end of file diff --git a/live/src/core/lib/page.ts b/live/src/core/lib/page.ts index c2110a2b8d3..fb80402aaf6 100644 --- a/live/src/core/lib/page.ts +++ b/live/src/core/lib/page.ts @@ -1,8 +1,8 @@ -// helpers +// plane editor import { - getAllDocumentFormatsFromBinaryData, - getBinaryDataFromHTMLString, -} from "@/core/helpers/page.js"; + getAllDocumentFormatsFromDocumentEditorBinaryData, + getBinaryDataFromDocumentEditorHTMLString, +} from "@plane/editor/lib"; // services import { PageService } from "@/core/services/page.service.js"; import { manualLogger } from "../helpers/logger.js"; @@ -12,12 +12,10 @@ export const updatePageDescription = async ( params: URLSearchParams, pageId: string, updatedDescription: Uint8Array, - cookie: string | undefined, + cookie: string | undefined ) => { 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(); @@ -25,7 +23,7 @@ export const updatePageDescription = async ( if (!workspaceSlug || !projectId || !cookie) return; const { contentBinaryEncoded, contentHTML, contentJSON } = - getAllDocumentFormatsFromBinaryData(updatedDescription); + getAllDocumentFormatsFromDocumentEditorBinaryData(updatedDescription); try { const payload = { description_binary: contentBinaryEncoded, @@ -33,13 +31,7 @@ export const updatePageDescription = async ( description: contentJSON, }; - await pageService.updateDescription( - workspaceSlug, - projectId, - pageId, - payload, - cookie, - ); + await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie); } catch (error) { manualLogger.error("Update error:", error); throw error; @@ -50,26 +42,16 @@ const fetchDescriptionHTMLAndTransform = async ( workspaceSlug: string, projectId: string, pageId: string, - cookie: string, + cookie: string ) => { if (!workspaceSlug || !projectId || !cookie) return; try { - const pageDetails = await pageService.fetchDetails( - workspaceSlug, - projectId, - pageId, - cookie, - ); - const { contentBinary } = getBinaryDataFromHTMLString( - pageDetails.description_html ?? "

", - ); + const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie); + const contentBinary = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "

"); return contentBinary; } catch (error) { - manualLogger.error( - "Error while transforming from HTML to Uint8Array", - error, - ); + manualLogger.error("Error while transforming from HTML to Uint8Array", error); throw error; } }; @@ -77,28 +59,18 @@ const fetchDescriptionHTMLAndTransform = async ( export const fetchPageDescriptionBinary = async ( params: URLSearchParams, pageId: string, - cookie: string | undefined, + cookie: string | undefined ) => { const workspaceSlug = params.get("workspaceSlug")?.toString(); const projectId = params.get("projectId")?.toString(); if (!workspaceSlug || !projectId || !cookie) return null; try { - const response = await pageService.fetchDescriptionBinary( - workspaceSlug, - projectId, - pageId, - cookie, - ); + const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie); const binaryData = new Uint8Array(response); if (binaryData.byteLength === 0) { - const binary = await fetchDescriptionHTMLAndTransform( - workspaceSlug, - projectId, - pageId, - cookie, - ); + const binary = await fetchDescriptionHTMLAndTransform(workspaceSlug, projectId, pageId, cookie); if (binary) { return binary; } diff --git a/live/src/core/resolve-conflicts.ts b/live/src/core/resolve-conflicts.ts new file mode 100644 index 00000000000..ffaab707c1d --- /dev/null +++ b/live/src/core/resolve-conflicts.ts @@ -0,0 +1,49 @@ +// 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"); + } +}; diff --git a/live/src/server.ts b/live/src/server.ts index 1868b86c198..ff9977e0a37 100644 --- a/live/src/server.ts +++ b/live/src/server.ts @@ -5,16 +5,13 @@ import expressWs from "express-ws"; import * as Sentry from "@sentry/node"; import compression from "compression"; import helmet from "helmet"; - -// cors import cors from "cors"; - // core hocuspocus server import { getHocusPocusServer } from "@/core/hocuspocus-server.js"; - // helpers -import { logger, manualLogger } from "@/core/helpers/logger.js"; import { errorHandler } from "@/core/helpers/error-handler.js"; +import { logger, manualLogger } from "@/core/helpers/logger.js"; +import { resolveDocumentConflicts, TResolveConflictsRequestBody } from "@/core/resolve-conflicts.js"; const app = express(); expressWs(app); @@ -29,7 +26,7 @@ app.use( compression({ level: 6, threshold: 5 * 1000, - }), + }) ); // Logging middleware @@ -62,6 +59,25 @@ 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((_req, res) => { @@ -82,9 +98,7 @@ const gracefulShutdown = async () => { try { // Close the HocusPocus server WebSocket connections await HocusPocusServer.destroy(); - manualLogger.info( - "HocusPocus server WebSocket connections closed gracefully.", - ); + manualLogger.info("HocusPocus server WebSocket connections closed gracefully."); // Close the Express server liveServer.close(() => { diff --git a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx index cd7d6f35489..6a9b9db532c 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-editor.tsx @@ -8,7 +8,7 @@ import { IssueWidget } from "@/extensions"; // helpers import { getEditorClassNames } from "@/helpers/common"; // hooks -import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor"; +import { useCollaborativeDocumentEditor } from "@/hooks/use-collaborative-document-editor"; // types import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types"; @@ -43,7 +43,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { } // use document editor - const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({ + const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeDocumentEditor({ onTransaction, disabledExtensions, editorClassName, diff --git a/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx index aa925abece4..90de2e84c62 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx @@ -8,7 +8,7 @@ import { IssueWidget } from "@/extensions"; // helpers import { getEditorClassNames } from "@/helpers/common"; // hooks -import { useReadOnlyCollaborativeEditor } from "@/hooks/use-read-only-collaborative-editor"; +import { useCollaborativeDocumentReadOnlyEditor } from "@/hooks/use-collaborative-document-read-only-editor"; // types import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/types"; @@ -36,7 +36,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn ); } - const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({ + const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeDocumentReadOnlyEditor({ editorClassName, extensions, fileHandler, diff --git a/packages/editor/src/core/components/editors/editor-wrapper.tsx b/packages/editor/src/core/components/editors/editor-wrapper.tsx index 33f011535c5..a7332f370fb 100644 --- a/packages/editor/src/core/components/editors/editor-wrapper.tsx +++ b/packages/editor/src/core/components/editors/editor-wrapper.tsx @@ -1,4 +1,4 @@ -import { Editor, Extension } from "@tiptap/core"; +import { AnyExtension, Editor } from "@tiptap/core"; // components import { EditorContainer } from "@/components/editors"; // constants @@ -12,7 +12,7 @@ import { EditorContentWrapper } from "./editor-content"; type Props = IEditorProps & { children?: (editor: Editor) => React.ReactNode; - extensions: Extension[]; + extensions: AnyExtension[]; }; export const EditorWrapper: React.FC = (props) => { diff --git a/packages/editor/src/core/components/editors/rich-text/collaborative-editor.tsx b/packages/editor/src/core/components/editors/rich-text/collaborative-editor.tsx new file mode 100644 index 00000000000..a96daef3325 --- /dev/null +++ b/packages/editor/src/core/components/editors/rich-text/collaborative-editor.tsx @@ -0,0 +1,72 @@ +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 ( + + +
+ +
+
+ ); +}; + +const CollaborativeRichTextEditorWithRef = React.forwardRef( + (props, ref) => ( + } /> + ) +); + +CollaborativeRichTextEditorWithRef.displayName = "CollaborativeRichTextEditorWithRef"; + +export { CollaborativeRichTextEditorWithRef }; diff --git a/packages/editor/src/core/components/editors/rich-text/collaborative-read-only-editor.tsx b/packages/editor/src/core/components/editors/rich-text/collaborative-read-only-editor.tsx new file mode 100644 index 00000000000..050d97cae61 --- /dev/null +++ b/packages/editor/src/core/components/editors/rich-text/collaborative-read-only-editor.tsx @@ -0,0 +1,70 @@ +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 ( + + +
+ +
+
+ ); +}; + +const CollaborativeRichTextReadOnlyEditorWithRef = React.forwardRef< + EditorReadOnlyRefApi, + ICollaborativeRichTextReadOnlyEditor +>((props, ref) => ( + } + /> +)); + +CollaborativeRichTextReadOnlyEditorWithRef.displayName = "CollaborativeRichTextReadOnlyEditorWithRef"; + +export { CollaborativeRichTextReadOnlyEditorWithRef }; diff --git a/packages/editor/src/core/components/editors/rich-text/index.ts b/packages/editor/src/core/components/editors/rich-text/index.ts index b2ba8682a3c..3053a54112d 100644 --- a/packages/editor/src/core/components/editors/rich-text/index.ts +++ b/packages/editor/src/core/components/editors/rich-text/index.ts @@ -1,2 +1,4 @@ +export * from "./collaborative-editor"; +export * from "./collaborative-read-only-editor"; export * from "./editor"; export * from "./read-only-editor"; diff --git a/packages/editor/src/core/helpers/yjs-utils.ts b/packages/editor/src/core/helpers/yjs-utils.ts new file mode 100644 index 00000000000..dd1b9f2fa2f --- /dev/null +++ b/packages/editor/src/core/helpers/yjs-utils.ts @@ -0,0 +1,132 @@ +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 ?? "

", 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 ?? "

", 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, + }; +}; diff --git a/packages/editor/src/core/helpers/yjs.ts b/packages/editor/src/core/helpers/yjs.ts deleted file mode 100644 index ffd9367107d..00000000000 --- a/packages/editor/src/core/helpers/yjs.ts +++ /dev/null @@ -1,16 +0,0 @@ -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; -}; diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-document-editor.ts similarity index 93% rename from packages/editor/src/core/hooks/use-collaborative-editor.ts rename to packages/editor/src/core/hooks/use-collaborative-document-editor.ts index 5bee8c0c3f5..d286db9625b 100644 --- a/packages/editor/src/core/hooks/use-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-document-editor.ts @@ -9,9 +9,9 @@ import { useEditor } from "@/hooks/use-editor"; // plane editor extensions import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions"; // types -import { TCollaborativeEditorProps } from "@/types"; +import { TCollaborativeDocumentEditorHookProps } from "@/types"; -export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { +export const useCollaborativeDocumentEditor = (props: TCollaborativeDocumentEditorHookProps) => { const { onTransaction, disabledExtensions, @@ -102,7 +102,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { forwardedRef, mentionHandler, placeholder, - provider, + providerDocument: provider.document, tabIndex, }); diff --git a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-document-read-only-editor.ts similarity index 90% rename from packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts rename to packages/editor/src/core/hooks/use-collaborative-document-read-only-editor.ts index d4081922973..274a40763d6 100644 --- a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-document-read-only-editor.ts @@ -7,9 +7,9 @@ import { HeadingListExtension } from "@/extensions"; // hooks import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; // types -import { TReadOnlyCollaborativeEditorProps } from "@/types"; +import { TCollaborativeDocumentReadOnlyEditorHookProps } from "@/types"; -export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEditorProps) => { +export const useCollaborativeDocumentReadOnlyEditor = (props: TCollaborativeDocumentReadOnlyEditorHookProps) => { const { editorClassName, editorProps = {}, @@ -79,7 +79,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit forwardedRef, handleEditorReady, mentionHandler, - provider, + providerDocument: provider.document, }); return { diff --git a/packages/editor/src/core/hooks/use-collaborative-rich-text-editor.ts b/packages/editor/src/core/hooks/use-collaborative-rich-text-editor.ts new file mode 100644 index 00000000000..e9a5106d44c --- /dev/null +++ b/packages/editor/src/core/hooks/use-collaborative-rich-text-editor.ts @@ -0,0 +1,78 @@ +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, + }; +}; diff --git a/packages/editor/src/core/hooks/use-collaborative-rich-text-read-only-editor.ts b/packages/editor/src/core/hooks/use-collaborative-rich-text-read-only-editor.ts new file mode 100644 index 00000000000..be5b915fdbc --- /dev/null +++ b/packages/editor/src/core/hooks/use-collaborative-rich-text-read-only-editor.ts @@ -0,0 +1,64 @@ +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, + }; +}; diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index eef72797cee..579dd816f4c 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -1,5 +1,4 @@ import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react"; -import { HocuspocusProvider } from "@hocuspocus/provider"; import { DOMSerializer } from "@tiptap/pm/model"; import { Selection } from "@tiptap/pm/state"; import { EditorProps } from "@tiptap/pm/view"; @@ -36,7 +35,7 @@ export interface CustomEditorProps { onTransaction?: () => void; autofocus?: boolean; placeholder?: string | ((isFocused: boolean, value: string) => string); - provider?: HocuspocusProvider; + providerDocument?: Y.Doc; tabIndex?: number; // undefined when prop is not passed, null if intentionally passed to stop // swr syncing @@ -58,7 +57,7 @@ export const useEditor = (props: CustomEditorProps) => { onChange, onTransaction, placeholder, - provider, + providerDocument, tabIndex, value, autofocus = false, @@ -206,7 +205,7 @@ export const useEditor = (props: CustomEditorProps) => { return markdownOutput; }, getDocument: () => { - const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null; + const documentBinary = providerDocument ? Y.encodeStateAsUpdate(providerDocument) : null; const documentHTML = editorRef.current?.getHTML() ?? "

"; const documentJSON = editorRef.current?.getJSON() ?? null; @@ -284,7 +283,7 @@ export const useEditor = (props: CustomEditorProps) => { words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0, }), setProviderDocument: (value) => { - const document = provider?.document; + const document = providerDocument; if (!document) return; Y.applyUpdate(document, value); }, diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index 23ce023adcd..cde6a8937a9 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -1,5 +1,4 @@ import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react"; -import { HocuspocusProvider } from "@hocuspocus/provider"; import { EditorProps } from "@tiptap/pm/view"; import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; import * as Y from "yjs"; @@ -24,7 +23,7 @@ interface CustomReadOnlyEditorProps { mentionHandler: { highlights: () => Promise; }; - provider?: HocuspocusProvider; + providerDocument?: Y.Doc; } export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { @@ -37,7 +36,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { fileHandler, handleEditorReady, mentionHandler, - provider, + providerDocument, } = props; const editor = useCustomEditor({ @@ -86,7 +85,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { return markdownOutput; }, getDocument: () => { - const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null; + const documentBinary = providerDocument ? Y.encodeStateAsUpdate(providerDocument) : null; const documentHTML = editorRef.current?.getHTML() ?? "

"; const documentJSON = editorRef.current?.getJSON() ?? null; diff --git a/packages/editor/src/core/providers/custom-collaboration-provider.ts b/packages/editor/src/core/providers/custom-collaboration-provider.ts new file mode 100644 index 00000000000..036b15fa152 --- /dev/null +++ b/packages/editor/src/core/providers/custom-collaboration-provider.ts @@ -0,0 +1,61 @@ +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> & + Partial; + +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 = {}): 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); + } +} diff --git a/packages/editor/src/core/providers/index.ts b/packages/editor/src/core/providers/index.ts new file mode 100644 index 00000000000..36e7996394a --- /dev/null +++ b/packages/editor/src/core/providers/index.ts @@ -0,0 +1 @@ +export * from "./custom-collaboration-provider"; diff --git a/packages/editor/src/core/types/collaboration.ts b/packages/editor/src/core/types/collaboration-hook.ts similarity index 60% rename from packages/editor/src/core/types/collaboration.ts rename to packages/editor/src/core/types/collaboration-hook.ts index 8609995ed83..5796df21900 100644 --- a/packages/editor/src/core/types/collaboration.ts +++ b/packages/editor/src/core/types/collaboration-hook.ts @@ -19,7 +19,7 @@ export type TServerHandler = { onServerError?: () => void; }; -type TCollaborativeEditorHookProps = { +type TCollaborativeEditorHookCommonProps = { disabledExtensions?: TExtensions[]; editorClassName: string; editorProps?: EditorProps; @@ -30,12 +30,9 @@ type TCollaborativeEditorHookProps = { highlights: () => Promise; suggestions?: () => Promise; }; - realtimeConfig: TRealtimeConfig; - serverHandler?: TServerHandler; - user: TUserDetails; }; -export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { +type TCollaborativeEditorHookProps = TCollaborativeEditorHookCommonProps & { onTransaction?: () => void; embedHandler?: TEmbedConfig; fileHandler: TFileHandler; @@ -44,7 +41,29 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { tabIndex?: number; }; -export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & { +type TCollaborativeReadOnlyEditorHookProps = TCollaborativeEditorHookCommonProps & { fileHandler: Pick; forwardedRef?: React.MutableRefObject; }; + +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; +}; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 53aae1f265d..4b2bf3e38bf 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -132,6 +132,12 @@ export interface IRichTextEditor extends IEditorProps { dragDropEnabled?: boolean; } +export interface ICollaborativeRichTextEditor extends Omit { + dragDropEnabled?: boolean; + onChange: (updatedDescription: Uint8Array) => void; + value: Uint8Array; +} + export interface ICollaborativeDocumentEditor extends Omit { aiHandler?: TAIHandler; @@ -161,6 +167,10 @@ export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps; export type IRichTextReadOnlyEditor = IReadOnlyEditorProps; +export type ICollaborativeRichTextReadOnlyEditor = Omit & { + value: Uint8Array; +}; + export interface ICollaborativeDocumentReadOnlyEditor extends Omit { embedHandler: TEmbedConfig; handleEditorReady?: (value: boolean) => void; diff --git a/packages/editor/src/core/types/index.ts b/packages/editor/src/core/types/index.ts index 8da9ed276e5..b4c4ad3625a 100644 --- a/packages/editor/src/core/types/index.ts +++ b/packages/editor/src/core/types/index.ts @@ -1,5 +1,5 @@ export * from "./ai"; -export * from "./collaboration"; +export * from "./collaboration-hook"; export * from "./config"; export * from "./editor"; export * from "./embed"; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index ed7d9134698..eb59deade40 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -10,6 +10,8 @@ import "./styles/drag-drop.css"; export { CollaborativeDocumentEditorWithRef, CollaborativeDocumentReadOnlyEditorWithRef, + CollaborativeRichTextEditorWithRef, + CollaborativeRichTextReadOnlyEditorWithRef, DocumentReadOnlyEditorWithRef, LiteTextEditorWithRef, LiteTextReadOnlyEditorWithRef, @@ -25,7 +27,7 @@ export * from "@/constants/common"; // helpers export * from "@/helpers/common"; export * from "@/helpers/editor-commands"; -export * from "@/helpers/yjs"; +export * from "@/helpers/yjs-utils"; export * from "@/extensions/table/table"; // components diff --git a/packages/editor/src/lib.ts b/packages/editor/src/lib.ts index e14c40127fb..2f684724bc2 100644 --- a/packages/editor/src/lib.ts +++ b/packages/editor/src/lib.ts @@ -1 +1 @@ -export * from "@/extensions/core-without-props"; +export * from "@/helpers/yjs-utils"; diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index ae4a98d63f0..1d9580fd1b4 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -50,6 +50,7 @@ export type IssueRelation = { }; export type TIssue = TBaseIssue & { + description_binary?: string; description_html?: string; is_subscribed?: boolean; parent?: Partial; diff --git a/web/core/components/core/modals/gpt-assistant-popover.tsx b/web/core/components/core/modals/gpt-assistant-popover.tsx index 0056977ed6b..99ae0e5863a 100644 --- a/web/core/components/core/modals/gpt-assistant-popover.tsx +++ b/web/core/components/core/modals/gpt-assistant-popover.tsx @@ -9,7 +9,7 @@ import { Popover, Transition } from "@headlessui/react"; // ui import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; // components -import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-editor"; +import { RichTextReadOnlyEditor } from "@/components/editor"; // services import { AIService } from "@/services/ai.service"; diff --git a/web/core/components/editor/rich-text-editor/collaborative-editor.tsx b/web/core/components/editor/rich-text-editor/collaborative-editor.tsx new file mode 100644 index 00000000000..103d0d77fce --- /dev/null +++ b/web/core/components/editor/rich-text-editor/collaborative-editor.tsx @@ -0,0 +1,63 @@ +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 { + key: string; + projectId: string; + uploadFile: (file: File) => Promise; + workspaceId: string; + workspaceSlug: string; +} + +export const CollaborativeRichTextEditor = forwardRef((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 ( + + ); +}); + +CollaborativeRichTextEditor.displayName = "CollaborativeRichTextEditor"; diff --git a/web/core/components/editor/rich-text-editor/collaborative-read-only-editor.tsx b/web/core/components/editor/rich-text-editor/collaborative-read-only-editor.tsx new file mode 100644 index 00000000000..2e80f86c75e --- /dev/null +++ b/web/core/components/editor/rich-text-editor/collaborative-read-only-editor.tsx @@ -0,0 +1,63 @@ +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 ( + + + + ); + + return ( + + ); +}); + +CollaborativeRichTextReadOnlyEditor.displayName = "CollaborativeRichTextReadOnlyEditor"; diff --git a/web/core/components/editor/rich-text-editor/rich-text-editor.tsx b/web/core/components/editor/rich-text-editor/editor.tsx similarity index 100% rename from web/core/components/editor/rich-text-editor/rich-text-editor.tsx rename to web/core/components/editor/rich-text-editor/editor.tsx diff --git a/web/core/components/editor/rich-text-editor/index.ts b/web/core/components/editor/rich-text-editor/index.ts index f185d0054e8..3053a54112d 100644 --- a/web/core/components/editor/rich-text-editor/index.ts +++ b/web/core/components/editor/rich-text-editor/index.ts @@ -1,2 +1,4 @@ -export * from "./rich-text-editor"; -export * from "./rich-text-read-only-editor"; +export * from "./collaborative-editor"; +export * from "./collaborative-read-only-editor"; +export * from "./editor"; +export * from "./read-only-editor"; diff --git a/web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx b/web/core/components/editor/rich-text-editor/read-only-editor.tsx similarity index 100% rename from web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx rename to web/core/components/editor/rich-text-editor/read-only-editor.tsx diff --git a/web/core/components/inbox/content/issue-root.tsx b/web/core/components/inbox/content/issue-root.tsx index 2673245b0f1..f547a552b87 100644 --- a/web/core/components/inbox/content/issue-root.tsx +++ b/web/core/components/inbox/content/issue-root.tsx @@ -3,10 +3,8 @@ import { Dispatch, SetStateAction, useEffect, useMemo } from "react"; import { observer } from "mobx-react"; import { usePathname } from "next/navigation"; -// plane types -import { TIssue } from "@plane/types"; // plane ui -import { Loader, TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { InboxIssueContentProperties } from "@/components/inbox/content"; import { @@ -22,11 +20,12 @@ import { ISSUE_ARCHIVED, ISSUE_DELETED } from "@/constants/event-tracker"; // helpers import { getTextContent } from "@/helpers/editor.helper"; // hooks -import { useEventTracker, useIssueDetail, useProject, useProjectInbox, useUser } from "@/hooks/store"; +import { useEventTracker, useIssueDetail, useProject, useUser } from "@/hooks/store"; import useReloadConfirmations from "@/hooks/use-reload-confirmation"; // store types import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe"; import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; +// store import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; type Props = { @@ -45,7 +44,6 @@ export const InboxIssueMainContent: React.FC = observer((props) => { const { data: currentUser } = useUser(); const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); const { captureIssueEvent } = useEventTracker(); - const { loader } = useProjectInbox(); const { getProjectById } = useProject(); const { removeIssue, archiveIssue } = useIssueDetail(); @@ -60,7 +58,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { } }, [isSubmitting, setShowAlert, setIsSubmitting]); - // dervied values + // derived values const issue = inboxIssue.issue; const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined; @@ -75,12 +73,8 @@ export const InboxIssueMainContent: React.FC = observer((props) => { const issueOperations: TIssueOperations = useMemo( () => ({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars, arrow-body-style - 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) => { + fetch: async () => {}, + remove: async (_workspaceSlug, _projectId, _issueId) => { try { await removeIssue(workspaceSlug, projectId, _issueId); setToast({ @@ -107,7 +101,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { }); } }, - update: async (_workspaceSlug: string, _projectId: string, _issueId: string, data: Partial) => { + update: async (_workspaceSlug, _projectId, _issueId, data) => { try { await inboxIssue.updateIssue(data); captureIssueEvent({ @@ -119,7 +113,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { }, path: pathname, }); - } catch (error) { + } catch { setToast({ title: "Issue update failed", type: TOAST_TYPE.ERROR, @@ -136,7 +130,14 @@ export const InboxIssueMainContent: React.FC = observer((props) => { }); } }, - archive: async (workspaceSlug: string, projectId: string, issueId: string) => { + updateDescription: async (_workspaceSlug, _projectId, _issueId, descriptionBinary) => { + try { + return await inboxIssue.updateIssueDescription(descriptionBinary); + } catch { + throw new Error("Failed to update issue description"); + } + }, + archive: async (workspaceSlug, projectId, issueId) => { try { await archiveIssue(workspaceSlug, projectId, issueId); captureIssueEvent({ @@ -154,7 +155,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { } }, }), - [inboxIssue] + [archiveIssue, captureIssueEvent, inboxIssue, pathname, projectId, removeIssue, workspaceSlug] ); if (!issue?.project_id || !issue?.id) return <>; @@ -184,21 +185,20 @@ export const InboxIssueMainContent: React.FC = observer((props) => { containerClassName="-ml-3" /> - {loader === "issue-loading" ? ( - - - - ) : ( + {issue.description_binary !== undefined && (

"} - initialValue={issue.description_html ?? "

"} + key={issue.id} + containerClassName="-ml-3 border-none" + descriptionBinary={issue.description_binary} + descriptionHTML={issue.description_html ?? "

"} disabled={!isEditable} - issueOperations={issueOperations} + updateDescription={async (data) => + await issueOperations.updateDescription(workspaceSlug, projectId, issue.id ?? "", data) + } + issueId={issue.id} + projectId={issue.project_id} setIsSubmitting={(value) => setIsSubmitting(value)} - containerClassName="-ml-3 border-none" + workspaceSlug={workspaceSlug} /> )} diff --git a/web/core/components/inbox/modals/create-modal/issue-description.tsx b/web/core/components/inbox/modals/create-modal/issue-description.tsx index b9bad6c11ac..4cf7b3f932c 100644 --- a/web/core/components/inbox/modals/create-modal/issue-description.tsx +++ b/web/core/components/inbox/modals/create-modal/issue-description.tsx @@ -10,7 +10,7 @@ import { EFileAssetType } from "@plane/types/src/enums"; // ui import { Loader } from "@plane/ui"; // components -import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor"; +import { RichTextEditor } from "@/components/editor"; // constants import { ETabIndices } from "@/constants/tab-indices"; // helpers diff --git a/web/core/components/issues/description-input.tsx b/web/core/components/issues/description-input.tsx index 8c18618c506..7e6229d9e42 100644 --- a/web/core/components/issues/description-input.tsx +++ b/web/core/components/issues/description-input.tsx @@ -1,157 +1,144 @@ "use client"; -import { FC, useCallback, useEffect, useState } from "react"; +import { FC, useCallback, useRef } from "react"; import debounce from "lodash/debounce"; import { observer } from "mobx-react"; -import { Controller, useForm } from "react-hook-form"; +// plane editor +import { convertBinaryDataToBase64String, EditorRefApi } from "@plane/editor"; // types -import { TIssue } from "@plane/types"; import { EFileAssetType } from "@plane/types/src/enums"; -// ui +// plane ui import { Loader } from "@plane/ui"; // components -import { RichTextEditor, RichTextReadOnlyEditor } from "@/components/editor"; -import { TIssueOperations } from "@/components/issues/issue-detail"; +import { CollaborativeRichTextEditor, CollaborativeRichTextReadOnlyEditor } from "@/components/editor"; // helpers import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; // hooks import { useWorkspace } from "@/hooks/store"; +import { useIssueDescription } from "@/hooks/use-issue-description"; // services import { FileService } from "@/services/file.service"; const fileService = new FileService(); export type IssueDescriptionInputProps = { containerClassName?: string; - workspaceSlug: string; - projectId: string; - issueId: string; - initialValue: string | undefined; + descriptionBinary: string | null; + descriptionHTML: string; disabled?: boolean; - issueOperations: TIssueOperations; + issueId: string; + key: string; placeholder?: string | ((isFocused: boolean, value: string) => string); + projectId: string; setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void; - swrIssueDescription?: string | null | undefined; + updateDescription: (data: string) => Promise; + workspaceSlug: string; }; export const IssueDescriptionInput: FC = observer((props) => { const { containerClassName, - workspaceSlug, - projectId, - issueId, + descriptionBinary: savedDescriptionBinary, + descriptionHTML, disabled, - swrIssueDescription, - initialValue, - issueOperations, - setIsSubmitting, + issueId, placeholder, + projectId, + setIsSubmitting, + updateDescription, + workspaceSlug, } = props; - - const { handleSubmit, reset, control } = useForm({ - defaultValues: { - description_html: initialValue, - }, - }); - - const [localIssueDescription, setLocalIssueDescription] = useState({ - id: issueId, - description_html: initialValue, - }); - - const handleDescriptionFormSubmit = useCallback( - async (formData: Partial) => { - await issueOperations.update(workspaceSlug, projectId, issueId, { - description_html: formData.description_html ?? "

", - }); - }, - [workspaceSlug, projectId, issueId, issueOperations] - ); - + // refs + const editorRef = useRef(null); + // store hooks const { getWorkspaceBySlug } = useWorkspace(); - // computed values - const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string; - - // reset form values - useEffect(() => { - if (!issueId) return; - reset({ - id: issueId, - description_html: initialValue === "" ? "

" : initialValue, - }); - setLocalIssueDescription({ - id: issueId, - description_html: initialValue === "" ? "

" : initialValue, - }); - }, [initialValue, issueId, reset]); + // derived values + const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id?.toString() ?? ""; + // use issue description + const { descriptionBinary, resolveConflictsAndUpdateDescription } = useIssueDescription({ + descriptionBinary: savedDescriptionBinary, + descriptionHTML, + updateDescription, + }); - // ADDING handleDescriptionFormSubmit TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS - // TODO: Verify the exhaustive-deps warning - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedFormSave = useCallback( - debounce(async () => { - handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted")); + const debouncedDescriptionSave = useCallback( + debounce(async (updatedDescription: Uint8Array) => { + const editor = editorRef.current; + if (!editor) return; + const encodedDescription = convertBinaryDataToBase64String(updatedDescription); + await resolveConflictsAndUpdateDescription(encodedDescription, editor); + setIsSubmitting("submitted"); }, 1500), - [handleSubmit, issueId] + [] ); + if (!descriptionBinary) + return ( + + +
+ + +
+
+ + +
+ +
+ +
+
+ + +
+
+ ); + return ( <> - {localIssueDescription.description_html ? ( - - !disabled ? ( -

"} - value={swrIssueDescription ?? null} - workspaceSlug={workspaceSlug} - workspaceId={workspaceId} - projectId={projectId} - dragDropEnabled - onChange={(_description: object, description_html: string) => { - setIsSubmitting("submitting"); - onChange(description_html); - debouncedFormSave(); - }} - placeholder={ - placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value) - } - containerClassName={containerClassName} - uploadFile={async (file) => { - try { - const { asset_id } = await fileService.uploadProjectAsset( - workspaceSlug, - projectId, - { - entity_identifier: issueId, - entity_type: EFileAssetType.ISSUE_DESCRIPTION, - }, - file - ); - return asset_id; - } catch (error) { - console.log("Error in uploading issue asset:", error); - throw new Error("Asset upload failed. Please try again later."); - } - }} - /> - ) : ( - - ) - } + {!disabled ? ( + { + setIsSubmitting("submitting"); + debouncedDescriptionSave(val); + }} + dragDropEnabled + id={issueId} + placeholder={placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value)} + projectId={projectId} + ref={editorRef} + uploadFile={async (file) => { + try { + const { asset_id } = await fileService.uploadProjectAsset( + workspaceSlug, + projectId, + { + entity_identifier: issueId, + entity_type: EFileAssetType.ISSUE_DESCRIPTION, + }, + file + ); + return asset_id; + } catch (error) { + console.log("Error in uploading issue asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }} + workspaceId={workspaceId} + workspaceSlug={workspaceSlug} /> ) : ( - - - + )} ); diff --git a/web/core/components/issues/issue-detail/main-content.tsx b/web/core/components/issues/issue-detail/main-content.tsx index fb4dbc1fce9..37be0c6c6dc 100644 --- a/web/core/components/issues/issue-detail/main-content.tsx +++ b/web/core/components/issues/issue-detail/main-content.tsx @@ -22,6 +22,7 @@ import useSize from "@/hooks/use-window-size"; // plane web components import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe"; import { IssueTypeSwitcher } from "@/plane-web/components/issues"; +// plane web hooks import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; // types import { TIssueOperations } from "./root"; @@ -113,16 +114,22 @@ export const IssueMainContent: React.FC = observer((props) => { containerClassName="-ml-3" /> - setIsSubmitting(value)} - containerClassName="-ml-3 border-none" - /> + {issue.description_binary !== undefined && ( +

"} + 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} + /> + )} {currentUser && ( Promise; update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; + updateDescription: ( + workspaceSlug: string, + projectId: string, + issueId: string, + descriptionBinary: string + ) => Promise; remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; archive?: (workspaceSlug: string, projectId: string, issueId: string) => Promise; restore?: (workspaceSlug: string, projectId: string, issueId: string) => Promise; @@ -64,6 +70,7 @@ export const IssueDetailRoot: FC = observer((props) => { issue: { getIssueById }, fetchIssue, updateIssue, + updateIssueDescription, removeIssue, archiveIssue, addCycleToIssue, @@ -118,6 +125,13 @@ export const IssueDetailRoot: FC = 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) => { try { if (is_archived) await removeArchivedIssue(workspaceSlug, projectId, issueId); @@ -317,6 +331,7 @@ export const IssueDetailRoot: FC = observer((props) => { is_archived, fetchIssue, updateIssue, + updateIssueDescription, removeIssue, archiveIssue, removeArchivedIssue, diff --git a/web/core/components/issues/peek-overview/issue-detail.tsx b/web/core/components/issues/peek-overview/issue-detail.tsx index 74aba71fdc0..6aec30422bb 100644 --- a/web/core/components/issues/peek-overview/issue-detail.tsx +++ b/web/core/components/issues/peek-overview/issue-detail.tsx @@ -12,8 +12,9 @@ import useReloadConfirmations from "@/hooks/use-reload-confirmation"; // plane web components import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe"; import { IssueTypeSwitcher } from "@/plane-web/components/issues"; -// local components +// plane web hooks import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; +// local components import { IssueDescriptionInput } from "../description-input"; import { IssueReaction } from "../issue-detail/reactions"; import { IssueTitleInput } from "../title-input"; @@ -63,13 +64,6 @@ export const PeekOverviewIssueDetails: FC = observer( if (!issue || !issue.project_id) return <>; - const issueDescription = - issue.description_html !== undefined || issue.description_html !== null - ? issue.description_html != "" - ? issue.description_html - : "

" - : undefined; - return (
{issue.parent_id && ( @@ -105,16 +99,22 @@ export const PeekOverviewIssueDetails: FC = observer( containerClassName="-ml-3" /> - setIsSubmitting(value)} - containerClassName="-ml-3 border-none" - /> + {issue.description_binary !== undefined && ( +

"} + 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} + /> + )} {currentUser && ( = observer((props) => { setPeekIssue, issue: { fetchIssue, getIsFetchingIssueDetails }, fetchActivities, + updateIssueDescription, } = useIssueDetail(); const { issues } = useIssuesStore(); @@ -92,6 +93,16 @@ export const IssuePeekOverview: FC = 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) => { try { return issues?.removeIssue(workspaceSlug, projectId, issueId).then(() => { @@ -318,7 +329,17 @@ export const IssuePeekOverview: FC = observer((props) => { } }, }), - [fetchIssue, is_draft, issues, fetchActivities, captureIssueEvent, pathname, removeRoutePeekId, restoreIssue] + [ + fetchIssue, + is_draft, + issues, + fetchActivities, + captureIssueEvent, + pathname, + removeRoutePeekId, + restoreIssue, + updateIssueDescription, + ] ); useEffect(() => { diff --git a/web/core/components/pages/editor/page-root.tsx b/web/core/components/pages/editor/page-root.tsx index ff1f3519e93..500a77586f9 100644 --- a/web/core/components/pages/editor/page-root.tsx +++ b/web/core/components/pages/editor/page-root.tsx @@ -50,7 +50,9 @@ export const PageRoot = observer((props: TPageRootProps) => { usePageFallback({ editorRef, fetchPageDescription: async () => { - if (!page.id) return; + if (!page.id) { + throw new Error("Required fields missing while fetching binary description"); + } return await projectPageService.fetchDescriptionBinary(workspaceSlug, projectId, page.id); }, hasConnectionFailed, diff --git a/web/core/components/profile/activity/activity-list.tsx b/web/core/components/profile/activity/activity-list.tsx index bdb6c6f9356..6b83a92bbde 100644 --- a/web/core/components/profile/activity/activity-list.tsx +++ b/web/core/components/profile/activity/activity-list.tsx @@ -8,7 +8,7 @@ import { IUserActivityResponse } from "@plane/types"; // components import { ActivityIcon, ActivityMessage, IssueLink } from "@/components/core"; // editor -import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-editor"; +import { RichTextReadOnlyEditor } from "@/components/editor"; // ui import { ActivitySettingsLoader } from "@/components/ui"; // helpers diff --git a/web/core/components/profile/activity/profile-activity-list.tsx b/web/core/components/profile/activity/profile-activity-list.tsx index 6878fe9b3c7..0fe9b44f9a8 100644 --- a/web/core/components/profile/activity/profile-activity-list.tsx +++ b/web/core/components/profile/activity/profile-activity-list.tsx @@ -7,7 +7,7 @@ import useSWR from "swr"; import { History, MessageSquare } from "lucide-react"; // hooks import { ActivityIcon, ActivityMessage, IssueLink } from "@/components/core"; -import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-editor"; +import { RichTextReadOnlyEditor } from "@/components/editor"; import { ActivitySettingsLoader } from "@/components/ui"; // constants import { USER_ACTIVITY } from "@/constants/fetch-keys"; diff --git a/web/core/hooks/use-issue-description.ts b/web/core/hooks/use-issue-description.ts new file mode 100644 index 00000000000..5493ffae02d --- /dev/null +++ b/web/core/hooks/use-issue-description.ts @@ -0,0 +1,54 @@ +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; +}; + +export const useIssueDescription = (args: TArgs) => { + const { descriptionBinary: savedDescriptionBinary, descriptionHTML, updateDescription } = args; + // states + const [descriptionBinary, setDescriptionBinary] = useState(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 ?? "

"); + setDescriptionBinary(decodedDescriptionHTML); + } + }, [descriptionBinary, descriptionHTML, savedDescriptionBinary]); + + return { + descriptionBinary, + resolveConflictsAndUpdateDescription, + }; +}; diff --git a/web/core/hooks/use-page-fallback.ts b/web/core/hooks/use-page-fallback.ts index 9f5ef348293..4e604ffb466 100644 --- a/web/core/hooks/use-page-fallback.ts +++ b/web/core/hooks/use-page-fallback.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect } from "react"; // plane editor -import { EditorRefApi } from "@plane/editor"; +import { convertBinaryDataToBase64String, EditorRefApi } from "@plane/editor"; // plane types import { TDocumentPayload } from "@plane/types"; // hooks @@ -8,7 +8,7 @@ import useAutoSave from "@/hooks/use-auto-save"; type TArgs = { editorRef: React.RefObject; - fetchPageDescription: () => Promise; + fetchPageDescription: () => Promise; hasConnectionFailed: boolean; updatePageDescription: (data: TDocumentPayload) => Promise; }; @@ -29,7 +29,7 @@ export const usePageFallback = (args: TArgs) => { editor.setProviderDocument(latestDecodedDescription); const { binary, html, json } = editor.getDocument(); if (!binary || !json) return; - const encodedBinary = Buffer.from(binary).toString("base64"); + const encodedBinary = convertBinaryDataToBase64String(binary); await updatePageDescription({ description_binary: encodedBinary, diff --git a/web/core/services/inbox/inbox-issue.service.ts b/web/core/services/inbox/inbox-issue.service.ts index 8837b6e74a3..d8e6357cc9c 100644 --- a/web/core/services/inbox/inbox-issue.service.ts +++ b/web/core/services/inbox/inbox-issue.service.ts @@ -1,5 +1,5 @@ // types -import type { TInboxIssue, TIssue, TInboxIssueWithPagination, TInboxForm } from "@plane/types"; +import type { TInboxIssue, TIssue, TInboxIssueWithPagination, TInboxForm, TDocumentPayload } from "@plane/types"; import { API_BASE_URL } from "@/helpers/common.helper"; import { APIService } from "@/services/api.service"; // helpers @@ -76,6 +76,25 @@ export class InboxIssueService extends APIService { }); } + async updateDescriptionBinary( + workspaceSlug: string, + projectId: string, + inboxIssueId: string, + data: Pick + ): Promise { + 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 { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/intake-settings/`) .then((response) => response?.data) diff --git a/web/core/services/issue/issue.service.ts b/web/core/services/issue/issue.service.ts index 2cef113d80f..5a9854062f9 100644 --- a/web/core/services/issue/issue.service.ts +++ b/web/core/services/issue/issue.service.ts @@ -4,6 +4,7 @@ import isEmpty from "lodash/isEmpty"; import type { IIssueDisplayProperties, TBulkOperationsPayload, + TDocumentPayload, TIssue, TIssueActivity, TIssueLink, @@ -388,4 +389,19 @@ export class IssueService extends APIService { throw error?.response?.data; }); } + + async updateDescriptionBinary( + workspaceSlug: string, + projectId: string, + issueId: string, + data: Pick + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/description/`, data, { + responseType: "arraybuffer", + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } diff --git a/web/core/services/page/project-page.service.ts b/web/core/services/page/project-page.service.ts index 00d9401a69a..f5331d8891f 100644 --- a/web/core/services/page/project-page.service.ts +++ b/web/core/services/page/project-page.service.ts @@ -4,15 +4,10 @@ import { TDocumentPayload, TPage } from "@plane/types"; import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; -import { FileUploadService } from "@/services/file-upload.service"; export class ProjectPageService extends APIService { - private fileUploadService: FileUploadService; - constructor() { super(API_BASE_URL); - // upload service - this.fileUploadService = new FileUploadService(); } async fetchAll(workspaceSlug: string, projectId: string): Promise { @@ -133,7 +128,7 @@ export class ProjectPageService extends APIService { }); } - async fetchDescriptionBinary(workspaceSlug: string, projectId: string, pageId: string): Promise { + async fetchDescriptionBinary(workspaceSlug: string, projectId: string, pageId: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, { headers: { "Content-Type": "application/octet-stream", diff --git a/web/core/store/inbox/inbox-issue.store.ts b/web/core/store/inbox/inbox-issue.store.ts index e080225aaf2..30a03207d8d 100644 --- a/web/core/store/inbox/inbox-issue.store.ts +++ b/web/core/store/inbox/inbox-issue.store.ts @@ -26,6 +26,7 @@ export interface IInboxIssueStore { updateInboxIssueDuplicateTo: (issueId: string) => Promise; // connecting the inbox issue to the project existing issue updateInboxIssueSnoozeTill: (date: Date | undefined) => Promise; // snooze the issue updateIssue: (issue: Partial) => Promise; // updating the issue + updateIssueDescription: (descriptionBinary: string) => Promise; // updating the local issue description updateProjectIssue: (issue: Partial) => Promise; // updating the issue fetchIssueActivity: () => Promise; // fetching the issue activity } @@ -78,6 +79,7 @@ export class InboxIssueStore implements IInboxIssueStore { updateInboxIssueDuplicateTo: action, updateInboxIssueSnoozeTill: action, updateIssue: action, + updateIssueDescription: action, updateProjectIssue: action, fetchIssueActivity: action, }); @@ -175,6 +177,26 @@ export class InboxIssueStore implements IInboxIssueStore { } }; + updateIssueDescription = async (descriptionBinary: string): Promise => { + 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) => { const inboxIssue = clone(this.issue); try { diff --git a/web/core/store/issue/issue-details/issue.store.ts b/web/core/store/issue/issue-details/issue.store.ts index db0ccc39af2..342c3adca67 100644 --- a/web/core/store/issue/issue-details/issue.store.ts +++ b/web/core/store/issue/issue-details/issue.store.ts @@ -7,6 +7,7 @@ import { persistence } from "@/local-db/storage.sqlite"; // services import { IssueArchiveService, IssueDraftService, IssueService } from "@/services/issue"; // types +import { IIssueRootStore } from "../root.store"; import { IIssueDetail } from "./root.store"; export interface IIssueStoreActions { @@ -15,9 +16,15 @@ export interface IIssueStoreActions { workspaceSlug: string, projectId: string, issueId: string, - issueStatus?: "DEFAULT" | "DRAFT", + issueStatus?: "DEFAULT" | "DRAFT" ) => Promise; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; + updateIssueDescription: ( + workspaceSlug: string, + projectId: string, + issueId: string, + descriptionBinary: string + ) => Promise; removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; addCycleToIssue: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; @@ -44,19 +51,21 @@ export class IssueStore implements IIssueStore { fetchingIssueDetails: string | undefined = undefined; localDBIssueDescription: string | undefined = undefined; // root store + rootIssueStore: IIssueRootStore; rootIssueDetailStore: IIssueDetail; // services issueService; issueArchiveService; issueDraftService; - constructor(rootStore: IIssueDetail) { + constructor(rootStore: IIssueRootStore, rootIssueDetailStore: IIssueDetail) { makeObservable(this, { fetchingIssueDetails: observable.ref, localDBIssueDescription: observable.ref, }); // root store - this.rootIssueDetailStore = rootStore; + this.rootIssueStore = rootStore; + this.rootIssueDetailStore = rootIssueDetailStore; // services this.issueService = new IssueService(); this.issueArchiveService = new IssueArchiveService(); @@ -156,6 +165,7 @@ export class IssueStore implements IIssueStore { id: issue?.id, sequence_id: issue?.sequence_id, name: issue?.name, + description_binary: issue?.description_binary, description_html: issue?.description_html, sort_order: issue?.sort_order, state_id: issue?.state_id, @@ -194,6 +204,20 @@ export class IssueStore implements IIssueStore { await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId); }; + updateIssueDescription = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + descriptionBinary: string + ): Promise => { + 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) => this.rootIssueDetailStore.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); diff --git a/web/core/store/issue/issue-details/root.store.ts b/web/core/store/issue/issue-details/root.store.ts index e6e0ca8d0b2..6a98715d9b4 100644 --- a/web/core/store/issue/issue-details/root.store.ts +++ b/web/core/store/issue/issue-details/root.store.ts @@ -192,7 +192,7 @@ export class IssueDetail implements IIssueDetail { // store this.rootIssueStore = rootStore; - this.issue = new IssueStore(this); + this.issue = new IssueStore(rootStore, this); this.reaction = new IssueReactionStore(this); this.attachment = new IssueAttachmentStore(rootStore); this.activity = new IssueActivityStore(rootStore.rootStore as RootStore); @@ -257,6 +257,12 @@ export class IssueDetail implements IIssueDetail { ) => this.issue.fetchIssue(workspaceSlug, projectId, issueId, issueStatus); updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => 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) => this.issue.removeIssue(workspaceSlug, projectId, issueId); archiveIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>