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 ( +
+
+