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) {