Skip to content

Commit 12a050e

Browse files
authored
[WIKI-538] chore: common description component (#7785)
* chore: common description input component * chore: replace existing description input components * fix: await for update calls * refactor: handle fallback values for description states and form data * fix: import statements * chore: add workspaceDetails check
1 parent fd542a8 commit 12a050e

File tree

8 files changed

+340
-235
lines changed

8 files changed

+340
-235
lines changed

apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
import { useParams, usePathname } from "next/navigation";
22
import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react";
3+
import type { LucideIcon } from "lucide-react";
4+
// plane imports
35
import {
46
EUserPermissionsLevel,
7+
EUserPermissions,
58
GROUPED_WORKSPACE_SETTINGS,
69
WORKSPACE_SETTINGS_CATEGORIES,
7-
EUserPermissions,
810
WORKSPACE_SETTINGS_CATEGORY,
911
} from "@plane/constants";
12+
import type { WORKSPACE_SETTINGS } from "@plane/constants";
13+
import type { ISvgIcons } from "@plane/propel/icons";
1014
import type { EUserWorkspaceRoles } from "@plane/types";
15+
// components
1116
import { SettingsSidebar } from "@/components/settings/sidebar";
17+
// hooks
1218
import { useUserPermissions } from "@/hooks/store/user";
19+
// plane web imports
1320
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
1421

15-
export const WORKSPACE_SETTINGS_ICONS = {
22+
export const WORKSPACE_SETTINGS_ICONS: Record<keyof typeof WORKSPACE_SETTINGS, LucideIcon | React.FC<ISvgIcons>> = {
1623
general: Building,
1724
members: Users,
1825
export: ArrowUpToLine,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./root";
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// plane imports
2+
import { Loader } from "@plane/ui";
3+
import { cn } from "@plane/utils";
4+
5+
type Props = {
6+
className?: string;
7+
};
8+
9+
export const DescriptionInputLoader: React.FC<Props> = (props) => {
10+
const { className } = props;
11+
12+
return (
13+
<Loader className={cn("space-y-2", className)}>
14+
<Loader.Item width="100%" height="26px" />
15+
<div className="flex items-center gap-2">
16+
<Loader.Item width="26px" height="26px" />
17+
<Loader.Item width="400px" height="26px" />
18+
</div>
19+
<div className="flex items-center gap-2">
20+
<Loader.Item width="26px" height="26px" />
21+
<Loader.Item width="400px" height="26px" />
22+
</div>
23+
<Loader.Item width="80%" height="26px" />
24+
<div className="flex items-center gap-2">
25+
<Loader.Item width="50%" height="26px" />
26+
</div>
27+
<div className="border-0.5 absolute bottom-2 right-3.5 z-10 flex items-center gap-2">
28+
<Loader.Item width="100px" height="26px" />
29+
<Loader.Item width="50px" height="26px" />
30+
</div>
31+
</Loader>
32+
);
33+
};
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
"use client";
2+
3+
import { useCallback, useEffect, useState, useRef } from "react";
4+
import { debounce } from "lodash-es";
5+
import { observer } from "mobx-react";
6+
import { Controller, useForm } from "react-hook-form";
7+
// plane imports
8+
import type { EditorRefApi, TExtensions } from "@plane/editor";
9+
import { useTranslation } from "@plane/i18n";
10+
import type { EFileAssetType, TNameDescriptionLoader } from "@plane/types";
11+
import { getDescriptionPlaceholderI18n } from "@plane/utils";
12+
// components
13+
import { RichTextEditor } from "@/components/editor/rich-text";
14+
// hooks
15+
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
16+
import { useWorkspace } from "@/hooks/store/use-workspace";
17+
// plane web services
18+
import { WorkspaceService } from "@/plane-web/services";
19+
// local imports
20+
import { DescriptionInputLoader } from "./loader";
21+
// services init
22+
const workspaceService = new WorkspaceService();
23+
24+
type TFormData = {
25+
id: string;
26+
description_html: string;
27+
};
28+
29+
type Props = {
30+
/**
31+
* @description Container class name, this will be used to add custom styles to the editor container
32+
*/
33+
containerClassName?: string;
34+
/**
35+
* @description Disabled, this will be used to disable the editor
36+
*/
37+
disabled?: boolean;
38+
/**
39+
* @description Disabled extensions, this will be used to disable the extensions in the editor
40+
*/
41+
disabledExtensions?: TExtensions[];
42+
/**
43+
* @description Editor ref, this will be used to imperatively attach editor related helper functions
44+
*/
45+
editorRef?: React.RefObject<EditorRefApi>;
46+
/**
47+
* @description Entity ID, this will be used for file uploads and as the unique identifier for the entity
48+
*/
49+
entityId: string;
50+
/**
51+
* @description File asset type, this will be used to upload the file to the editor
52+
*/
53+
fileAssetType: EFileAssetType;
54+
/**
55+
* @description Initial value, pass the actual description to initialize the editor
56+
*/
57+
initialValue: string | undefined;
58+
/**
59+
* @description Submit handler, the actual function which will be called when the form is submitted
60+
*/
61+
onSubmit: (value: string) => Promise<void>;
62+
/**
63+
* @description Placeholder, if not provided, the placeholder will be the default placeholder
64+
*/
65+
placeholder?: string | ((isFocused: boolean, value: string) => string);
66+
/**
67+
* @description projectId, if not provided, the entity will be considered as a workspace entity
68+
*/
69+
projectId?: string;
70+
/**
71+
* @description Set is submitting, use it to set the loading state of the form
72+
*/
73+
setIsSubmitting: (initialValue: TNameDescriptionLoader) => void;
74+
/**
75+
* @description SWR description, use it only if you want to sync changes in realtime(pseudo realtime)
76+
*/
77+
swrDescription?: string | null | undefined;
78+
/**
79+
* @description Workspace slug, this will be used to get the workspace details
80+
*/
81+
workspaceSlug: string;
82+
};
83+
84+
/**
85+
* @description DescriptionInput component for rich text editor with autosave functionality using debounce
86+
* The component also makes an API call to save the description on unmount
87+
*/
88+
export const DescriptionInput: React.FC<Props> = observer((props) => {
89+
const {
90+
containerClassName,
91+
disabled,
92+
disabledExtensions,
93+
editorRef,
94+
entityId,
95+
fileAssetType,
96+
initialValue,
97+
onSubmit,
98+
placeholder,
99+
projectId,
100+
setIsSubmitting,
101+
swrDescription,
102+
workspaceSlug,
103+
} = props;
104+
// states
105+
const [localDescription, setLocalDescription] = useState<TFormData>({
106+
id: entityId,
107+
description_html: initialValue?.trim() ?? "",
108+
});
109+
// ref to track if there are unsaved changes
110+
const hasUnsavedChanges = useRef(false);
111+
// store hooks
112+
const { getWorkspaceBySlug } = useWorkspace();
113+
const { uploadEditorAsset } = useEditorAsset();
114+
// derived values
115+
const workspaceDetails = getWorkspaceBySlug(workspaceSlug);
116+
// translation
117+
const { t } = useTranslation();
118+
// form info
119+
const { handleSubmit, reset, control } = useForm<TFormData>({
120+
defaultValues: {
121+
id: entityId,
122+
description_html: initialValue?.trim() ?? "",
123+
},
124+
});
125+
126+
// submit handler
127+
const handleDescriptionFormSubmit = useCallback(
128+
async (formData: TFormData) => {
129+
await onSubmit(formData.description_html);
130+
},
131+
[onSubmit]
132+
);
133+
134+
// reset form values
135+
useEffect(() => {
136+
if (!entityId) return;
137+
reset({
138+
id: entityId,
139+
description_html: initialValue?.trim() === "" ? "<p></p>" : (initialValue ?? "<p></p>"),
140+
});
141+
setLocalDescription({
142+
id: entityId,
143+
description_html: initialValue?.trim() === "" ? "<p></p>" : (initialValue ?? "<p></p>"),
144+
});
145+
// Reset unsaved changes flag when form is reset
146+
hasUnsavedChanges.current = false;
147+
}, [entityId, initialValue, reset]);
148+
149+
// ADDING handleDescriptionFormSubmit TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS
150+
// TODO: Verify the exhaustive-deps warning
151+
// eslint-disable-next-line react-hooks/exhaustive-deps
152+
const debouncedFormSave = useCallback(
153+
debounce(async () => {
154+
handleSubmit(handleDescriptionFormSubmit)()
155+
.catch((error) => console.error(`Failed to save description for ${entityId}:`, error))
156+
.finally(() => {
157+
setIsSubmitting("submitted");
158+
hasUnsavedChanges.current = false;
159+
});
160+
}, 1500),
161+
[entityId, handleSubmit]
162+
);
163+
164+
// Save on unmount if there are unsaved changes
165+
useEffect(
166+
() => () => {
167+
debouncedFormSave.cancel();
168+
169+
if (hasUnsavedChanges.current) {
170+
handleSubmit(handleDescriptionFormSubmit)()
171+
.catch((error) => {
172+
console.error("Failed to save description on unmount:", error);
173+
})
174+
.finally(() => {
175+
setIsSubmitting("submitted");
176+
hasUnsavedChanges.current = false;
177+
});
178+
}
179+
},
180+
// since we don't want to save on unmount if there are no unsaved changes, no deps are needed
181+
// eslint-disable-next-line react-hooks/exhaustive-deps
182+
[]
183+
);
184+
185+
if (!workspaceDetails) return null;
186+
187+
return (
188+
<>
189+
{localDescription.description_html ? (
190+
<Controller
191+
name="description_html"
192+
control={control}
193+
render={({ field: { onChange } }) => (
194+
<RichTextEditor
195+
editable={!disabled}
196+
ref={editorRef}
197+
id={entityId}
198+
disabledExtensions={disabledExtensions}
199+
initialValue={localDescription.description_html ?? "<p></p>"}
200+
value={swrDescription ?? null}
201+
workspaceSlug={workspaceSlug}
202+
workspaceId={workspaceDetails.id}
203+
projectId={projectId}
204+
dragDropEnabled
205+
onChange={(_description, description_html) => {
206+
setIsSubmitting("submitting");
207+
onChange(description_html);
208+
hasUnsavedChanges.current = true;
209+
debouncedFormSave();
210+
}}
211+
placeholder={placeholder ?? ((isFocused, value) => t(getDescriptionPlaceholderI18n(isFocused, value)))}
212+
searchMentionCallback={async (payload) =>
213+
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
214+
...payload,
215+
project_id: projectId,
216+
})
217+
}
218+
containerClassName={containerClassName}
219+
uploadFile={async (blockId, file) => {
220+
try {
221+
const { asset_id } = await uploadEditorAsset({
222+
blockId,
223+
data: {
224+
entity_identifier: entityId,
225+
entity_type: fileAssetType,
226+
},
227+
file,
228+
projectId,
229+
workspaceSlug,
230+
});
231+
return asset_id;
232+
} catch (error) {
233+
console.log("Error in uploading asset:", error);
234+
throw new Error("Asset upload failed. Please try again later.");
235+
}
236+
}}
237+
/>
238+
)}
239+
/>
240+
) : (
241+
<DescriptionInputLoader />
242+
)}
243+
</>
244+
);
245+
});

apps/web/core/components/inbox/content/issue-root.tsx

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,20 @@ import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
88
import type { EditorRefApi } from "@plane/editor";
99
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
1010
import type { TIssue, TNameDescriptionLoader } from "@plane/types";
11-
import { EInboxIssueSource } from "@plane/types";
12-
import { Loader } from "@plane/ui";
11+
import { EFileAssetType, EInboxIssueSource } from "@plane/types";
1312
import { getTextContent } from "@plane/utils";
1413
// components
1514
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
15+
import { DescriptionInput } from "@/components/editor/rich-text/description-input";
16+
import { DescriptionInputLoader } from "@/components/editor/rich-text/description-input/loader";
1617
import { IssueAttachmentRoot } from "@/components/issues/attachment";
17-
import { IssueDescriptionInput } from "@/components/issues/description-input";
1818
import type { TIssueOperations } from "@/components/issues/issue-detail";
1919
import { IssueActivity } from "@/components/issues/issue-detail/issue-activity";
2020
import { IssueReaction } from "@/components/issues/issue-detail/reactions";
2121
import { IssueTitleInput } from "@/components/issues/title-input";
2222
// helpers
23-
// hooks
2423
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
24+
// hooks
2525
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
2626
import { useMember } from "@/hooks/store/use-member";
2727
import { useProject } from "@/hooks/store/use-project";
@@ -152,7 +152,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
152152
payload: { id: issueId },
153153
});
154154
} catch (error) {
155-
console.log("Error in archiving issue:", error);
155+
console.error("Error in archiving issue:", error);
156156
captureError({
157157
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
158158
payload: { id: issueId },
@@ -192,21 +192,25 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
192192
/>
193193

194194
{loader === "issue-loading" ? (
195-
<Loader className="min-h-[6rem] rounded-md border border-custom-border-200">
196-
<Loader.Item width="100%" height="140px" />
197-
</Loader>
195+
<DescriptionInputLoader />
198196
) : (
199-
<IssueDescriptionInput
197+
<DescriptionInput
198+
containerClassName="-ml-3 border-none"
199+
disabled={!isEditable}
200200
editorRef={editorRef}
201-
workspaceSlug={workspaceSlug}
202-
projectId={issue.project_id}
203-
issueId={issue.id}
204-
swrIssueDescription={issue.description_html ?? "<p></p>"}
201+
entityId={issue.id}
202+
fileAssetType={EFileAssetType.ISSUE_DESCRIPTION}
205203
initialValue={issue.description_html ?? "<p></p>"}
206-
disabled={!isEditable}
207-
issueOperations={issueOperations}
204+
onSubmit={async (value) => {
205+
if (!issue.id || !issue.project_id) return;
206+
await issueOperations.update(workspaceSlug, issue.project_id, issue.id, {
207+
description_html: value,
208+
});
209+
}}
210+
projectId={issue.project_id}
208211
setIsSubmitting={(value) => setIsSubmitting(value)}
209-
containerClassName="-ml-3 border-none"
212+
swrDescription={issue.description_html ?? "<p></p>"}
213+
workspaceSlug={workspaceSlug}
210214
/>
211215
)}
212216

0 commit comments

Comments
 (0)