feat: issue description conllaboration

This commit is contained in:
Aaryan Khandelwal 2024-06-05 14:41:19 +05:30
parent 1573db24c5
commit d8c4461497
16 changed files with 600 additions and 108 deletions

View File

@ -54,7 +54,11 @@
"react-moveable": "^0.54.2", "react-moveable": "^0.54.2",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7", "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": { "devDependencies": {
"@types/node": "18.15.3", "@types/node": "18.15.3",

View File

@ -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<EditorRefApi | null>;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions?: () => Promise<IMentionSuggestion[]>;
};
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;
};

View File

@ -17,6 +17,7 @@ export { EditorContentWrapper } from "src/ui/components/editor-content";
// hooks // hooks
export { useEditor } from "src/hooks/use-editor"; 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"; export { useReadOnlyEditor } from "src/hooks/use-read-only-editor";
// helper items // helper items

View File

@ -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<Pick<CompleteCollaboratorProviderConfiguration, "name">> &
Partial<CompleteCollaboratorProviderConfiguration>;
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<CompleteCollaboratorProviderConfiguration> = {}): 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);
}
}

View File

@ -7,9 +7,9 @@ import {
getEditorClassNames, getEditorClassNames,
IMentionHighlight, IMentionHighlight,
IMentionSuggestion, IMentionSuggestion,
useEditor,
EditorRefApi, EditorRefApi,
TFileHandler, TFileHandler,
useConflictFreeEditor,
} from "@plane/editor-core"; } from "@plane/editor-core";
// extensions // extensions
import { RichTextEditorExtensions } from "src/ui/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"; import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
export type IRichTextEditor = { export type IRichTextEditor = {
initialValue: string; value: Uint8Array;
value?: string | null;
dragDropEnabled?: boolean; dragDropEnabled?: boolean;
fileHandler: TFileHandler; fileHandler: TFileHandler;
id?: string; id?: string;
containerClassName?: string; containerClassName?: string;
editorClassName?: string; editorClassName?: string;
onChange?: (json: object, html: string) => void; onChange: (updates: Uint8Array) => void;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>; forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
debouncedUpdatesEnabled?: boolean; debouncedUpdatesEnabled?: boolean;
mentionHandler: { mentionHandler: {
@ -39,7 +38,6 @@ const RichTextEditor = (props: IRichTextEditor) => {
const { const {
onChange, onChange,
dragDropEnabled, dragDropEnabled,
initialValue,
value, value,
fileHandler, fileHandler,
containerClassName, containerClassName,
@ -60,23 +58,21 @@ const RichTextEditor = (props: IRichTextEditor) => {
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop); setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
}; };
const editor = useEditor({ const editor = useConflictFreeEditor({
id, id,
editorClassName, editorClassName,
fileHandler, fileHandler,
onChange,
initialValue,
value, value,
onChange,
forwardedRef, forwardedRef,
// rerenderOnPropsChange,
extensions: RichTextEditorExtensions({ extensions: RichTextEditorExtensions({
uploadFile: fileHandler.upload, uploadFile: fileHandler.upload,
dragDropEnabled, dragDropEnabled,
setHideDragHandle: setHideDragHandleFunction, setHideDragHandle: setHideDragHandleFunction,
}), }),
tabIndex,
mentionHandler, mentionHandler,
placeholder, placeholder,
tabIndex,
}); });
const editorContainerClassName = getEditorClassNames({ const editorContainerClassName = getEditorClassNames({

View File

@ -3,12 +3,16 @@ import { TIssueAttachment } from "./issue_attachment";
import { TIssueLink } from "./issue_link"; import { TIssueLink } from "./issue_link";
import { TIssueReaction } from "./issue_reaction"; import { TIssueReaction } from "./issue_reaction";
export type TIssueDescription = {
description_binary: string;
description_html: string;
};
// new issue structure types // new issue structure types
export type TIssue = { export type TIssue = TIssueDescription & {
id: string; id: string;
sequence_id: number; sequence_id: number;
name: string; name: string;
description_html: string;
sort_order: number; sort_order: number;
state_id: string; state_id: string;

View File

@ -1,7 +1,7 @@
import { Dispatch, SetStateAction, useEffect, useMemo } from "react"; import { Dispatch, SetStateAction, useEffect, useMemo } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { TIssue } from "@plane/types"; import { TIssue, TIssueDescription } from "@plane/types";
import { Loader, TOAST_TYPE, setToast } from "@plane/ui"; import { Loader, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { InboxIssueContentProperties } from "@/components/inbox/content"; import { InboxIssueContentProperties } from "@/components/inbox/content";
@ -18,6 +18,7 @@ import { useEventTracker, useProjectInbox, useUser } from "@/hooks/store";
import useReloadConfirmations from "@/hooks/use-reload-confirmation"; import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// store types // store types
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
import { useIssueDescription } from "@/hooks/use-issue-description";
type Props = { type Props = {
workspaceSlug: string; workspaceSlug: string;
@ -37,6 +38,16 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
const { captureIssueEvent } = useEventTracker(); const { captureIssueEvent } = useEventTracker();
const { loader } = useProjectInbox(); const { loader } = useProjectInbox();
const { issueDescriptionYJS, handleDescriptionChange, isDescriptionReady } = useIssueDescription({
canUpdateDescription: isEditable,
isSubmitting,
issueId,
projectId,
setIsSubmitting,
updateIssueDescription: issueOperations.updateDescription,
workspaceSlug,
});
useEffect(() => { useEffect(() => {
if (isSubmitting === "submitted") { if (isSubmitting === "submitted") {
setShowAlert(false); setShowAlert(false);
@ -106,8 +117,37 @@ export const InboxIssueMainContent: React.FC<Props> = 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 <></>; if (!issue?.project_id || !issue?.id) return <></>;
@ -133,11 +173,10 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
</Loader> </Loader>
) : ( ) : (
<IssueDescriptionInput <IssueDescriptionInput
value={issueDescriptionYJS}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={issue.project_id} projectId={issue.project_id}
issueId={issue.id} issueId={issue.id}
swrIssueDescription={issue.description_html ?? "<p></p>"}
initialValue={issue.description_html ?? "<p></p>"}
disabled={!isEditable} disabled={!isEditable}
issueOperations={issueOperations} issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)} setIsSubmitting={(value) => setIsSubmitting(value)}

View File

@ -4,7 +4,7 @@ import { EditorRefApi } from "@plane/rich-text-editor";
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
// components // components
import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor"; import { RichTextEditor } from "@/components/editor";
// helpers // helpers
import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
// hooks // hooks

View File

@ -13,18 +13,18 @@ import { TIssueOperations } from "@/components/issues/issue-detail";
import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
// hooks // hooks
import { useWorkspace } from "@/hooks/store"; import { useWorkspace } from "@/hooks/store";
import { useIssueDescription } from "@/hooks/use-issue-description";
export type IssueDescriptionInputProps = { export type IssueDescriptionInputProps = {
containerClassName?: string; containerClassName?: string;
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
issueId: string; issueId: string;
initialValue: string | undefined;
disabled?: boolean; disabled?: boolean;
issueOperations: TIssueOperations; issueOperations: TIssueOperations;
placeholder?: string | ((isFocused: boolean, value: string) => string); placeholder?: string | ((isFocused: boolean, value: string) => string);
setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void; setIsSubmitting: (initialValue: "submitting" | "submitted" | "saved") => void;
swrIssueDescription: string | null | undefined; value: Uint8Array;
}; };
export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((props) => { export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((props) => {
@ -34,98 +34,42 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
projectId, projectId,
issueId, issueId,
disabled, disabled,
swrIssueDescription,
initialValue,
issueOperations, issueOperations,
setIsSubmitting, setIsSubmitting,
placeholder, placeholder,
value,
} = props; } = props;
const { handleSubmit, reset, control } = useForm<TIssue>({
defaultValues: {
description_html: initialValue,
},
});
const [localIssueDescription, setLocalIssueDescription] = useState({
id: issueId,
description_html: initialValue,
});
const handleDescriptionFormSubmit = useCallback(
async (formData: Partial<TIssue>) => {
await issueOperations.update(workspaceSlug, projectId, issueId, {
description_html: formData.description_html ?? "<p></p>",
});
},
[workspaceSlug, projectId, issueId, issueOperations]
);
const { getWorkspaceBySlug } = useWorkspace(); const { getWorkspaceBySlug } = useWorkspace();
// computed values // computed values
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string; const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string;
// reset form values if (!isDescriptionReady)
useEffect(() => { return (
if (!issueId) return; <Loader>
reset({ <Loader.Item height="150px" />
id: issueId, </Loader>
description_html: initialValue === "" ? "<p></p>" : initialValue, );
});
setLocalIssueDescription({
id: issueId,
description_html: initialValue === "" ? "<p></p>" : 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 ( return (
<> <>
{localIssueDescription.description_html ? ( {!disabled ? (
<Controller <RichTextEditor
name="description_html" id={issueId}
control={control} value={value}
render={({ field: { onChange } }) => workspaceSlug={workspaceSlug}
!disabled ? ( workspaceId={workspaceId}
<RichTextEditor projectId={projectId}
id={issueId} dragDropEnabled
initialValue={localIssueDescription.description_html ?? "<p></p>"} onChange={handleDescriptionChange}
value={swrIssueDescription ?? null} placeholder={placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value)}
workspaceSlug={workspaceSlug} containerClassName={containerClassName}
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}
/>
) : (
<RichTextReadOnlyEditor
initialValue={localIssueDescription.description_html ?? ""}
containerClassName={containerClassName}
/>
)
}
/> />
) : ( ) : (
<Loader> <RichTextReadOnlyEditor
<Loader.Item height="150px" /> initialValue={localIssueDescription.description_html ?? ""}
</Loader> containerClassName={containerClassName}
/>
)} )}
</> </>
); );

View File

@ -2,7 +2,7 @@ import { FC, useMemo } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// types // types
import { TIssue } from "@plane/types"; import { TIssue, TIssueDescription } from "@plane/types";
// ui // ui
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui"; import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
// components // components
@ -23,6 +23,12 @@ import { IssueDetailsSidebar } from "./sidebar";
export type TIssueOperations = { export type TIssueOperations = {
fetch: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; fetch: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>; update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
updateDescription: (
workspaceSlug: string,
projectId: string,
issueId: string,
data: TIssueDescription
) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
archive?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; archive?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
restore?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; restore?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
@ -61,6 +67,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
issue: { getIssueById }, issue: { getIssueById },
fetchIssue, fetchIssue,
updateIssue, updateIssue,
updateIssueDescription,
removeIssue, removeIssue,
archiveIssue, archiveIssue,
addCycleToIssue, addCycleToIssue,
@ -117,6 +124,35 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = 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) => { remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
try { try {
if (is_archived) await removeArchivedIssue(workspaceSlug, projectId, issueId); if (is_archived) await removeArchivedIssue(workspaceSlug, projectId, issueId);

View File

@ -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<EditorRefApi>;
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<void>;
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<Uint8Array[]>([]);
// 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 ?? "<p></p>");
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
await updateIssueDescription(workspaceSlug.toString(), projectId.toString(), issueId.toString(), {
description_binary: yDocBinaryString,
description_html: issueDescription ?? "<p></p>",
});
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() ?? "<p></p>";
// 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,
};
};

View File

@ -1,5 +1,5 @@
// types // 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 { API_BASE_URL } from "@/helpers/common.helper";
import { APIService } from "@/services/api.service"; import { APIService } from "@/services/api.service";
// helpers // helpers
@ -68,6 +68,38 @@ export class InboxIssueService extends APIService {
}); });
} }
async fetchDescriptionBinary(workspaceSlug: string, projectId: string, inboxIssueId: string): Promise<any> {
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<any> {
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<void> { async destroy(workspaceSlug: string, projectId: string, inboxIssueId: string): Promise<void> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/`) return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/`)
.then((response) => response?.data) .then((response) => response?.data)

View File

@ -1,5 +1,12 @@
// types // types
import type { TIssue, IIssueDisplayProperties, TIssueLink, TIssueSubIssues, TIssueActivity } from "@plane/types"; import type {
TIssue,
IIssueDisplayProperties,
TIssueLink,
TIssueSubIssues,
TIssueActivity,
TIssueDescription,
} from "@plane/types";
// helpers // helpers
import { API_BASE_URL } from "@/helpers/common.helper"; import { API_BASE_URL } from "@/helpers/common.helper";
// services // services
@ -52,6 +59,32 @@ export class IssueService extends APIService {
}); });
} }
async fetchDescriptionBinary(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
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<any> {
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<TIssue[]> { async retrieveIssues(workspaceSlug: string, projectId: string, issueIds: string[]): Promise<TIssue[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/list/`, { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/list/`, {
params: { issues: issueIds.join(",") }, params: { issues: issueIds.join(",") },

View File

@ -1,7 +1,7 @@
import clone from "lodash/clone"; import clone from "lodash/clone";
import set from "lodash/set"; import set from "lodash/set";
import { makeObservable, observable, runInAction, action } from "mobx"; 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 // helpers
import { EInboxIssueStatus } from "@/helpers/inbox.helper"; import { EInboxIssueStatus } from "@/helpers/inbox.helper";
// services // services
@ -24,6 +24,7 @@ export interface IInboxIssueStore {
updateInboxIssueDuplicateTo: (issueId: string) => Promise<void>; // connecting the inbox issue to the project existing issue updateInboxIssueDuplicateTo: (issueId: string) => Promise<void>; // connecting the inbox issue to the project existing issue
updateInboxIssueSnoozeTill: (date: Date) => Promise<void>; // snooze the issue updateInboxIssueSnoozeTill: (date: Date) => Promise<void>; // snooze the issue
updateIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue updateIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue
updateIssueDescription: (issue: TIssueDescription) => Promise<void>; // updating the issue
updateProjectIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue updateProjectIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue
fetchIssueActivity: () => Promise<void>; // fetching the issue activity fetchIssueActivity: () => Promise<void>; // fetching the issue activity
} }
@ -44,7 +45,12 @@ export class InboxIssueStore implements IInboxIssueStore {
inboxIssueService; inboxIssueService;
issueService; 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.id = data.id;
this.status = data.status; this.status = data.status;
this.issue = data?.issue; this.issue = data?.issue;
@ -71,6 +77,7 @@ export class InboxIssueStore implements IInboxIssueStore {
updateInboxIssueDuplicateTo: action, updateInboxIssueDuplicateTo: action,
updateInboxIssueSnoozeTill: action, updateInboxIssueSnoozeTill: action,
updateIssue: action, updateIssue: action,
updateIssueDescription: action,
updateProjectIssue: action, updateProjectIssue: action,
fetchIssueActivity: action, fetchIssueActivity: action,
}); });
@ -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<TIssue>) => { updateProjectIssue = async (issue: Partial<TIssue>) => {
const inboxIssue = clone(this.issue); const inboxIssue = clone(this.issue);
try { try {

View File

@ -1,7 +1,7 @@
import { makeObservable } from "mobx"; import { makeObservable } from "mobx";
import { computedFn } from "mobx-utils"; import { computedFn } from "mobx-utils";
// types // types
import { TIssue } from "@plane/types"; import { TIssue, TIssueDescription } from "@plane/types";
// services // services
import { IssueArchiveService, IssueDraftService, IssueService } from "@/services/issue"; import { IssueArchiveService, IssueDraftService, IssueService } from "@/services/issue";
// types // types
@ -16,6 +16,12 @@ export interface IIssueStoreActions {
issueType?: "DEFAULT" | "DRAFT" | "ARCHIVED" issueType?: "DEFAULT" | "DRAFT" | "ARCHIVED"
) => Promise<TIssue>; ) => Promise<TIssue>;
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
updateIssueDescription: (
workspaceSlug: string,
projectId: string,
issueId: string,
data: TIssueDescription
) => Promise<void>;
removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
addCycleToIssue: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>; addCycleToIssue: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
@ -157,6 +163,19 @@ export class IssueStore implements IIssueStore {
await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId); 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) => removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>
this.rootIssueDetailStore.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); this.rootIssueDetailStore.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId);

View File

@ -4,7 +4,15 @@ import set from "lodash/set";
import update from "lodash/update"; import update from "lodash/update";
import { action, makeObservable, observable, runInAction, computed } from "mobx"; import { action, makeObservable, observable, runInAction, computed } from "mobx";
// types // types
import { TIssue, TGroupedIssues, TSubGroupedIssues, TLoader, TUnGroupedIssues, ViewFlags } from "@plane/types"; import {
TIssue,
TGroupedIssues,
TSubGroupedIssues,
TLoader,
TUnGroupedIssues,
ViewFlags,
TIssueDescription,
} from "@plane/types";
// helpers // helpers
import { issueCountBasedOnFilters } from "@/helpers/issue.helper"; import { issueCountBasedOnFilters } from "@/helpers/issue.helper";
// base class // base class
@ -26,6 +34,12 @@ export interface IProjectIssues {
fetchIssues: (workspaceSlug: string, projectId: string, loadType: TLoader) => Promise<TIssue[]>; fetchIssues: (workspaceSlug: string, projectId: string, loadType: TLoader) => Promise<TIssue[]>;
createIssue: (workspaceSlug: string, projectId: string, data: Partial<TIssue>) => Promise<TIssue>; createIssue: (workspaceSlug: string, projectId: string, data: Partial<TIssue>) => Promise<TIssue>;
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
updateIssueDescription: (
workspaceSlug: string,
projectId: string,
issueId: string,
data: TIssueDescription
) => Promise<void>;
removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
quickAddIssue: (workspaceSlug: string, projectId: string, data: TIssue) => Promise<TIssue>; quickAddIssue: (workspaceSlug: string, projectId: string, data: TIssue) => Promise<TIssue>;
@ -60,6 +74,7 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues {
fetchIssues: action, fetchIssues: action,
createIssue: action, createIssue: action,
updateIssue: action, updateIssue: action,
updateIssueDescription: action,
removeIssue: action, removeIssue: action,
archiveIssue: action, archiveIssue: action,
removeBulkIssues: 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) => { removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {
try { try {
await this.issueService.deleteIssue(workspaceSlug, projectId, issueId); 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 response = await this.createIssue(workspaceSlug, projectId, data);
const quickAddIssueIndex = this.issues[projectId].findIndex((_issueId) => _issueId === data.id); const quickAddIssueIndex = this.issues[projectId].findIndex((_issueId) => _issueId === data.id);
if (quickAddIssueIndex >= 0) { if (quickAddIssueIndex >= 0) {
runInAction(() => { runInAction(() => {
this.issues[projectId].splice(quickAddIssueIndex, 1); 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 //TODO: error handling needs to be improved for rare cases
if (data.cycle_id && data.cycle_id !== "") { 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) { if (data.module_ids && data.module_ids.length > 0) {
@ -264,7 +293,7 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues {
response.id, response.id,
data.module_ids, data.module_ids,
[] []
) );
} }
return response; return response;
} catch (error) { } catch (error) {