diff --git a/apiserver/plane/app/serializers/draft.py b/apiserver/plane/app/serializers/draft.py index b1df79f28e7..e07e416a75d 100644 --- a/apiserver/plane/app/serializers/draft.py +++ b/apiserver/plane/app/serializers/draft.py @@ -284,11 +284,9 @@ 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 ad1f9606b8a..b0904834f7b 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -1,6 +1,3 @@ -# Python imports -import base64 - # Django imports from django.utils import timezone from django.core.validators import URLValidator @@ -735,31 +732,14 @@ 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 4f106022606..1a2b89bba61 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -66,7 +66,6 @@ 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 d4f160577e5..be2f3a053f4 100644 --- a/apiserver/plane/app/urls/intake.py +++ b/apiserver/plane/app/urls/intake.py @@ -92,14 +92,4 @@ ), 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 e20643546da..e8ad4408dfb 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -66,16 +66,6 @@ ), 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( @@ -298,15 +288,6 @@ ), 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 6481f5691cd..fb6f4c13acc 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -276,16 +276,6 @@ ), 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 394957884c9..9e420c00720 100644 --- a/apiserver/plane/app/views/intake/base.py +++ b/apiserver/plane/app/views/intake/base.py @@ -1,7 +1,5 @@ # Python imports import json -import requests -import base64 # Django import from django.utils import timezone @@ -11,9 +9,6 @@ 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 @@ -45,6 +40,7 @@ class IntakeViewSet(BaseViewSet): + serializer_class = IntakeSerializer model = Intake @@ -93,6 +89,7 @@ def destroy(self, request, slug, project_id, pk): class IntakeIssueViewSet(BaseViewSet): + serializer_class = IntakeIssueSerializer model = IntakeIssue @@ -643,82 +640,3 @@ 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 3fa4e3c480d..292d9d61734 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -7,8 +7,6 @@ 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 @@ -29,7 +27,7 @@ IssueLink, IssueSubscriber, IssueReaction, - CycleIssue, + CycleIssue ) from plane.utils.grouper import ( issue_group_values, @@ -329,32 +327,6 @@ 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 09b23f11a47..c30f889ba01 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -1,7 +1,5 @@ # Python imports import json -import requests -import base64 # Django imports from django.contrib.postgres.aggregates import ArrayAgg @@ -22,10 +20,8 @@ ) 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 @@ -729,84 +725,6 @@ 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 1d68256c0ca..b2cb529fca6 100644 --- a/apiserver/plane/app/views/workspace/draft.py +++ b/apiserver/plane/app/views/workspace/draft.py @@ -1,7 +1,5 @@ # Python imports import json -import requests -import base64 # Django imports from django.utils import timezone @@ -9,7 +7,6 @@ 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, @@ -20,7 +17,6 @@ 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 @@ -354,78 +350,3 @@ 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 16c4167cf66..36cf73bc541 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,7 +1,9 @@ # 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 5e465cc46d8..a7096ef2a9f 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -381,7 +381,6 @@ 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 350157c950d..9f43a28098b 100644 --- a/apiserver/plane/space/urls/intake.py +++ b/apiserver/plane/space/urls/intake.py @@ -3,6 +3,7 @@ from plane.space.views import ( IntakeIssuePublicViewSet, + IssueVotePublicViewSet, WorkspaceProjectDeployBoardEndpoint, ) diff --git a/live/.prettierignore b/live/.prettierignore deleted file mode 100644 index 09a5bb19de2..00000000000 --- a/live/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -.next -.vercel -.tubro -out/ -dist/ -node_modules/ \ No newline at end of file diff --git a/live/.prettierrc b/live/.prettierrc deleted file mode 100644 index 87d988f1b26..00000000000 --- a/live/.prettierrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "printWidth": 120, - "tabWidth": 2, - "trailingComma": "es5" -} diff --git a/live/src/core/helpers/page.ts b/live/src/core/helpers/page.ts new file mode 100644 index 00000000000..4e79afe6b88 --- /dev/null +++ b/live/src/core/helpers/page.ts @@ -0,0 +1,59 @@ +import { getSchema } from "@tiptap/core"; +import { generateHTML, generateJSON } from "@tiptap/html"; +import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror"; +import * as Y from "yjs" +// plane editor +import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib"; + +const DOCUMENT_EDITOR_EXTENSIONS = [ + ...CoreEditorExtensionsWithoutProps, + ...DocumentEditorExtensionsWithoutProps, +]; +const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS); + +export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): { + contentBinaryEncoded: string; + contentJSON: object; + contentHTML: string; +} => { + // encode binary description data + const base64Data = Buffer.from(description).toString("base64"); + const yDoc = new Y.Doc(); + Y.applyUpdate(yDoc, description); + // convert to JSON + const type = yDoc.getXmlFragment("default"); + const contentJSON = yXmlFragmentToProseMirrorRootNode( + type, + documentEditorSchema + ).toJSON(); + // convert to HTML + const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS); + + return { + contentBinaryEncoded: base64Data, + contentJSON, + contentHTML, + }; +} + +export const getBinaryDataFromHTMLString = (descriptionHTML: string): { + contentBinary: Uint8Array +} => { + // convert HTML to JSON + const contentJSON = generateJSON( + descriptionHTML ?? "

", + 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 fb80402aaf6..c2110a2b8d3 100644 --- a/live/src/core/lib/page.ts +++ b/live/src/core/lib/page.ts @@ -1,8 +1,8 @@ -// plane editor +// helpers import { - getAllDocumentFormatsFromDocumentEditorBinaryData, - getBinaryDataFromDocumentEditorHTMLString, -} from "@plane/editor/lib"; + getAllDocumentFormatsFromBinaryData, + getBinaryDataFromHTMLString, +} from "@/core/helpers/page.js"; // services import { PageService } from "@/core/services/page.service.js"; import { manualLogger } from "../helpers/logger.js"; @@ -12,10 +12,12 @@ 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(); @@ -23,7 +25,7 @@ export const updatePageDescription = async ( if (!workspaceSlug || !projectId || !cookie) return; const { contentBinaryEncoded, contentHTML, contentJSON } = - getAllDocumentFormatsFromDocumentEditorBinaryData(updatedDescription); + getAllDocumentFormatsFromBinaryData(updatedDescription); try { const payload = { description_binary: contentBinaryEncoded, @@ -31,7 +33,13 @@ 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; @@ -42,16 +50,26 @@ 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 = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "

"); + const pageDetails = await pageService.fetchDetails( + workspaceSlug, + projectId, + pageId, + cookie, + ); + const { contentBinary } = getBinaryDataFromHTMLString( + 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; } }; @@ -59,18 +77,28 @@ 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 deleted file mode 100644 index ffaab707c1d..00000000000 --- a/live/src/core/resolve-conflicts.ts +++ /dev/null @@ -1,49 +0,0 @@ -// plane editor -import { - applyUpdates, - convertBase64StringToBinaryData, - getAllDocumentFormatsFromRichTextEditorBinaryData, -} from "@plane/editor/lib"; - -export type TResolveConflictsRequestBody = { - original_document: string; - updates: string; -}; - -export type TResolveConflictsResponse = { - description_binary: string; - description_html: string; - description: object; -}; - -export const resolveDocumentConflicts = (body: TResolveConflictsRequestBody): TResolveConflictsResponse => { - const { original_document, updates } = body; - try { - // convert from base64 to buffer - const originalDocumentBuffer = original_document ? convertBase64StringToBinaryData(original_document) : null; - const updatesBuffer = updates ? convertBase64StringToBinaryData(updates) : null; - // decode req.body - const decodedOriginalDocument = originalDocumentBuffer ? new Uint8Array(originalDocumentBuffer) : new Uint8Array(); - const decodedUpdates = updatesBuffer ? new Uint8Array(updatesBuffer) : new Uint8Array(); - // resolve conflicts - let resolvedDocument: Uint8Array; - if (decodedOriginalDocument.length === 0) { - // use updates to create the document id original_description is null - resolvedDocument = applyUpdates(decodedUpdates); - } else { - // use original document and updates to resolve conflicts - resolvedDocument = applyUpdates(decodedOriginalDocument, decodedUpdates); - } - - const { contentBinaryEncoded, contentHTML, contentJSON } = - getAllDocumentFormatsFromRichTextEditorBinaryData(resolvedDocument); - - return { - description_binary: contentBinaryEncoded, - description_html: contentHTML, - description: contentJSON, - }; - } catch (error) { - throw new Error("Internal server error"); - } -}; diff --git a/live/src/server.ts b/live/src/server.ts index ff9977e0a37..1868b86c198 100644 --- a/live/src/server.ts +++ b/live/src/server.ts @@ -5,13 +5,16 @@ 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 { errorHandler } from "@/core/helpers/error-handler.js"; import { logger, manualLogger } from "@/core/helpers/logger.js"; -import { resolveDocumentConflicts, TResolveConflictsRequestBody } from "@/core/resolve-conflicts.js"; +import { errorHandler } from "@/core/helpers/error-handler.js"; const app = express(); expressWs(app); @@ -26,7 +29,7 @@ app.use( compression({ level: 6, threshold: 5 * 1000, - }) + }), ); // Logging middleware @@ -59,25 +62,6 @@ router.ws("/collaboration", (ws, req) => { } }); -app.post("/resolve-document-conflicts", (req, res) => { - const { original_document, updates } = req.body as TResolveConflictsRequestBody; - try { - if (original_document === undefined || updates === undefined) { - res.status(400).send({ - message: "Missing required fields", - }); - return; - } - const resolvedDocument = resolveDocumentConflicts(req.body); - res.status(200).json(resolvedDocument); - } catch (error) { - manualLogger.error("Error in /resolve-document-conflicts endpoint:", error); - res.status(500).send({ - message: "Internal server error", - }); - } -}); - app.use(process.env.LIVE_BASE_PATH || "/live", router); app.use((_req, res) => { @@ -98,7 +82,9 @@ 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 6a9b9db532c..cd7d6f35489 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 { useCollaborativeDocumentEditor } from "@/hooks/use-collaborative-document-editor"; +import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor"; // types import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types"; @@ -43,7 +43,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => { } // use document editor - const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeDocumentEditor({ + const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({ 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 90de2e84c62..aa925abece4 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 { useCollaborativeDocumentReadOnlyEditor } from "@/hooks/use-collaborative-document-read-only-editor"; +import { useReadOnlyCollaborativeEditor } from "@/hooks/use-read-only-collaborative-editor"; // types import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/types"; @@ -36,7 +36,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn ); } - const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeDocumentReadOnlyEditor({ + const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({ editorClassName, 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 a7332f370fb..33f011535c5 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 { AnyExtension, Editor } from "@tiptap/core"; +import { Editor, Extension } 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: AnyExtension[]; + extensions: Extension[]; }; 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 deleted file mode 100644 index a96daef3325..00000000000 --- a/packages/editor/src/core/components/editors/rich-text/collaborative-editor.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React from "react"; -// components -import { EditorContainer, EditorContentWrapper } from "@/components/editors"; -import { EditorBubbleMenu } from "@/components/menus"; -// constants -import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config"; -// helpers -import { getEditorClassNames } from "@/helpers/common"; -// hooks -import { useCollaborativeRichTextEditor } from "@/hooks/use-collaborative-rich-text-editor"; -// types -import { EditorRefApi, ICollaborativeRichTextEditor } from "@/types"; - -const CollaborativeRichTextEditor = (props: ICollaborativeRichTextEditor) => { - const { - containerClassName, - displayConfig = DEFAULT_DISPLAY_CONFIG, - editorClassName, - fileHandler, - forwardedRef, - id, - mentionHandler, - onChange, - placeholder, - tabIndex, - value, - } = props; - - const { editor } = useCollaborativeRichTextEditor({ - editorClassName, - fileHandler, - forwardedRef, - id, - mentionHandler, - onChange, - placeholder, - tabIndex, - value, - }); - - const editorContainerClassName = getEditorClassNames({ - noBorder: true, - borderOnFocus: false, - containerClassName, - }); - - if (!editor) return null; - - return ( - - -
- -
-
- ); -}; - -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 deleted file mode 100644 index 050d97cae61..00000000000 --- a/packages/editor/src/core/components/editors/rich-text/collaborative-read-only-editor.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from "react"; -// components -import { EditorContainer, EditorContentWrapper } from "@/components/editors"; -import { EditorBubbleMenu } from "@/components/menus"; -// constants -import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config"; -// helpers -import { getEditorClassNames } from "@/helpers/common"; -// hooks -import { useCollaborativeRichTextReadOnlyEditor } from "@/hooks/use-collaborative-rich-text-read-only-editor"; -// types -import { EditorReadOnlyRefApi, ICollaborativeRichTextReadOnlyEditor } from "@/types"; - -const CollaborativeRichTextReadOnlyEditor = (props: ICollaborativeRichTextReadOnlyEditor) => { - const { - containerClassName, - displayConfig = DEFAULT_DISPLAY_CONFIG, - editorClassName, - fileHandler, - forwardedRef, - id, - mentionHandler, - value, - } = props; - - const { editor } = useCollaborativeRichTextReadOnlyEditor({ - editorClassName, - fileHandler, - forwardedRef, - id, - mentionHandler, - value, - }); - - const editorContainerClassName = getEditorClassNames({ - noBorder: true, - borderOnFocus: false, - containerClassName, - }); - - if (!editor) return null; - - return ( - - -
- -
-
- ); -}; - -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 3053a54112d..b2ba8682a3c 100644 --- a/packages/editor/src/core/components/editors/rich-text/index.ts +++ b/packages/editor/src/core/components/editors/rich-text/index.ts @@ -1,4 +1,2 @@ -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 deleted file mode 100644 index dd1b9f2fa2f..00000000000 --- a/packages/editor/src/core/helpers/yjs-utils.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@/extensions"; -import { getSchema } from "@tiptap/core"; -import { generateHTML, generateJSON } from "@tiptap/html"; -import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror"; -import * as Y from "yjs"; - -// editor extension configs -const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps; -const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps]; -// editor schemas -const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS); -const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS); - -/** - * @description apply updates to a doc and return the updated doc in binary format - * @param {Uint8Array} document - * @param {Uint8Array} updates - * @returns {Uint8Array} - */ -export const applyUpdates = (document: Uint8Array, updates?: Uint8Array): Uint8Array => { - const yDoc = new Y.Doc(); - Y.applyUpdate(yDoc, document); - if (updates) { - Y.applyUpdate(yDoc, updates); - } - - const encodedDoc = Y.encodeStateAsUpdate(yDoc); - return encodedDoc; -}; - -/** - * @description this function encodes binary data to base64 string - * @param {Uint8Array} document - * @returns {string} - */ -export const convertBinaryDataToBase64String = (document: Uint8Array): string => - Buffer.from(document).toString("base64"); - -/** - * @description this function decodes base64 string to binary data - * @param {string} document - * @returns {ArrayBuffer} - */ -export const convertBase64StringToBinaryData = (document: string): ArrayBuffer => Buffer.from(document, "base64"); - -/** - * @description this function generates the binary equivalent of html content for the rich text editor - * @param {string} descriptionHTML - * @returns {Uint8Array} - */ -export const getBinaryDataFromRichTextEditorHTMLString = (descriptionHTML: string): Uint8Array => { - // convert HTML to JSON - const contentJSON = generateJSON(descriptionHTML ?? "

", 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 new file mode 100644 index 00000000000..ffd9367107d --- /dev/null +++ b/packages/editor/src/core/helpers/yjs.ts @@ -0,0 +1,16 @@ +import * as Y from "yjs"; + +/** + * @description apply updates to a doc and return the updated doc in base64(binary) format + * @param {Uint8Array} document + * @param {Uint8Array} updates + * @returns {string} base64(binary) form of the updated doc + */ +export const applyUpdates = (document: Uint8Array, updates: Uint8Array): Uint8Array => { + const yDoc = new Y.Doc(); + Y.applyUpdate(yDoc, document); + Y.applyUpdate(yDoc, updates); + + const encodedDoc = Y.encodeStateAsUpdate(yDoc); + return encodedDoc; +}; diff --git a/packages/editor/src/core/hooks/use-collaborative-document-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts similarity index 93% rename from packages/editor/src/core/hooks/use-collaborative-document-editor.ts rename to packages/editor/src/core/hooks/use-collaborative-editor.ts index d286db9625b..5bee8c0c3f5 100644 --- a/packages/editor/src/core/hooks/use-collaborative-document-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -9,9 +9,9 @@ import { useEditor } from "@/hooks/use-editor"; // plane editor extensions import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions"; // types -import { TCollaborativeDocumentEditorHookProps } from "@/types"; +import { TCollaborativeEditorProps } from "@/types"; -export const useCollaborativeDocumentEditor = (props: TCollaborativeDocumentEditorHookProps) => { +export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { const { onTransaction, disabledExtensions, @@ -102,7 +102,7 @@ export const useCollaborativeDocumentEditor = (props: TCollaborativeDocumentEdit forwardedRef, mentionHandler, placeholder, - providerDocument: provider.document, + provider, tabIndex, }); 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 deleted file mode 100644 index e9a5106d44c..00000000000 --- a/packages/editor/src/core/hooks/use-collaborative-rich-text-editor.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useEffect, useMemo } from "react"; -import Collaboration from "@tiptap/extension-collaboration"; -import * as Y from "yjs"; -// extensions -import { HeadingListExtension, SideMenuExtension } from "@/extensions"; -// hooks -import { useEditor } from "@/hooks/use-editor"; -// providers -import { CustomCollaborationProvider } from "@/providers"; -// types -import { TCollaborativeRichTextEditorHookProps } from "@/types"; - -export const useCollaborativeRichTextEditor = (props: TCollaborativeRichTextEditorHookProps) => { - const { - editorClassName, - editorProps = {}, - extensions, - fileHandler, - forwardedRef, - handleEditorReady, - id, - mentionHandler, - onChange, - placeholder, - tabIndex, - value, - } = props; - // initialize custom collaboration provider - const provider = useMemo( - () => - new CustomCollaborationProvider({ - name: id, - onChange, - }), - [id] - ); - - useEffect(() => { - if (provider.hasSynced) return; - if (value && value.length > 0) { - try { - Y.applyUpdate(provider.document, value); - provider.hasSynced = true; - } catch (error) { - console.error("Error applying binary updates to the description", error); - } - } - }, [value, provider.document]); - - const editor = useEditor({ - id, - editorProps, - editorClassName, - enableHistory: false, - extensions: [ - SideMenuExtension({ - aiEnabled: false, - dragDropEnabled: true, - }), - HeadingListExtension, - Collaboration.configure({ - document: provider.document, - }), - ...(extensions ?? []), - ], - fileHandler, - handleEditorReady, - forwardedRef, - mentionHandler, - placeholder, - providerDocument: provider.document, - tabIndex, - }); - - return { - editor, - }; -}; 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 deleted file mode 100644 index be5b915fdbc..00000000000 --- a/packages/editor/src/core/hooks/use-collaborative-rich-text-read-only-editor.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useEffect, useMemo } from "react"; -import Collaboration from "@tiptap/extension-collaboration"; -import * as Y from "yjs"; -// extensions -import { HeadingListExtension, SideMenuExtension } from "@/extensions"; -// hooks -import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; -// providers -import { CustomCollaborationProvider } from "@/providers"; -// types -import { TCollaborativeRichTextReadOnlyEditorHookProps } from "@/types"; - -export const useCollaborativeRichTextReadOnlyEditor = (props: TCollaborativeRichTextReadOnlyEditorHookProps) => { - const { - editorClassName, - editorProps = {}, - extensions, - fileHandler, - forwardedRef, - handleEditorReady, - id, - mentionHandler, - value, - } = props; - // initialize custom collaboration provider - const provider = useMemo( - () => - new CustomCollaborationProvider({ - name: id, - }), - [id] - ); - - useEffect(() => { - if (value.length > 0) { - Y.applyUpdate(provider.document, value); - } - }, [value, provider.document]); - - const editor = useReadOnlyEditor({ - editorProps, - editorClassName, - extensions: [ - SideMenuExtension({ - aiEnabled: false, - dragDropEnabled: true, - }), - HeadingListExtension, - Collaboration.configure({ - document: provider.document, - }), - ...(extensions ?? []), - ], - fileHandler, - handleEditorReady, - forwardedRef, - mentionHandler, - providerDocument: provider.document, - }); - - return { - editor, - }; -}; diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 579dd816f4c..eef72797cee 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -1,4 +1,5 @@ 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"; @@ -35,7 +36,7 @@ export interface CustomEditorProps { onTransaction?: () => void; autofocus?: boolean; placeholder?: string | ((isFocused: boolean, value: string) => string); - providerDocument?: Y.Doc; + provider?: HocuspocusProvider; tabIndex?: number; // undefined when prop is not passed, null if intentionally passed to stop // swr syncing @@ -57,7 +58,7 @@ export const useEditor = (props: CustomEditorProps) => { onChange, onTransaction, placeholder, - providerDocument, + provider, tabIndex, value, autofocus = false, @@ -205,7 +206,7 @@ export const useEditor = (props: CustomEditorProps) => { return markdownOutput; }, getDocument: () => { - const documentBinary = providerDocument ? Y.encodeStateAsUpdate(providerDocument) : null; + const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null; const documentHTML = editorRef.current?.getHTML() ?? "

"; const documentJSON = editorRef.current?.getJSON() ?? null; @@ -283,7 +284,7 @@ export const useEditor = (props: CustomEditorProps) => { words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0, }), setProviderDocument: (value) => { - const document = providerDocument; + const document = provider?.document; if (!document) return; Y.applyUpdate(document, value); }, diff --git a/packages/editor/src/core/hooks/use-collaborative-document-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts similarity index 90% rename from packages/editor/src/core/hooks/use-collaborative-document-read-only-editor.ts rename to packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts index 274a40763d6..d4081922973 100644 --- a/packages/editor/src/core/hooks/use-collaborative-document-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts @@ -7,9 +7,9 @@ import { HeadingListExtension } from "@/extensions"; // hooks import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; // types -import { TCollaborativeDocumentReadOnlyEditorHookProps } from "@/types"; +import { TReadOnlyCollaborativeEditorProps } from "@/types"; -export const useCollaborativeDocumentReadOnlyEditor = (props: TCollaborativeDocumentReadOnlyEditorHookProps) => { +export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEditorProps) => { const { editorClassName, editorProps = {}, @@ -79,7 +79,7 @@ export const useCollaborativeDocumentReadOnlyEditor = (props: TCollaborativeDocu forwardedRef, handleEditorReady, mentionHandler, - providerDocument: provider.document, + provider, }); return { 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 cde6a8937a9..23ce023adcd 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -1,4 +1,5 @@ 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"; @@ -23,7 +24,7 @@ interface CustomReadOnlyEditorProps { mentionHandler: { highlights: () => Promise; }; - providerDocument?: Y.Doc; + provider?: HocuspocusProvider; } export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { @@ -36,7 +37,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { fileHandler, handleEditorReady, mentionHandler, - providerDocument, + provider, } = props; const editor = useCustomEditor({ @@ -85,7 +86,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { return markdownOutput; }, getDocument: () => { - const documentBinary = providerDocument ? Y.encodeStateAsUpdate(providerDocument) : null; + const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : 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 deleted file mode 100644 index 036b15fa152..00000000000 --- a/packages/editor/src/core/providers/custom-collaboration-provider.ts +++ /dev/null @@ -1,61 +0,0 @@ -import * as Y from "yjs"; - -export interface CompleteCollaborationProviderConfiguration { - /** - * The identifier/name of your document - */ - name: string; - /** - * The actual Y.js document - */ - document: Y.Doc; - /** - * onChange callback - */ - onChange: (updates: Uint8Array) => void; -} - -export type CollaborationProviderConfiguration = Required> & - 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 deleted file mode 100644 index 36e7996394a..00000000000 --- a/packages/editor/src/core/providers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./custom-collaboration-provider"; diff --git a/packages/editor/src/core/types/collaboration-hook.ts b/packages/editor/src/core/types/collaboration.ts similarity index 60% rename from packages/editor/src/core/types/collaboration-hook.ts rename to packages/editor/src/core/types/collaboration.ts index 5796df21900..8609995ed83 100644 --- a/packages/editor/src/core/types/collaboration-hook.ts +++ b/packages/editor/src/core/types/collaboration.ts @@ -19,7 +19,7 @@ export type TServerHandler = { onServerError?: () => void; }; -type TCollaborativeEditorHookCommonProps = { +type TCollaborativeEditorHookProps = { disabledExtensions?: TExtensions[]; editorClassName: string; editorProps?: EditorProps; @@ -30,9 +30,12 @@ type TCollaborativeEditorHookCommonProps = { highlights: () => Promise; suggestions?: () => Promise; }; + realtimeConfig: TRealtimeConfig; + serverHandler?: TServerHandler; + user: TUserDetails; }; -type TCollaborativeEditorHookProps = TCollaborativeEditorHookCommonProps & { +export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { onTransaction?: () => void; embedHandler?: TEmbedConfig; fileHandler: TFileHandler; @@ -41,29 +44,7 @@ type TCollaborativeEditorHookProps = TCollaborativeEditorHookCommonProps & { tabIndex?: number; }; -type TCollaborativeReadOnlyEditorHookProps = TCollaborativeEditorHookCommonProps & { +export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & { 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 4b2bf3e38bf..53aae1f265d 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -132,12 +132,6 @@ 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; @@ -167,10 +161,6 @@ 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 b4c4ad3625a..8da9ed276e5 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-hook"; +export * from "./collaboration"; export * from "./config"; export * from "./editor"; export * from "./embed"; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index eb59deade40..ed7d9134698 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -10,8 +10,6 @@ import "./styles/drag-drop.css"; export { CollaborativeDocumentEditorWithRef, CollaborativeDocumentReadOnlyEditorWithRef, - CollaborativeRichTextEditorWithRef, - CollaborativeRichTextReadOnlyEditorWithRef, DocumentReadOnlyEditorWithRef, LiteTextEditorWithRef, LiteTextReadOnlyEditorWithRef, @@ -27,7 +25,7 @@ export * from "@/constants/common"; // helpers export * from "@/helpers/common"; export * from "@/helpers/editor-commands"; -export * from "@/helpers/yjs-utils"; +export * from "@/helpers/yjs"; export * from "@/extensions/table/table"; // components diff --git a/packages/editor/src/lib.ts b/packages/editor/src/lib.ts index 2f684724bc2..e14c40127fb 100644 --- a/packages/editor/src/lib.ts +++ b/packages/editor/src/lib.ts @@ -1 +1 @@ -export * from "@/helpers/yjs-utils"; +export * from "@/extensions/core-without-props"; diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 1d9580fd1b4..ae4a98d63f0 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -50,7 +50,6 @@ 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 99ae0e5863a..0056977ed6b 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"; +import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-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 deleted file mode 100644 index 103d0d77fce..00000000000 --- a/web/core/components/editor/rich-text-editor/collaborative-editor.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React, { forwardRef } from "react"; -// editor -import { CollaborativeRichTextEditorWithRef, EditorRefApi, ICollaborativeRichTextEditor } from "@plane/editor"; -// types -import { IUserLite } from "@plane/types"; -// helpers -import { cn } from "@/helpers/common.helper"; -import { getEditorFileHandlers } from "@/helpers/editor.helper"; -// hooks -import { useMember, useMention, useUser } from "@/hooks/store"; -// plane web hooks -import { useFileSize } from "@/plane-web/hooks/use-file-size"; - -interface Props extends Omit { - 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 deleted file mode 100644 index 2e80f86c75e..00000000000 --- a/web/core/components/editor/rich-text-editor/collaborative-read-only-editor.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from "react"; -// editor -import { - CollaborativeRichTextReadOnlyEditorWithRef, - EditorReadOnlyRefApi, - ICollaborativeRichTextReadOnlyEditor, -} from "@plane/editor"; -// plane ui -import { Loader } from "@plane/ui"; -// helpers -import { cn } from "@/helpers/common.helper"; -import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper"; -// hooks -import { useMention } from "@/hooks/store"; -import { useIssueDescription } from "@/hooks/use-issue-description"; - -type RichTextReadOnlyEditorWrapperProps = Omit< - ICollaborativeRichTextReadOnlyEditor, - "fileHandler" | "mentionHandler" | "value" -> & { - descriptionBinary: string | null; - descriptionHTML: string; - projectId?: string; - workspaceSlug: string; -}; - -export const CollaborativeRichTextReadOnlyEditor = React.forwardRef< - EditorReadOnlyRefApi, - RichTextReadOnlyEditorWrapperProps ->(({ descriptionBinary: savedDescriptionBinary, descriptionHTML, projectId, workspaceSlug, ...props }, ref) => { - const { mentionHighlights } = useMention({}); - - const { descriptionBinary } = useIssueDescription({ - descriptionBinary: savedDescriptionBinary, - descriptionHTML, - }); - - if (!descriptionBinary) - return ( - - - - ); - - return ( - - ); -}); - -CollaborativeRichTextReadOnlyEditor.displayName = "CollaborativeRichTextReadOnlyEditor"; diff --git a/web/core/components/editor/rich-text-editor/index.ts b/web/core/components/editor/rich-text-editor/index.ts index 3053a54112d..f185d0054e8 100644 --- a/web/core/components/editor/rich-text-editor/index.ts +++ b/web/core/components/editor/rich-text-editor/index.ts @@ -1,4 +1,2 @@ -export * from "./collaborative-editor"; -export * from "./collaborative-read-only-editor"; -export * from "./editor"; -export * from "./read-only-editor"; +export * from "./rich-text-editor"; +export * from "./rich-text-read-only-editor"; diff --git a/web/core/components/editor/rich-text-editor/editor.tsx b/web/core/components/editor/rich-text-editor/rich-text-editor.tsx similarity index 100% rename from web/core/components/editor/rich-text-editor/editor.tsx rename to web/core/components/editor/rich-text-editor/rich-text-editor.tsx diff --git a/web/core/components/editor/rich-text-editor/read-only-editor.tsx b/web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx similarity index 100% rename from web/core/components/editor/rich-text-editor/read-only-editor.tsx rename to web/core/components/editor/rich-text-editor/rich-text-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 f547a552b87..2673245b0f1 100644 --- a/web/core/components/inbox/content/issue-root.tsx +++ b/web/core/components/inbox/content/issue-root.tsx @@ -3,8 +3,10 @@ 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 { TOAST_TYPE, setToast } from "@plane/ui"; +import { Loader, TOAST_TYPE, setToast } from "@plane/ui"; // components import { InboxIssueContentProperties } from "@/components/inbox/content"; import { @@ -20,12 +22,11 @@ import { ISSUE_ARCHIVED, ISSUE_DELETED } from "@/constants/event-tracker"; // helpers import { getTextContent } from "@/helpers/editor.helper"; // hooks -import { useEventTracker, useIssueDetail, useProject, useUser } from "@/hooks/store"; +import { useEventTracker, useIssueDetail, useProject, useProjectInbox, useUser } from "@/hooks/store"; import useReloadConfirmations from "@/hooks/use-reload-confirmation"; // 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 = { @@ -44,6 +45,7 @@ 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(); @@ -58,7 +60,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { } }, [isSubmitting, setShowAlert, setIsSubmitting]); - // derived values + // dervied values const issue = inboxIssue.issue; const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined; @@ -73,8 +75,12 @@ export const InboxIssueMainContent: React.FC = observer((props) => { const issueOperations: TIssueOperations = useMemo( () => ({ - fetch: async () => {}, - remove: async (_workspaceSlug, _projectId, _issueId) => { + // 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) => { try { await removeIssue(workspaceSlug, projectId, _issueId); setToast({ @@ -101,7 +107,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { }); } }, - update: async (_workspaceSlug, _projectId, _issueId, data) => { + update: async (_workspaceSlug: string, _projectId: string, _issueId: string, data: Partial) => { try { await inboxIssue.updateIssue(data); captureIssueEvent({ @@ -113,7 +119,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { }, path: pathname, }); - } catch { + } catch (error) { setToast({ title: "Issue update failed", type: TOAST_TYPE.ERROR, @@ -130,14 +136,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { }); } }, - 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) => { + archive: async (workspaceSlug: string, projectId: string, issueId: string) => { try { await archiveIssue(workspaceSlug, projectId, issueId); captureIssueEvent({ @@ -155,7 +154,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { } }, }), - [archiveIssue, captureIssueEvent, inboxIssue, pathname, projectId, removeIssue, workspaceSlug] + [inboxIssue] ); if (!issue?.project_id || !issue?.id) return <>; @@ -185,20 +184,21 @@ export const InboxIssueMainContent: React.FC = observer((props) => { containerClassName="-ml-3" /> - {issue.description_binary !== undefined && ( + {loader === "issue-loading" ? ( + + + + ) : (

"} - disabled={!isEditable} - updateDescription={async (data) => - await issueOperations.updateDescription(workspaceSlug, projectId, issue.id ?? "", data) - } - issueId={issue.id} + workspaceSlug={workspaceSlug} projectId={issue.project_id} + issueId={issue.id} + swrIssueDescription={issue.description_html ?? "

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

"} + disabled={!isEditable} + issueOperations={issueOperations} setIsSubmitting={(value) => setIsSubmitting(value)} - workspaceSlug={workspaceSlug} + containerClassName="-ml-3 border-none" /> )} 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 4cf7b3f932c..b9bad6c11ac 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"; +import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-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 7e6229d9e42..8c18618c506 100644 --- a/web/core/components/issues/description-input.tsx +++ b/web/core/components/issues/description-input.tsx @@ -1,144 +1,157 @@ "use client"; -import { FC, useCallback, useRef } from "react"; +import { FC, useCallback, useEffect, useState } from "react"; import debounce from "lodash/debounce"; import { observer } from "mobx-react"; -// plane editor -import { convertBinaryDataToBase64String, EditorRefApi } from "@plane/editor"; +import { Controller, useForm } from "react-hook-form"; // types +import { TIssue } from "@plane/types"; import { EFileAssetType } from "@plane/types/src/enums"; -// plane ui +// ui import { Loader } from "@plane/ui"; // components -import { CollaborativeRichTextEditor, CollaborativeRichTextReadOnlyEditor } from "@/components/editor"; +import { RichTextEditor, RichTextReadOnlyEditor } from "@/components/editor"; +import { TIssueOperations } from "@/components/issues/issue-detail"; // 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; - descriptionBinary: string | null; - descriptionHTML: string; - disabled?: boolean; + workspaceSlug: string; + projectId: string; issueId: string; - key: string; + initialValue: string | undefined; + disabled?: boolean; + issueOperations: TIssueOperations; placeholder?: string | ((isFocused: boolean, value: string) => string); - projectId: string; setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void; - updateDescription: (data: string) => Promise; - workspaceSlug: string; + swrIssueDescription?: string | null | undefined; }; export const IssueDescriptionInput: FC = observer((props) => { const { containerClassName, - descriptionBinary: savedDescriptionBinary, - descriptionHTML, - disabled, - issueId, - placeholder, + workspaceSlug, projectId, + issueId, + disabled, + swrIssueDescription, + initialValue, + issueOperations, setIsSubmitting, - updateDescription, - workspaceSlug, + placeholder, } = props; - // refs - const editorRef = useRef(null); - // store hooks - const { getWorkspaceBySlug } = useWorkspace(); - // derived values - const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id?.toString() ?? ""; - // use issue description - const { descriptionBinary, resolveConflictsAndUpdateDescription } = useIssueDescription({ - descriptionBinary: savedDescriptionBinary, - descriptionHTML, - updateDescription, + + const { handleSubmit, reset, control } = useForm({ + defaultValues: { + description_html: initialValue, + }, }); - 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), - [] + 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] ); - if (!descriptionBinary) - return ( - - -
- - -
-
- - -
- -
- -
-
- - -
-
- ); + 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]); + + // ADDING handleDescriptionFormSubmit TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS + // TODO: Verify the exhaustive-deps warning + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedFormSave = useCallback( + debounce(async () => { + handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted")); + }, 1500), + [handleSubmit, issueId] + ); return ( <> - {!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} + {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."); + } + }} + /> + ) : ( + + ) + } /> ) : ( - + + + )} ); diff --git a/web/core/components/issues/issue-detail/main-content.tsx b/web/core/components/issues/issue-detail/main-content.tsx index 37be0c6c6dc..fb4dbc1fce9 100644 --- a/web/core/components/issues/issue-detail/main-content.tsx +++ b/web/core/components/issues/issue-detail/main-content.tsx @@ -22,7 +22,6 @@ 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"; @@ -114,22 +113,16 @@ export const IssueMainContent: React.FC = observer((props) => { containerClassName="-ml-3" /> - {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} - /> - )} + setIsSubmitting(value)} + containerClassName="-ml-3 border-none" + /> {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; @@ -70,7 +64,6 @@ export const IssueDetailRoot: FC = observer((props) => { issue: { getIssueById }, fetchIssue, updateIssue, - updateIssueDescription, removeIssue, archiveIssue, addCycleToIssue, @@ -125,13 +118,6 @@ 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); @@ -331,7 +317,6 @@ 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 6aec30422bb..74aba71fdc0 100644 --- a/web/core/components/issues/peek-overview/issue-detail.tsx +++ b/web/core/components/issues/peek-overview/issue-detail.tsx @@ -12,9 +12,8 @@ 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"; -// plane web hooks -import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; // local components +import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; import { IssueDescriptionInput } from "../description-input"; import { IssueReaction } from "../issue-detail/reactions"; import { IssueTitleInput } from "../title-input"; @@ -64,6 +63,13 @@ 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 && ( @@ -99,22 +105,16 @@ export const PeekOverviewIssueDetails: FC = observer( containerClassName="-ml-3" /> - {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} - /> - )} + setIsSubmitting(value)} + containerClassName="-ml-3 border-none" + /> {currentUser && ( = observer((props) => { setPeekIssue, issue: { fetchIssue, getIsFetchingIssueDetails }, fetchActivities, - updateIssueDescription, } = useIssueDetail(); const { issues } = useIssuesStore(); @@ -93,16 +92,6 @@ 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(() => { @@ -329,17 +318,7 @@ export const IssuePeekOverview: FC = observer((props) => { } }, }), - [ - fetchIssue, - is_draft, - issues, - fetchActivities, - captureIssueEvent, - pathname, - removeRoutePeekId, - restoreIssue, - updateIssueDescription, - ] + [fetchIssue, is_draft, issues, fetchActivities, captureIssueEvent, pathname, removeRoutePeekId, restoreIssue] ); useEffect(() => { diff --git a/web/core/components/pages/editor/page-root.tsx b/web/core/components/pages/editor/page-root.tsx index 500a77586f9..ff1f3519e93 100644 --- a/web/core/components/pages/editor/page-root.tsx +++ b/web/core/components/pages/editor/page-root.tsx @@ -50,9 +50,7 @@ export const PageRoot = observer((props: TPageRootProps) => { usePageFallback({ editorRef, fetchPageDescription: async () => { - if (!page.id) { - throw new Error("Required fields missing while fetching binary description"); - } + if (!page.id) return; 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 6b83a92bbde..bdb6c6f9356 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"; +import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-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 0fe9b44f9a8..6878fe9b3c7 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"; +import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-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 deleted file mode 100644 index 5493ffae02d..00000000000 --- a/web/core/hooks/use-issue-description.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -// plane editor -import { - convertBase64StringToBinaryData, - EditorRefApi, - getBinaryDataFromRichTextEditorHTMLString, -} from "@plane/editor"; - -type TArgs = { - descriptionBinary: string | null; - descriptionHTML: string | null; - updateDescription?: (data: string) => Promise; -}; - -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 4e604ffb466..9f5ef348293 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 { convertBinaryDataToBase64String, EditorRefApi } from "@plane/editor"; +import { 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 = convertBinaryDataToBase64String(binary); + const encodedBinary = Buffer.from(binary).toString("base64"); 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 d8e6357cc9c..8837b6e74a3 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, TDocumentPayload } from "@plane/types"; +import type { TInboxIssue, TIssue, TInboxIssueWithPagination, TInboxForm } from "@plane/types"; import { API_BASE_URL } from "@/helpers/common.helper"; import { APIService } from "@/services/api.service"; // helpers @@ -76,25 +76,6 @@ 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 5a9854062f9..2cef113d80f 100644 --- a/web/core/services/issue/issue.service.ts +++ b/web/core/services/issue/issue.service.ts @@ -4,7 +4,6 @@ import isEmpty from "lodash/isEmpty"; import type { IIssueDisplayProperties, TBulkOperationsPayload, - TDocumentPayload, TIssue, TIssueActivity, TIssueLink, @@ -389,19 +388,4 @@ 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 f5331d8891f..00d9401a69a 100644 --- a/web/core/services/page/project-page.service.ts +++ b/web/core/services/page/project-page.service.ts @@ -4,10 +4,15 @@ 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 { @@ -128,7 +133,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 30a03207d8d..e080225aaf2 100644 --- a/web/core/store/inbox/inbox-issue.store.ts +++ b/web/core/store/inbox/inbox-issue.store.ts @@ -26,7 +26,6 @@ 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 } @@ -79,7 +78,6 @@ export class InboxIssueStore implements IInboxIssueStore { updateInboxIssueDuplicateTo: action, updateInboxIssueSnoozeTill: action, updateIssue: action, - updateIssueDescription: action, updateProjectIssue: action, fetchIssueActivity: action, }); @@ -177,26 +175,6 @@ 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 342c3adca67..db0ccc39af2 100644 --- a/web/core/store/issue/issue-details/issue.store.ts +++ b/web/core/store/issue/issue-details/issue.store.ts @@ -7,7 +7,6 @@ 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 { @@ -16,15 +15,9 @@ 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; @@ -51,21 +44,19 @@ 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: IIssueRootStore, rootIssueDetailStore: IIssueDetail) { + constructor(rootStore: IIssueDetail) { makeObservable(this, { fetchingIssueDetails: observable.ref, localDBIssueDescription: observable.ref, }); // root store - this.rootIssueStore = rootStore; - this.rootIssueDetailStore = rootIssueDetailStore; + this.rootIssueDetailStore = rootStore; // services this.issueService = new IssueService(); this.issueArchiveService = new IssueArchiveService(); @@ -165,7 +156,6 @@ 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, @@ -204,20 +194,6 @@ 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 6a98715d9b4..e6e0ca8d0b2 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(rootStore, this); + this.issue = new IssueStore(this); this.reaction = new IssueReactionStore(this); this.attachment = new IssueAttachmentStore(rootStore); this.activity = new IssueActivityStore(rootStore.rootStore as RootStore); @@ -257,12 +257,6 @@ export class IssueDetail implements IIssueDetail { ) => this.issue.fetchIssue(workspaceSlug, projectId, issueId, issueStatus); 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) =>