diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index da2a02f65..d01b25bb1 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -54,7 +54,11 @@ "react-moveable": "^0.54.2", "tailwind-merge": "^1.14.0", "tippy.js": "^6.3.7", - "tiptap-markdown": "^0.8.9" + "tiptap-markdown": "^0.8.9", + "y-indexeddb": "^9.0.12", + "y-prosemirror": "^1.2.5", + "y-protocols": "^1.0.6", + "yjs": "^13.6.15" }, "devDependencies": { "@types/node": "18.15.3", diff --git a/packages/editor/core/src/hooks/use-conflict-free-editor.ts b/packages/editor/core/src/hooks/use-conflict-free-editor.ts new file mode 100644 index 000000000..bbc46aab5 --- /dev/null +++ b/packages/editor/core/src/hooks/use-conflict-free-editor.ts @@ -0,0 +1,78 @@ +import { useEffect, useLayoutEffect, useMemo } from "react"; +import { EditorProps } from "@tiptap/pm/view"; +import { IndexeddbPersistence } from "y-indexeddb"; +import * as Y from "yjs"; +import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TFileHandler, useEditor } from "src"; +// custom provider +import { CollaborationProvider } from "src/providers/collaboration-provider"; + +type DocumentEditorProps = { + id: string; + fileHandler: TFileHandler; + value: Uint8Array; + extensions?: any; + editorClassName: string; + onChange: (updates: Uint8Array) => void; + editorProps?: EditorProps; + forwardedRef?: React.MutableRefObject; + mentionHandler: { + highlights: () => Promise; + suggestions?: () => Promise; + }; + handleEditorReady?: (value: boolean) => void; + placeholder?: string | ((isFocused: boolean, value: string) => string); + tabIndex?: number; +}; + +export const useConflictFreeEditor = ({ + id, + editorProps = {}, + value, + extensions, + editorClassName, + fileHandler, + onChange, + forwardedRef, + tabIndex, + handleEditorReady, + mentionHandler, + placeholder, +}: DocumentEditorProps) => { + const provider = useMemo( + () => + new CollaborationProvider({ + name: id, + onChange, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [id] + ); + + // update document on value change + useEffect(() => { + if (value.byteLength > 0) Y.applyUpdate(provider.document, value); + }, [value, provider.document]); + + // indexedDB provider + useLayoutEffect(() => { + const localProvider = new IndexeddbPersistence(id, provider.document); + return () => { + localProvider?.destroy(); + }; + }, [provider, id]); + + const editor = useEditor({ + id, + editorProps, + editorClassName, + fileHandler, + handleEditorReady, + forwardedRef, + mentionHandler, + extensions, + placeholder, + tabIndex, + }); + + return editor; +}; diff --git a/packages/editor/core/src/index.ts b/packages/editor/core/src/index.ts index 86066eeba..1f058ddd7 100644 --- a/packages/editor/core/src/index.ts +++ b/packages/editor/core/src/index.ts @@ -17,6 +17,7 @@ export { EditorContentWrapper } from "src/ui/components/editor-content"; // hooks export { useEditor } from "src/hooks/use-editor"; +export { useConflictFreeEditor } from "src/hooks/use-conflict-free-editor"; export { useReadOnlyEditor } from "src/hooks/use-read-only-editor"; // helper items diff --git a/packages/editor/core/src/providers/collaboration-provider.ts b/packages/editor/core/src/providers/collaboration-provider.ts new file mode 100644 index 000000000..b61ceebd5 --- /dev/null +++ b/packages/editor/core/src/providers/collaboration-provider.ts @@ -0,0 +1,60 @@ +import * as Y from "yjs"; + +export interface CompleteCollaboratorProviderConfiguration { + /** + * The identifier/name of your document + */ + name: string; + /** + * The actual Y.js document + */ + document: Y.Doc; + /** + * onChange callback + */ + onChange: (updates: Uint8Array) => void; +} + +export type CollaborationProviderConfiguration = Required> & + Partial; + +export class CollaborationProvider { + public configuration: CompleteCollaboratorProviderConfiguration = { + name: "", + // @ts-expect-error cannot be undefined + document: undefined, + onChange: () => {}, + }; + + constructor(configuration: CollaborationProviderConfiguration) { + this.setConfiguration(configuration); + + this.configuration.document = configuration.document ?? new Y.Doc(); + this.document.on("update", this.documentUpdateHandler.bind(this)); + this.document.on("destroy", this.documentDestroyHandler.bind(this)); + } + + public setConfiguration(configuration: Partial = {}): void { + this.configuration = { + ...this.configuration, + ...configuration, + }; + } + + get document() { + return this.configuration.document; + } + + documentUpdateHandler(update: Uint8Array, origin: any) { + // return if the update is from the provider itself + if (origin === this) return; + + // call onChange with the update + this.configuration.onChange?.(update); + } + + documentDestroyHandler() { + this.document.off("update", this.documentUpdateHandler); + this.document.off("destroy", this.documentDestroyHandler); + } +} diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index b7ccad5c7..649057c4d 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -7,9 +7,9 @@ import { getEditorClassNames, IMentionHighlight, IMentionSuggestion, - useEditor, EditorRefApi, TFileHandler, + useConflictFreeEditor, } from "@plane/editor-core"; // extensions import { RichTextEditorExtensions } from "src/ui/extensions"; @@ -17,14 +17,13 @@ import { RichTextEditorExtensions } from "src/ui/extensions"; import { EditorBubbleMenu } from "src/ui/menus/bubble-menu"; export type IRichTextEditor = { - initialValue: string; - value?: string | null; + value: Uint8Array; dragDropEnabled?: boolean; fileHandler: TFileHandler; id?: string; containerClassName?: string; editorClassName?: string; - onChange?: (json: object, html: string) => void; + onChange: (updates: Uint8Array) => void; forwardedRef?: React.MutableRefObject; debouncedUpdatesEnabled?: boolean; mentionHandler: { @@ -39,7 +38,6 @@ const RichTextEditor = (props: IRichTextEditor) => { const { onChange, dragDropEnabled, - initialValue, value, fileHandler, containerClassName, @@ -60,23 +58,21 @@ const RichTextEditor = (props: IRichTextEditor) => { setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop); }; - const editor = useEditor({ + const editor = useConflictFreeEditor({ id, editorClassName, fileHandler, - onChange, - initialValue, value, + onChange, forwardedRef, - // rerenderOnPropsChange, extensions: RichTextEditorExtensions({ uploadFile: fileHandler.upload, dragDropEnabled, setHideDragHandle: setHideDragHandleFunction, }), - tabIndex, mentionHandler, placeholder, + tabIndex, }); const editorContainerClassName = getEditorClassNames({ diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 42c95dc4e..e648a447b 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -3,12 +3,16 @@ import { TIssueAttachment } from "./issue_attachment"; import { TIssueLink } from "./issue_link"; import { TIssueReaction } from "./issue_reaction"; +export type TIssueDescription = { + description_binary: string; + description_html: string; +}; + // new issue structure types -export type TIssue = { +export type TIssue = TIssueDescription & { id: string; sequence_id: number; name: string; - description_html: string; sort_order: number; state_id: string; diff --git a/web/components/inbox/content/issue-root.tsx b/web/components/inbox/content/issue-root.tsx index 3d078bb2f..24aab4d29 100644 --- a/web/components/inbox/content/issue-root.tsx +++ b/web/components/inbox/content/issue-root.tsx @@ -1,7 +1,7 @@ import { Dispatch, SetStateAction, useEffect, useMemo } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -import { TIssue } from "@plane/types"; +import { TIssue, TIssueDescription } from "@plane/types"; import { Loader, TOAST_TYPE, setToast } from "@plane/ui"; // components import { InboxIssueContentProperties } from "@/components/inbox/content"; @@ -18,6 +18,7 @@ import { useEventTracker, useProjectInbox, useUser } from "@/hooks/store"; import useReloadConfirmations from "@/hooks/use-reload-confirmation"; // store types import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; +import { useIssueDescription } from "@/hooks/use-issue-description"; type Props = { workspaceSlug: string; @@ -37,6 +38,16 @@ export const InboxIssueMainContent: React.FC = observer((props) => { const { captureIssueEvent } = useEventTracker(); const { loader } = useProjectInbox(); + const { issueDescriptionYJS, handleDescriptionChange, isDescriptionReady } = useIssueDescription({ + canUpdateDescription: isEditable, + isSubmitting, + issueId, + projectId, + setIsSubmitting, + updateIssueDescription: issueOperations.updateDescription, + workspaceSlug, + }); + useEffect(() => { if (isSubmitting === "submitted") { setShowAlert(false); @@ -106,8 +117,37 @@ export const InboxIssueMainContent: React.FC = observer((props) => { }); } }, + updateDescription: async (workspaceSlug: string, projectId: string, issueId: string, data: TIssueDescription) => { + try { + await inboxIssue.updateIssueDescription(data); + captureIssueEvent({ + eventName: "Inbox issue updated", + payload: { ...data, state: "SUCCESS", element: "Inbox" }, + updates: { + changed_property: Object.keys(data).join(","), + change_details: Object.values(data).join(","), + }, + path: router.asPath, + }); + } catch (error) { + setToast({ + title: "Issue update failed", + type: TOAST_TYPE.ERROR, + message: "Issue update failed", + }); + captureIssueEvent({ + eventName: "Inbox issue updated", + payload: { state: "SUCCESS", element: "Inbox" }, + updates: { + changed_property: Object.keys(data).join(","), + change_details: Object.values(data).join(","), + }, + path: router.asPath, + }); + } + }, }), - [inboxIssue] + [captureIssueEvent, inboxIssue, router.asPath] ); if (!issue?.project_id || !issue?.id) return <>; @@ -133,11 +173,10 @@ export const InboxIssueMainContent: React.FC = observer((props) => { ) : (

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

"} disabled={!isEditable} issueOperations={issueOperations} setIsSubmitting={(value) => setIsSubmitting(value)} diff --git a/web/components/inbox/modals/create-edit-modal/issue-description.tsx b/web/components/inbox/modals/create-edit-modal/issue-description.tsx index 882fb0f95..19e7f64cb 100644 --- a/web/components/inbox/modals/create-edit-modal/issue-description.tsx +++ b/web/components/inbox/modals/create-edit-modal/issue-description.tsx @@ -4,7 +4,7 @@ import { EditorRefApi } from "@plane/rich-text-editor"; import { TIssue } from "@plane/types"; import { Loader } from "@plane/ui"; // components -import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor"; +import { RichTextEditor } from "@/components/editor"; // helpers import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; // hooks diff --git a/web/components/issues/description-input.tsx b/web/components/issues/description-input.tsx index 538f5444e..99d9a5355 100644 --- a/web/components/issues/description-input.tsx +++ b/web/components/issues/description-input.tsx @@ -13,18 +13,18 @@ import { TIssueOperations } from "@/components/issues/issue-detail"; import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; // hooks import { useWorkspace } from "@/hooks/store"; +import { useIssueDescription } from "@/hooks/use-issue-description"; export type IssueDescriptionInputProps = { containerClassName?: string; workspaceSlug: string; projectId: string; issueId: string; - initialValue: string | undefined; disabled?: boolean; issueOperations: TIssueOperations; placeholder?: string | ((isFocused: boolean, value: string) => string); setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void; - swrIssueDescription: string | null | undefined; + value: Uint8Array; }; export const IssueDescriptionInput: FC = observer((props) => { @@ -34,98 +34,42 @@ export const IssueDescriptionInput: FC = observer((p projectId, issueId, disabled, - swrIssueDescription, - initialValue, issueOperations, setIsSubmitting, placeholder, + value, } = props; - const { handleSubmit, reset, control } = useForm({ - defaultValues: { - description_html: initialValue, - }, - }); - - const [localIssueDescription, setLocalIssueDescription] = useState({ - id: issueId, - description_html: initialValue, - }); - - const handleDescriptionFormSubmit = useCallback( - async (formData: Partial) => { - await issueOperations.update(workspaceSlug, projectId, issueId, { - description_html: formData.description_html ?? "

", - }); - }, - [workspaceSlug, projectId, issueId, issueOperations] - ); - 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] - ); + if (!isDescriptionReady) + return ( + + + + ); return ( <> - {localIssueDescription.description_html ? ( - - !disabled ? ( -

"} - value={swrIssueDescription ?? null} - workspaceSlug={workspaceSlug} - workspaceId={workspaceId} - projectId={projectId} - dragDropEnabled - onChange={(_description: object, description_html: string) => { - setIsSubmitting("submitting"); - onChange(description_html); - debouncedFormSave(); - }} - placeholder={ - placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value) - } - containerClassName={containerClassName} - /> - ) : ( - - ) - } + {!disabled ? ( + getDescriptionPlaceholder(isFocused, value)} + containerClassName={containerClassName} /> ) : ( - - - + )} ); diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx index 6fff55aa8..36e40f26e 100644 --- a/web/components/issues/issue-detail/root.tsx +++ b/web/components/issues/issue-detail/root.tsx @@ -2,7 +2,7 @@ import { FC, useMemo } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // types -import { TIssue } from "@plane/types"; +import { TIssue, TIssueDescription } from "@plane/types"; // ui import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui"; // components @@ -23,6 +23,12 @@ import { IssueDetailsSidebar } from "./sidebar"; export type TIssueOperations = { fetch: (workspaceSlug: string, projectId: string, issueId: string) => Promise; update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; + updateDescription: ( + workspaceSlug: string, + projectId: string, + issueId: string, + data: TIssueDescription + ) => 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; @@ -61,6 +67,7 @@ export const IssueDetailRoot: FC = observer((props) => { issue: { getIssueById }, fetchIssue, updateIssue, + updateIssueDescription, removeIssue, archiveIssue, addCycleToIssue, @@ -117,6 +124,35 @@ export const IssueDetailRoot: FC = observer((props) => { }); } }, + updateDescription: async (workspaceSlug: string, projectId: string, issueId: string, data: TIssueDescription) => { + try { + await updateIssueDescription(workspaceSlug, projectId, issueId, data); + captureIssueEvent({ + eventName: ISSUE_UPDATED, + payload: { ...data, issueId, state: "SUCCESS", element: "Issue detail page" }, + updates: { + changed_property: Object.keys(data).join(","), + change_details: Object.values(data).join(","), + }, + path: router.asPath, + }); + } catch (error) { + captureIssueEvent({ + eventName: ISSUE_UPDATED, + payload: { state: "FAILED", element: "Issue detail page" }, + updates: { + changed_property: Object.keys(data).join(","), + change_details: Object.values(data).join(","), + }, + path: router.asPath, + }); + setToast({ + title: "Error!", + type: TOAST_TYPE.ERROR, + message: "Issue update failed", + }); + } + }, remove: async (workspaceSlug: string, projectId: string, issueId: string) => { try { if (is_archived) await removeArchivedIssue(workspaceSlug, projectId, issueId); diff --git a/web/hooks/use-issue-description.ts b/web/hooks/use-issue-description.ts new file mode 100644 index 000000000..8a2f4fc6b --- /dev/null +++ b/web/hooks/use-issue-description.ts @@ -0,0 +1,191 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import useSWR from "swr"; +// editor +import { applyUpdates, mergeUpdates, proseMirrorJSONToBinaryString } from "@plane/document-editor"; +import { EditorRefApi, generateJSONfromHTML } from "@plane/editor-core"; +// types +import { TIssueDescription } from "@plane/types"; +// hooks +import useReloadConfirmations from "@/hooks/use-reload-confirmation"; +// services +import { IssueService } from "@/services/issue"; +import { useIssueDetail } from "./store"; +const issueService = new IssueService(); + +type Props = { + canUpdateDescription: boolean; + editorRef: React.RefObject; + isSubmitting: "submitting" | "submitted" | "saved"; + issueId: string | string[] | undefined; + projectId: string | string[] | undefined; + setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void; + updateIssueDescription: ( + workspaceSlug: string, + projectId: string, + issueId: string, + data: TIssueDescription + ) => Promise; + workspaceSlug: string | string[] | undefined; +}; + +const AUTO_SAVE_TIME = 10000; + +export const useIssueDescription = (props: Props) => { + const { + canUpdateDescription, + editorRef, + isSubmitting, + issueId, + projectId, + setIsSubmitting, + updateIssueDescription, + workspaceSlug, + } = props; + // states + const [isDescriptionReady, setIsDescriptionReady] = useState(false); + const [descriptionUpdates, setDescriptionUpdates] = useState([]); + // store hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + // derived values + const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined; + const issueDescription = issueDetails?.description_html; + + const { data: descriptionBinary, mutate: mutateDescriptionBinary } = useSWR( + workspaceSlug && projectId && issueId ? `ISSUE_DESCRIPTION_BINARY_${workspaceSlug}_${projectId}_${issueId}` : null, + workspaceSlug && projectId && issueId + ? () => issueService.fetchDescriptionBinary(workspaceSlug.toString(), projectId.toString(), issueId.toString()) + : null, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + } + ); + // description in Uint8Array format + const issueDescriptionYJS = useMemo( + () => (descriptionBinary ? new Uint8Array(descriptionBinary) : undefined), + [descriptionBinary] + ); + + // push the new updates to the updates array + const handleDescriptionChange = useCallback((updates: Uint8Array) => { + setDescriptionUpdates((prev) => [...prev, updates]); + }, []); + + // if description_binary field is empty, convert description_html to yDoc and update the DB + // TODO: this is a one-time operation, and needs to be removed once all the issues are updated + useEffect(() => { + const changeHTMLToBinary = async () => { + if (!workspaceSlug || !projectId || !issueId) return; + if (!issueDescriptionYJS || !issueDescription) return; + if (issueDescriptionYJS.byteLength === 0) { + const { contentJSON, editorSchema } = generateJSONfromHTML(issueDescription ?? "

"); + const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema); + await updateIssueDescription(workspaceSlug.toString(), projectId.toString(), issueId.toString(), { + description_binary: yDocBinaryString, + description_html: issueDescription ?? "

", + }); + await mutateDescriptionBinary(); + setIsDescriptionReady(true); + } else setIsDescriptionReady(true); + }; + changeHTMLToBinary(); + }, [ + issueDescription, + issueId, + mutateDescriptionBinary, + issueDescriptionYJS, + projectId, + updateIssueDescription, + workspaceSlug, + ]); + + const handleSaveDescription = useCallback(async () => { + if (!canUpdateDescription) return; + + const applyUpdatesAndSave = async (latestDescription: any, updates: Uint8Array) => { + if (!workspaceSlug || !projectId || !issueId || !latestDescription) return; + // convert description to Uint8Array + const descriptionArray = new Uint8Array(latestDescription); + // apply the updates to the description + const combinedBinaryString = applyUpdates(descriptionArray, updates); + // get the latest html content + const descriptionHTML = editorRef.current?.getHTML() ?? "

"; + // make a request to update the descriptions + await updateIssueDescription(workspaceSlug.toString(), projectId.toString(), issueId.toString(), { + description_binary: combinedBinaryString, + description_html: descriptionHTML, + }).finally(() => setIsSubmitting("saved")); + }; + + try { + setIsSubmitting("submitting"); + // fetch the latest description + const latestDescription = await mutateDescriptionBinary(); + // return if there are no updates + if (descriptionUpdates.length <= 0) { + setIsSubmitting("saved"); + return; + } + // merge the updates array into one single update + const mergedUpdates = mergeUpdates(descriptionUpdates); + await applyUpdatesAndSave(latestDescription, mergedUpdates); + // reset the updates array to empty + setDescriptionUpdates([]); + } catch (error) { + setIsSubmitting("saved"); + throw error; + } + }, [ + canUpdateDescription, + descriptionUpdates, + editorRef, + issueId, + mutateDescriptionBinary, + projectId, + setIsSubmitting, + updateIssueDescription, + workspaceSlug, + ]); + + // auto-save updates every 10 seconds + // handle ctrl/cmd + S to save the description + useEffect(() => { + const intervalId = setInterval(handleSaveDescription, AUTO_SAVE_TIME); + + const handleSave = (e: KeyboardEvent) => { + const { ctrlKey, metaKey, key } = e; + const cmdClicked = ctrlKey || metaKey; + + if (cmdClicked && key.toLowerCase() === "s") { + e.preventDefault(); + e.stopPropagation(); + handleSaveDescription(); + + // reset interval timer + clearInterval(intervalId); + } + }; + window.addEventListener("keydown", handleSave); + + return () => { + clearInterval(intervalId); + window.removeEventListener("keydown", handleSave); + }; + }, [handleSaveDescription]); + + // show a confirm dialog if there are any unsaved changes, or saving is going on + const { setShowAlert } = useReloadConfirmations(descriptionUpdates.length > 0 || isSubmitting === "submitting"); + useEffect(() => { + if (descriptionUpdates.length > 0 || isSubmitting === "submitting") setShowAlert(true); + else setShowAlert(false); + }, [descriptionUpdates, isSubmitting, setShowAlert]); + + return { + handleDescriptionChange, + isDescriptionReady, + issueDescriptionYJS, + }; +}; diff --git a/web/services/inbox/inbox-issue.service.ts b/web/services/inbox/inbox-issue.service.ts index f8fcf72c4..2b4b419ef 100644 --- a/web/services/inbox/inbox-issue.service.ts +++ b/web/services/inbox/inbox-issue.service.ts @@ -1,5 +1,5 @@ // types -import type { TInboxIssue, TIssue, TInboxIssueWithPagination } from "@plane/types"; +import type { TInboxIssue, TIssue, TInboxIssueWithPagination, TIssueDescription } from "@plane/types"; import { API_BASE_URL } from "@/helpers/common.helper"; import { APIService } from "@/services/api.service"; // helpers @@ -68,6 +68,38 @@ export class InboxIssueService extends APIService { }); } + async fetchDescriptionBinary(workspaceSlug: string, projectId: string, inboxIssueId: string): Promise { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/description/`, + { + headers: { + "Content-Type": "application/octet-stream", + }, + responseType: "arraybuffer", + } + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async updateDescriptionBinary( + workspaceSlug: string, + projectId: string, + inboxIssueId: string, + data: TIssueDescription + ): Promise { + return this.patch( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/description/`, + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async destroy(workspaceSlug: string, projectId: string, inboxIssueId: string): Promise { return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/`) .then((response) => response?.data) diff --git a/web/services/issue/issue.service.ts b/web/services/issue/issue.service.ts index 1f6d95fd6..27b151f54 100644 --- a/web/services/issue/issue.service.ts +++ b/web/services/issue/issue.service.ts @@ -1,5 +1,12 @@ // types -import type { TIssue, IIssueDisplayProperties, TIssueLink, TIssueSubIssues, TIssueActivity } from "@plane/types"; +import type { + TIssue, + IIssueDisplayProperties, + TIssueLink, + TIssueSubIssues, + TIssueActivity, + TIssueDescription, +} from "@plane/types"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; // services @@ -52,6 +59,32 @@ export class IssueService extends APIService { }); } + async fetchDescriptionBinary(workspaceSlug: string, projectId: string, issueId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/description/`, { + headers: { + "Content-Type": "application/octet-stream", + }, + responseType: "arraybuffer", + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async updateDescriptionBinary( + workspaceSlug: string, + projectId: string, + issueId: string, + data: TIssueDescription + ): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/description/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async retrieveIssues(workspaceSlug: string, projectId: string, issueIds: string[]): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/list/`, { params: { issues: issueIds.join(",") }, diff --git a/web/store/inbox/inbox-issue.store.ts b/web/store/inbox/inbox-issue.store.ts index a76e58ab6..99888fc0d 100644 --- a/web/store/inbox/inbox-issue.store.ts +++ b/web/store/inbox/inbox-issue.store.ts @@ -1,7 +1,7 @@ import clone from "lodash/clone"; import set from "lodash/set"; import { makeObservable, observable, runInAction, action } from "mobx"; -import { TIssue, TInboxIssue, TInboxIssueStatus, TInboxDuplicateIssueDetails } from "@plane/types"; +import { TIssue, TInboxIssue, TInboxIssueStatus, TInboxDuplicateIssueDetails, TIssueDescription } from "@plane/types"; // helpers import { EInboxIssueStatus } from "@/helpers/inbox.helper"; // services @@ -24,6 +24,7 @@ export interface IInboxIssueStore { updateInboxIssueDuplicateTo: (issueId: string) => Promise; // connecting the inbox issue to the project existing issue updateInboxIssueSnoozeTill: (date: Date) => Promise; // snooze the issue updateIssue: (issue: Partial) => Promise; // updating the issue + updateIssueDescription: (issue: TIssueDescription) => Promise; // updating the issue updateProjectIssue: (issue: Partial) => Promise; // updating the issue fetchIssueActivity: () => Promise; // fetching the issue activity } @@ -44,7 +45,12 @@ export class InboxIssueStore implements IInboxIssueStore { inboxIssueService; issueService; - constructor(workspaceSlug: string, projectId: string, data: TInboxIssue, private store: RootStore) { + constructor( + workspaceSlug: string, + projectId: string, + data: TInboxIssue, + private store: RootStore + ) { this.id = data.id; this.status = data.status; this.issue = data?.issue; @@ -71,6 +77,7 @@ export class InboxIssueStore implements IInboxIssueStore { updateInboxIssueDuplicateTo: action, updateInboxIssueSnoozeTill: action, updateIssue: action, + updateIssueDescription: action, updateProjectIssue: action, fetchIssueActivity: action, }); @@ -162,6 +169,25 @@ export class InboxIssueStore implements IInboxIssueStore { } }; + updateIssueDescription = async (issue: TIssueDescription) => { + const inboxIssue = clone(this.issue); + try { + if (!this.issue.id) return; + runInAction(() => { + this.issue.description_binary = issue.description_binary; + this.issue.description_html = issue.description_html; + }); + await this.inboxIssueService.updateDescriptionBinary(this.workspaceSlug, this.projectId, this.issue.id, issue); + // fetching activity + this.fetchIssueActivity(); + } catch { + runInAction(() => { + this.issue.description_binary = inboxIssue.description_binary; + this.issue.description_html = inboxIssue.description_html; + }); + } + }; + updateProjectIssue = async (issue: Partial) => { const inboxIssue = clone(this.issue); try { diff --git a/web/store/issue/issue-details/issue.store.ts b/web/store/issue/issue-details/issue.store.ts index 361df2e25..a5ee9a0b5 100644 --- a/web/store/issue/issue-details/issue.store.ts +++ b/web/store/issue/issue-details/issue.store.ts @@ -1,7 +1,7 @@ import { makeObservable } from "mobx"; import { computedFn } from "mobx-utils"; // types -import { TIssue } from "@plane/types"; +import { TIssue, TIssueDescription } from "@plane/types"; // services import { IssueArchiveService, IssueDraftService, IssueService } from "@/services/issue"; // types @@ -16,6 +16,12 @@ export interface IIssueStoreActions { issueType?: "DEFAULT" | "DRAFT" | "ARCHIVED" ) => Promise; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; + updateIssueDescription: ( + workspaceSlug: string, + projectId: string, + issueId: string, + data: TIssueDescription + ) => 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; @@ -157,6 +163,19 @@ export class IssueStore implements IIssueStore { await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId); }; + /** + * @description update the issue description + * @param {string} binaryString + * @param {string} descriptionHTML + */ + updateIssueDescription = async (workspaceSlug: string, projectId: string, issueId: string, data: TIssueDescription) => + this.rootIssueDetailStore.rootIssueStore.projectIssues.updateIssueDescription( + workspaceSlug, + projectId, + issueId, + data + ); + removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => this.rootIssueDetailStore.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); diff --git a/web/store/issue/project/issue.store.ts b/web/store/issue/project/issue.store.ts index 1e1f9a515..5bc7f8b30 100644 --- a/web/store/issue/project/issue.store.ts +++ b/web/store/issue/project/issue.store.ts @@ -4,7 +4,15 @@ import set from "lodash/set"; import update from "lodash/update"; import { action, makeObservable, observable, runInAction, computed } from "mobx"; // types -import { TIssue, TGroupedIssues, TSubGroupedIssues, TLoader, TUnGroupedIssues, ViewFlags } from "@plane/types"; +import { + TIssue, + TGroupedIssues, + TSubGroupedIssues, + TLoader, + TUnGroupedIssues, + ViewFlags, + TIssueDescription, +} from "@plane/types"; // helpers import { issueCountBasedOnFilters } from "@/helpers/issue.helper"; // base class @@ -26,6 +34,12 @@ export interface IProjectIssues { fetchIssues: (workspaceSlug: string, projectId: string, loadType: TLoader) => Promise; createIssue: (workspaceSlug: string, projectId: string, data: Partial) => Promise; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; + updateIssueDescription: ( + workspaceSlug: string, + projectId: string, + issueId: string, + data: TIssueDescription + ) => Promise; removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; quickAddIssue: (workspaceSlug: string, projectId: string, data: TIssue) => Promise; @@ -60,6 +74,7 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { fetchIssues: action, createIssue: action, updateIssue: action, + updateIssueDescription: action, removeIssue: action, archiveIssue: action, removeBulkIssues: action, @@ -202,6 +217,20 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { } }; + updateIssueDescription = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + data: TIssueDescription + ) => { + try { + this.rootStore.issues.updateIssue(issueId, data); + await this.issueService.updateDescriptionBinary(workspaceSlug, projectId, issueId, data); + } catch (error) { + throw error; + } + }; + removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { try { await this.issueService.deleteIssue(workspaceSlug, projectId, issueId); @@ -244,7 +273,7 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { const response = await this.createIssue(workspaceSlug, projectId, data); const quickAddIssueIndex = this.issues[projectId].findIndex((_issueId) => _issueId === data.id); - + if (quickAddIssueIndex >= 0) { runInAction(() => { this.issues[projectId].splice(quickAddIssueIndex, 1); @@ -254,7 +283,7 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { //TODO: error handling needs to be improved for rare cases if (data.cycle_id && data.cycle_id !== "") { - await this.rootStore.cycleIssues.addCycleToIssue(workspaceSlug, projectId, data.cycle_id, response.id) + await this.rootStore.cycleIssues.addCycleToIssue(workspaceSlug, projectId, data.cycle_id, response.id); } if (data.module_ids && data.module_ids.length > 0) { @@ -264,7 +293,7 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { response.id, data.module_ids, [] - ) + ); } return response; } catch (error) {