Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./root";
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// plane imports
import { Loader } from "@plane/ui";
import { cn } from "@plane/utils";

type Props = {
className?: string;
};

export const DescriptionInputLoader: React.FC<Props> = (props) => {
const { className } = props;

return (
<Loader className={cn("space-y-2", className)}>
<Loader.Item width="100%" height="26px" />
<div className="flex items-center gap-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="400px" height="26px" />
</div>
<div className="flex items-center gap-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="400px" height="26px" />
</div>
<Loader.Item width="80%" height="26px" />
<div className="flex items-center gap-2">
<Loader.Item width="50%" height="26px" />
</div>
<div className="border-0.5 absolute bottom-2 right-3.5 z-10 flex items-center gap-2">
<Loader.Item width="100px" height="26px" />
<Loader.Item width="50px" height="26px" />
</div>
</Loader>
);
};
243 changes: 243 additions & 0 deletions apps/web/core/components/editor/rich-text/description-input/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
"use client";

import { useCallback, useEffect, useState, useRef } from "react";
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// plane imports
import type { EditorRefApi, TExtensions } from "@plane/editor";
import { useTranslation } from "@plane/i18n";
import { EFileAssetType, type TNameDescriptionLoader } from "@plane/types";
import { getDescriptionPlaceholderI18n } from "@plane/utils";
// components
import { RichTextEditor } from "@/components/editor/rich-text";
// hooks
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
import { useWorkspace } from "@/hooks/store/use-workspace";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// local imports
import { DescriptionInputLoader } from "./loader";
// services init
const workspaceService = new WorkspaceService();

type TFormData = {
id: string;
description_html: string;
};

type Props = {
/**
* @description Container class name, this will be used to add custom styles to the editor container
*/
containerClassName?: string;
/**
* @description Disabled, this will be used to disable the editor
*/
disabled?: boolean;
/**
* @description Disabled extensions, this will be used to disable the extensions in the editor
*/
disabledExtensions?: TExtensions[];
/**
* @description Editor ref, this will be used to imperatively attach editor related helper functions
*/
editorRef?: React.RefObject<EditorRefApi>;
/**
* @description Entity ID, this will be used for file uploads and as the unique identifier for the entity
*/
entityId: string;
/**
* @description File asset type, this will be used to upload the file to the editor
*/
fileAssetType: EFileAssetType;
/**
* @description Initial value, pass the actual description to initialize the editor
*/
initialValue: string | undefined;
/**
* @description Submit handler, the actual function which will be called when the form is submitted
*/
onSubmit: (value: string) => Promise<void>;
/**
* @description Placeholder, if not provided, the placeholder will be the default placeholder
*/
placeholder?: string | ((isFocused: boolean, value: string) => string);
/**
* @description projectId, if not provided, the entity will be considered as a workspace entity
*/
projectId?: string;
/**
* @description Set is submitting, use it to set the loading state of the form
*/
setIsSubmitting: (initialValue: TNameDescriptionLoader) => void;
/**
* @description SWR description, use it only if you want to sync changes in realtime(pseudo realtime)
*/
swrDescription?: string | null | undefined;
/**
* @description Workspace slug, this will be used to get the workspace details
*/
workspaceSlug: string;
};

/**
* @description DescriptionInput component for rich text editor with autosave functionality using debounce
* The component also makes an API call to save the description on unmount
*/
export const DescriptionInput: React.FC<Props> = observer((props) => {
const {
containerClassName,
disabled,
disabledExtensions,
editorRef,
entityId,
fileAssetType,
initialValue,
onSubmit,
placeholder,
projectId,
setIsSubmitting,
swrDescription,
workspaceSlug,
} = props;
// states
const [localDescription, setLocalDescription] = useState<TFormData>({
id: entityId,
description_html: initialValue?.trim() ?? "",
});
// ref to track if there are unsaved changes
const hasUnsavedChanges = useRef(false);
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
const { uploadEditorAsset } = useEditorAsset();
// derived values
const workspaceDetails = getWorkspaceBySlug(workspaceSlug);
// translation
const { t } = useTranslation();
// form info
const { handleSubmit, reset, control } = useForm<TFormData>({
defaultValues: {
id: entityId,
description_html: initialValue?.trim() ?? "",
},
});

// submit handler
const handleDescriptionFormSubmit = useCallback(
async (formData: TFormData) => {
await onSubmit(formData.description_html);
},
[onSubmit]
);

// reset form values
useEffect(() => {
if (!entityId) return;
reset({
id: entityId,
description_html: initialValue?.trim() === "" ? "<p></p>" : (initialValue ?? "<p></p>"),
});
setLocalDescription({
id: entityId,
description_html: initialValue?.trim() === "" ? "<p></p>" : (initialValue ?? "<p></p>"),
});
// Reset unsaved changes flag when form is reset
hasUnsavedChanges.current = false;
}, [entityId, initialValue, 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)()
.catch((error) => console.error(`Failed to save description for ${entityId}:`, error))
.finally(() => {
setIsSubmitting("submitted");
hasUnsavedChanges.current = false;
});
}, 1500),
[entityId, handleSubmit]
);

// Save on unmount if there are unsaved changes
useEffect(
() => () => {
debouncedFormSave.cancel();

if (hasUnsavedChanges.current) {
handleSubmit(handleDescriptionFormSubmit)()
.catch((error) => {
console.error("Failed to save description on unmount:", error);
})
.finally(() => {
setIsSubmitting("submitted");
hasUnsavedChanges.current = false;
});
}
},
// since we don't want to save on unmount if there are no unsaved changes, no deps are needed
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
Comment on lines +164 to +183
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Unmount save uses stale closures and cancels only the initial debounced function.

Tie cleanup to the latest debounced instance and use onSubmitRef; clear dirty only on success.

-  useEffect(
-    () => () => {
-      debouncedFormSave.cancel();
-
-      if (hasUnsavedChanges.current) {
-        handleSubmit(handleDescriptionFormSubmit)()
-          .catch((error) => {
-            console.error("Failed to save description on unmount:", error);
-          })
-          .finally(() => {
-            setIsSubmitting("submitted");
-            hasUnsavedChanges.current = false;
-          });
-      }
-    },
-    // since we don't want to save on unmount if there are no unsaved changes, no deps are needed
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-    []
-  );
+  useEffect(() => {
+    return () => {
+      debouncedFormSave.cancel();
+      if (hasUnsavedChanges.current) {
+        handleSubmit(async ({ description_html }) => {
+          await onSubmitRef.current(description_html);
+        })()
+          .then(() => {
+            setIsSubmitting("submitted");
+            hasUnsavedChanges.current = false;
+          })
+          .catch((error) => {
+            console.error("Failed to save description on unmount:", error);
+          });
+      }
+    };
+  }, [debouncedFormSave, handleSubmit]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Save on unmount if there are unsaved changes
useEffect(
() => () => {
debouncedFormSave.cancel();
if (hasUnsavedChanges.current) {
handleSubmit(handleDescriptionFormSubmit)()
.catch((error) => {
console.error("Failed to save description on unmount:", error);
})
.finally(() => {
setIsSubmitting("submitted");
hasUnsavedChanges.current = false;
});
}
},
// since we don't want to save on unmount if there are no unsaved changes, no deps are needed
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
// Save on unmount if there are unsaved changes
useEffect(() => {
return () => {
debouncedFormSave.cancel();
if (hasUnsavedChanges.current) {
handleSubmit(async ({ description_html }) => {
await onSubmitRef.current(description_html);
})()
.then(() => {
setIsSubmitting("submitted");
hasUnsavedChanges.current = false;
})
.catch((error) => {
console.error("Failed to save description on unmount:", error);
});
}
};
}, [debouncedFormSave, handleSubmit]);
🤖 Prompt for AI Agents
apps/web/core/components/editor/rich-text/description-input/root.tsx around
lines 164 to 183: the unmount cleanup currently cancels only the initial
debounced function and calls handleSubmit via a stale closure, and it clears the
dirty flag regardless of save success; fix by storing the latest
debouncedFormSave and submit handler in refs (e.g., debouncedFormSaveRef and
onSubmitRef), call debouncedFormSaveRef.current?.cancel() in the cleanup, invoke
the current submit function via onSubmitRef.current() (await it), and only set
hasUnsavedChanges.current = false inside the success branch (not in finally);
update relevant places to keep those refs in sync with the latest instances so
the cleanup uses the latest functions.


return (
<>
{localDescription.description_html ? (
<Controller
name="description_html"
control={control}
render={({ field: { onChange } }) => (
<RichTextEditor
editable={!disabled}
ref={editorRef}
id={entityId}
disabledExtensions={disabledExtensions}
initialValue={localDescription.description_html ?? "<p></p>"}
value={swrDescription ?? null}
workspaceSlug={workspaceSlug}
workspaceId={workspaceDetails?.id ?? ""}
projectId={projectId}
dragDropEnabled
onChange={(_description, description_html) => {
setIsSubmitting("submitting");
onChange(description_html);
hasUnsavedChanges.current = true;
debouncedFormSave();
}}
placeholder={placeholder ?? ((isFocused, value) => t(getDescriptionPlaceholderI18n(isFocused, value)))}
searchMentionCallback={async (payload) =>
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId,
})
}
containerClassName={containerClassName}
uploadFile={async (blockId, file) => {
try {
const { asset_id } = await uploadEditorAsset({
blockId,
data: {
entity_identifier: entityId,
entity_type: fileAssetType,
},
file,
projectId,
workspaceSlug,
});
return asset_id;
} catch (error) {
console.log("Error in uploading asset:", error);
throw new Error("Asset upload failed. Please try again later.");
}
}}
/>
)}
/>
) : (
<DescriptionInputLoader />
)}
</>
);
});
37 changes: 21 additions & 16 deletions apps/web/core/components/inbox/content/issue-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,21 @@ import { observer } from "mobx-react";
// plane imports
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import type { EditorRefApi } from "@plane/editor";
import { EInboxIssueSource, TIssue, TNameDescriptionLoader } from "@plane/types";
import { Loader, TOAST_TYPE, setToast } from "@plane/ui";
import { EFileAssetType, EInboxIssueSource, TIssue, TNameDescriptionLoader } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
import { getTextContent } from "@plane/utils";
// components
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
import { DescriptionInput } from "@/components/editor/rich-text/description-input";
import { DescriptionInputLoader } from "@/components/editor/rich-text/description-input/loader";
import { IssueAttachmentRoot } from "@/components/issues/attachment";
import { IssueDescriptionInput } from "@/components/issues/description-input";
import type { TIssueOperations } from "@/components/issues/issue-detail";
import { IssueActivity } from "@/components/issues/issue-detail/issue-activity";
import { IssueReaction } from "@/components/issues/issue-detail/reactions";
import { IssueTitleInput } from "@/components/issues/title-input";
// helpers
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
Expand Down Expand Up @@ -149,7 +150,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
payload: { id: issueId },
});
} catch (error) {
console.log("Error in archiving issue:", error);
console.error("Error in archiving issue:", error);
captureError({
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
payload: { id: issueId },
Expand Down Expand Up @@ -189,21 +190,25 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
/>

{loader === "issue-loading" ? (
<Loader className="min-h-[6rem] rounded-md border border-custom-border-200">
<Loader.Item width="100%" height="140px" />
</Loader>
<DescriptionInputLoader />
) : (
<IssueDescriptionInput
<DescriptionInput
containerClassName="-ml-3 border-none"
disabled={!isEditable}
editorRef={editorRef}
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
swrIssueDescription={issue.description_html ?? "<p></p>"}
entityId={issue.id}
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
initialValue={issue.description_html ?? "<p></p>"}
disabled={!isEditable}
issueOperations={issueOperations}
onSubmit={async (value) => {
if (!issue.id || !issue.project_id) return;
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, {
description_html: value,
});
}}
projectId={issue.project_id}
setIsSubmitting={(value) => setIsSubmitting(value)}
containerClassName="-ml-3 border-none"
swrDescription={issue.description_html ?? "<p></p>"}
workspaceSlug={workspaceSlug}
/>
)}

Expand Down
Loading
Loading