From 4c4ebd09889dfdc2413c0d70c46a8fa514569394 Mon Sep 17 00:00:00 2001
From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Date: Thu, 2 Jan 2025 12:32:36 +0530
Subject: [PATCH] dev: common title and description input component added
(#2064)
---
.../common/input/description-input.tsx | 176 ++++++++++++++++++
web/ee/components/common/input/index.ts | 2 +
.../components/common/input/title-input.tsx | 124 ++++++++++++
3 files changed, 302 insertions(+)
create mode 100644 web/ee/components/common/input/description-input.tsx
create mode 100644 web/ee/components/common/input/index.ts
create mode 100644 web/ee/components/common/input/title-input.tsx
diff --git a/web/ee/components/common/input/description-input.tsx b/web/ee/components/common/input/description-input.tsx
new file mode 100644
index 0000000000..b15ccff025
--- /dev/null
+++ b/web/ee/components/common/input/description-input.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import { FC, useCallback, useEffect, useState } from "react";
+import debounce from "lodash/debounce";
+import { observer } from "mobx-react";
+import { Controller, useForm } from "react-hook-form";
+// types
+import { TNameDescriptionLoader } from "@plane/types";
+import { EFileAssetType } from "@plane/types/src/enums";
+// ui
+import { Loader } from "@plane/ui";
+// components
+import { RichTextEditor, RichTextReadOnlyEditor } from "@/components/editor";
+// helpers
+import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
+// hooks
+import { useWorkspace } from "@/hooks/store";
+// plane web services
+import { WorkspaceService } from "@/plane-web/services";
+// services
+import { FileService } from "@/services/file.service";
+
+const workspaceService = new WorkspaceService();
+const fileService = new FileService();
+
+interface IFormData {
+ id: string;
+ description_html: string;
+}
+
+export type DescriptionInputProps = {
+ workspaceSlug: string;
+ projectId?: string;
+ itemId: string;
+ initialValue: string | undefined;
+ onSubmit: (value: string) => Promise;
+ fileAssetType: EFileAssetType;
+ placeholder?: string | ((isFocused: boolean, value: string) => string);
+ setIsSubmitting: (initialValue: TNameDescriptionLoader) => void;
+ swrDescription?: string | null | undefined;
+ containerClassName?: string;
+ disabled?: boolean;
+};
+
+export const DescriptionInput: FC = observer((props) => {
+ const {
+ workspaceSlug,
+ projectId,
+ itemId,
+ initialValue,
+ onSubmit,
+ fileAssetType,
+ placeholder,
+ setIsSubmitting,
+ swrDescription,
+ containerClassName,
+ disabled,
+ } = props;
+ // store hooks
+ const { getWorkspaceBySlug } = useWorkspace();
+
+ // states
+ const [localDescription, setLocalDescription] = useState({
+ id: itemId,
+ description_html: initialValue,
+ });
+
+ // form
+ const { handleSubmit, reset, control } = useForm({
+ defaultValues: {
+ id: itemId,
+ description_html: initialValue || "",
+ },
+ });
+
+ // handlers
+ const handleDescriptionFormSubmit = useCallback(
+ async (formData: IFormData) => {
+ await onSubmit(formData.description_html ?? "");
+ },
+ [onSubmit]
+ );
+
+ // computed values
+ const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string;
+
+ // reset form values
+ useEffect(() => {
+ if (!itemId) return;
+ reset({
+ id: itemId,
+ description_html: initialValue === "" ? "" : initialValue,
+ });
+ setLocalDescription({
+ id: itemId,
+ description_html: initialValue === "" ? "" : initialValue,
+ });
+ }, [initialValue, itemId, 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, itemId]
+ );
+
+ return (
+ <>
+ {localDescription.description_html ? (
+
+ !disabled ? (
+
"}
+ value={swrDescription ?? null}
+ workspaceSlug={workspaceSlug}
+ workspaceId={workspaceId}
+ projectId={projectId}
+ dragDropEnabled
+ onChange={(_description: object, description_html: string) => {
+ setIsSubmitting("submitting");
+ onChange(description_html);
+ debouncedFormSave();
+ }}
+ placeholder={
+ placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value)
+ }
+ searchMentionCallback={async (payload) =>
+ await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
+ ...payload,
+ project_id: projectId?.toString() ?? "",
+ })
+ }
+ containerClassName={containerClassName}
+ uploadFile={async (file) => {
+ const params = {
+ entity_identifier: itemId,
+ entity_type: fileAssetType,
+ };
+ try {
+ const uploadAsset = projectId
+ ? fileService.uploadProjectAsset(workspaceSlug, projectId, params, file)
+ : fileService.uploadWorkspaceAsset(workspaceSlug, params, file);
+ const { asset_id } = await uploadAsset;
+ return asset_id;
+ } catch (error) {
+ console.log("Error in uploading asset:", error);
+ throw new Error("Asset upload failed. Please try again later.");
+ }
+ }}
+ />
+ ) : (
+
+ )
+ }
+ />
+ ) : (
+
+
+
+ )}
+ >
+ );
+});
diff --git a/web/ee/components/common/input/index.ts b/web/ee/components/common/input/index.ts
new file mode 100644
index 0000000000..bbd1d55271
--- /dev/null
+++ b/web/ee/components/common/input/index.ts
@@ -0,0 +1,2 @@
+export * from "./description-input";
+export * from "./title-input";
diff --git a/web/ee/components/common/input/title-input.tsx b/web/ee/components/common/input/title-input.tsx
new file mode 100644
index 0000000000..b3902f3299
--- /dev/null
+++ b/web/ee/components/common/input/title-input.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { FC, useState, useEffect, useCallback } from "react";
+import { observer } from "mobx-react";
+// components
+import { TextArea } from "@plane/ui";
+// helpers
+import { cn } from "@/helpers/common.helper";
+// hooks
+import useDebounce from "@/hooks/use-debounce";
+
+export type TitleInputProps = {
+ value: string | undefined | null;
+ isSubmitting: "submitting" | "submitted" | "saved";
+ setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void;
+ onSubmit: (value: string) => Promise;
+ className?: string;
+ containerClassName?: string;
+ disabled?: boolean;
+};
+
+export const TitleInput: FC = observer((props) => {
+ const { value, isSubmitting, setIsSubmitting, onSubmit, className, containerClassName, disabled } = props;
+ // states
+ const [title, setTitle] = useState("");
+ const [isLengthVisible, setIsLengthVisible] = useState(false);
+ // hooks
+ const debouncedValue = useDebounce(title, 1500);
+
+ useEffect(() => {
+ if (value) setTitle(value);
+ }, [value]);
+
+ useEffect(() => {
+ const textarea = document.querySelector("#title-input");
+ if (debouncedValue && debouncedValue !== value) {
+ if (debouncedValue.trim().length > 0) {
+ onSubmit(debouncedValue).finally(() => {
+ setIsSubmitting("saved");
+ if (textarea && !textarea.matches(":focus")) {
+ const trimmedTitle = debouncedValue.trim();
+ if (trimmedTitle !== title) setTitle(trimmedTitle);
+ }
+ });
+ } else {
+ setTitle(value || "");
+ setIsSubmitting("saved");
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [debouncedValue]);
+
+ useEffect(() => {
+ const handleBlur = () => {
+ const trimmedTitle = title.trim();
+ if (trimmedTitle !== title && isSubmitting !== "submitting") {
+ if (trimmedTitle.length > 0) {
+ setTitle(trimmedTitle);
+ setIsSubmitting("submitting");
+ } else {
+ setTitle(value || "");
+ setIsSubmitting("saved");
+ }
+ }
+ };
+
+ const textarea = document.querySelector("#title-input");
+ if (textarea) {
+ textarea.addEventListener("blur", handleBlur);
+ }
+
+ return () => {
+ if (textarea) {
+ textarea.removeEventListener("blur", handleBlur);
+ }
+ };
+ }, [title, isSubmitting, setIsSubmitting, value]);
+
+ const handleTitleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ setIsSubmitting("submitting");
+ setTitle(e.target.value);
+ },
+ [setIsSubmitting]
+ );
+
+ if (disabled) return {title}
;
+
+ return (
+
+
+ {title?.length === 0 &&
Title is required}
+
+ );
+});