mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
feat: issue description conllaboration
This commit is contained in:
parent
1573db24c5
commit
d8c4461497
@ -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",
|
||||||
|
78
packages/editor/core/src/hooks/use-conflict-free-editor.ts
Normal file
78
packages/editor/core/src/hooks/use-conflict-free-editor.ts
Normal 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;
|
||||||
|
};
|
@ -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
|
||||||
|
60
packages/editor/core/src/providers/collaboration-provider.ts
Normal file
60
packages/editor/core/src/providers/collaboration-provider.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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({
|
||||||
|
8
packages/types/src/issues/issue.d.ts
vendored
8
packages/types/src/issues/issue.d.ts
vendored
@ -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;
|
||||||
|
@ -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)}
|
||||||
|
@ -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
|
||||||
|
@ -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}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -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);
|
||||||
|
191
web/hooks/use-issue-description.ts
Normal file
191
web/hooks/use-issue-description.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
@ -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)
|
||||||
|
@ -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(",") },
|
||||||
|
@ -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 {
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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);
|
||||||
@ -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) {
|
||||||
|
Loading…
Reference in New Issue
Block a user