From 57eb08c8a2accdd7c0f04cfd73a579d08ea16c36 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 29 Oct 2024 19:39:55 +0530 Subject: [PATCH] chore: code refactoring (#5928) * chore: de dupe code splitting * chore: code refactor --- packages/types/src/de-dupe.d.ts | 24 + packages/types/src/index.d.ts | 1 + .../de-dupe/duplicate-modal/index.ts | 1 + .../de-dupe/duplicate-modal/root.tsx | 16 + .../de-dupe/duplicate-popover/index.ts | 1 + .../de-dupe/duplicate-popover/root.tsx | 32 ++ web/ce/components/de-dupe/index.ts | 3 + .../de-dupe/issue-block/button-label.tsx | 13 + .../components/de-dupe/issue-block/index.ts | 1 + .../hooks/use-debounced-duplicate-issues.tsx | 11 + .../components/inbox/content/issue-root.tsx | 73 +++- .../inbox/modals/create-modal/create-root.tsx | 201 ++++++--- .../inbox/modals/create-modal/modal.tsx | 24 +- .../components/issues/archive-issue-modal.tsx | 10 +- .../components/issues/delete-issue-modal.tsx | 6 +- .../issues/issue-detail/main-content.tsx | 33 +- .../components/issues/issue-modal/base.tsx | 11 +- .../issues/issue-modal/draft-issue-layout.tsx | 6 + .../components/issues/issue-modal/form.tsx | 409 +++++++++++------- .../issues/peek-overview/issue-detail.tsx | 30 +- web/helpers/editor.helper.ts | 8 + 21 files changed, 664 insertions(+), 250 deletions(-) create mode 100644 packages/types/src/de-dupe.d.ts create mode 100644 web/ce/components/de-dupe/duplicate-modal/index.ts create mode 100644 web/ce/components/de-dupe/duplicate-modal/root.tsx create mode 100644 web/ce/components/de-dupe/duplicate-popover/index.ts create mode 100644 web/ce/components/de-dupe/duplicate-popover/root.tsx create mode 100644 web/ce/components/de-dupe/index.ts create mode 100644 web/ce/components/de-dupe/issue-block/button-label.tsx create mode 100644 web/ce/components/de-dupe/issue-block/index.ts create mode 100644 web/ce/hooks/use-debounced-duplicate-issues.tsx diff --git a/packages/types/src/de-dupe.d.ts b/packages/types/src/de-dupe.d.ts new file mode 100644 index 000000000..539a151a7 --- /dev/null +++ b/packages/types/src/de-dupe.d.ts @@ -0,0 +1,24 @@ +import { TIssuePriorities } from "./issues"; + +export type TDuplicateIssuePayload = { + title: string; + workspace_id: string; + issue_id?: string; + project_id?: string; + description_stripped?: string; +}; + +export type TDeDupeIssue = { + id: string; + type_id: string | null; + project_id: string; + sequence_id: number; + name: string; + priority: TIssuePriorities; + state_id: string; + created_by: string; +}; + +export type TDuplicateIssueResponse = { + dupes: TDeDupeIssue[]; +}; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index d637b0102..10e519700 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -2,6 +2,7 @@ export * from "./users"; export * from "./workspace"; export * from "./cycle"; export * from "./dashboard"; +export * from "./de-dupe"; export * from "./project"; export * from "./state"; export * from "./issues"; diff --git a/web/ce/components/de-dupe/duplicate-modal/index.ts b/web/ce/components/de-dupe/duplicate-modal/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/web/ce/components/de-dupe/duplicate-modal/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ce/components/de-dupe/duplicate-modal/root.tsx b/web/ce/components/de-dupe/duplicate-modal/root.tsx new file mode 100644 index 000000000..42284c6ed --- /dev/null +++ b/web/ce/components/de-dupe/duplicate-modal/root.tsx @@ -0,0 +1,16 @@ +"use-client"; + +import { FC } from "react"; +// types +import { TDeDupeIssue } from "@plane/types"; + +type TDuplicateModalRootProps = { + workspaceSlug: string; + issues: TDeDupeIssue[]; + handleDuplicateIssueModal: (value: boolean) => void; +}; + +export const DuplicateModalRoot: FC = (props) => { + const { workspaceSlug, issues, handleDuplicateIssueModal } = props; + return <>; +}; diff --git a/web/ce/components/de-dupe/duplicate-popover/index.ts b/web/ce/components/de-dupe/duplicate-popover/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/web/ce/components/de-dupe/duplicate-popover/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ce/components/de-dupe/duplicate-popover/root.tsx b/web/ce/components/de-dupe/duplicate-popover/root.tsx new file mode 100644 index 000000000..ad1c2e5c3 --- /dev/null +++ b/web/ce/components/de-dupe/duplicate-popover/root.tsx @@ -0,0 +1,32 @@ +"use client"; + +import React, { FC } from "react"; +import { observer } from "mobx-react"; +// types +import { TDeDupeIssue } from "@plane/types"; +import { TIssueOperations } from "@/components/issues"; + +type TDeDupeIssuePopoverRootProps = { + workspaceSlug: string; + projectId: string; + rootIssueId: string; + issues: TDeDupeIssue[]; + issueOperations: TIssueOperations; + disabled?: boolean; + renderDeDupeActionModals?: boolean; + isIntakeIssue?: boolean; +}; + +export const DeDupeIssuePopoverRoot: FC = observer((props) => { + const { + workspaceSlug, + projectId, + rootIssueId, + issues, + issueOperations, + disabled = false, + renderDeDupeActionModals = true, + isIntakeIssue = false, + } = props; + return <>; +}); diff --git a/web/ce/components/de-dupe/index.ts b/web/ce/components/de-dupe/index.ts new file mode 100644 index 000000000..83622b978 --- /dev/null +++ b/web/ce/components/de-dupe/index.ts @@ -0,0 +1,3 @@ +export * from "./duplicate-modal"; +export * from "./duplicate-popover"; +export * from "./issue-block"; diff --git a/web/ce/components/de-dupe/issue-block/button-label.tsx b/web/ce/components/de-dupe/issue-block/button-label.tsx new file mode 100644 index 000000000..303b0cec6 --- /dev/null +++ b/web/ce/components/de-dupe/issue-block/button-label.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { FC } from "react"; + +type TDeDupeIssueButtonLabelProps = { + isOpen: boolean; + buttonLabel: string; +}; + +export const DeDupeIssueButtonLabel: FC = (props) => { + const { isOpen, buttonLabel } = props; + return <>; +}; diff --git a/web/ce/components/de-dupe/issue-block/index.ts b/web/ce/components/de-dupe/issue-block/index.ts new file mode 100644 index 000000000..f50893b65 --- /dev/null +++ b/web/ce/components/de-dupe/issue-block/index.ts @@ -0,0 +1 @@ +export * from "./button-label"; diff --git a/web/ce/hooks/use-debounced-duplicate-issues.tsx b/web/ce/hooks/use-debounced-duplicate-issues.tsx new file mode 100644 index 000000000..f0325bc12 --- /dev/null +++ b/web/ce/hooks/use-debounced-duplicate-issues.tsx @@ -0,0 +1,11 @@ +import { TDeDupeIssue } from "@plane/types"; + +export const useDebouncedDuplicateIssues = ( + workspaceId: string | undefined, + projectId: string | undefined, + formData: { name: string | undefined; description_html?: string | undefined; issueId?: string | undefined } +) => { + const duplicateIssues: TDeDupeIssue[] = []; + + return { duplicateIssues }; +}; diff --git a/web/core/components/inbox/content/issue-root.tsx b/web/core/components/inbox/content/issue-root.tsx index 87c8ae6d2..2673245b0 100644 --- a/web/core/components/inbox/content/issue-root.tsx +++ b/web/core/components/inbox/content/issue-root.tsx @@ -17,10 +17,16 @@ import { TIssueOperations, IssueAttachmentRoot, } from "@/components/issues"; +// constants +import { ISSUE_ARCHIVED, ISSUE_DELETED } from "@/constants/event-tracker"; +// helpers +import { getTextContent } from "@/helpers/editor.helper"; // hooks -import { useEventTracker, useProjectInbox, useUser } from "@/hooks/store"; +import { useEventTracker, useIssueDetail, useProject, useProjectInbox, useUser } from "@/hooks/store"; import useReloadConfirmations from "@/hooks/use-reload-confirmation"; // store types +import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe"; +import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; type Props = { @@ -40,6 +46,8 @@ export const InboxIssueMainContent: React.FC = observer((props) => { const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); const { captureIssueEvent } = useEventTracker(); const { loader } = useProjectInbox(); + const { getProjectById } = useProject(); + const { removeIssue, archiveIssue } = useIssueDetail(); useEffect(() => { if (isSubmitting === "submitted") { @@ -52,7 +60,17 @@ export const InboxIssueMainContent: React.FC = observer((props) => { } }, [isSubmitting, setShowAlert, setIsSubmitting]); + // dervied values const issue = inboxIssue.issue; + const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined; + + // debounced duplicate issues swr + const { duplicateIssues } = useDebouncedDuplicateIssues(projectDetails?.workspace.toString(), projectId, { + name: issue?.name, + description_html: getTextContent(issue?.description_html), + issueId: issue?.id, + }); + if (!issue) return <>; const issueOperations: TIssueOperations = useMemo( @@ -63,7 +81,31 @@ export const InboxIssueMainContent: React.FC = observer((props) => { }, // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars, arrow-body-style remove: async (_workspaceSlug: string, _projectId: string, _issueId: string) => { - return; + try { + await removeIssue(workspaceSlug, projectId, _issueId); + setToast({ + title: "Success!", + type: TOAST_TYPE.SUCCESS, + message: "Issue deleted successfully", + }); + captureIssueEvent({ + eventName: ISSUE_DELETED, + payload: { id: _issueId, state: "SUCCESS", element: "Issue detail page" }, + path: pathname, + }); + } catch (error) { + console.log("Error in deleting issue:", error); + setToast({ + title: "Error!", + type: TOAST_TYPE.ERROR, + message: "Issue delete failed", + }); + captureIssueEvent({ + eventName: ISSUE_DELETED, + payload: { id: _issueId, state: "FAILED", element: "Issue detail page" }, + path: pathname, + }); + } }, update: async (_workspaceSlug: string, _projectId: string, _issueId: string, data: Partial) => { try { @@ -94,6 +136,23 @@ export const InboxIssueMainContent: React.FC = observer((props) => { }); } }, + archive: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + await archiveIssue(workspaceSlug, projectId, issueId); + captureIssueEvent({ + eventName: ISSUE_ARCHIVED, + payload: { id: issueId, state: "SUCCESS", element: "Issue details page" }, + path: pathname, + }); + } catch (error) { + console.log("Error in archiving issue:", error); + captureIssueEvent({ + eventName: ISSUE_ARCHIVED, + payload: { id: issueId, state: "FAILED", element: "Issue details page" }, + path: pathname, + }); + } + }, }), [inboxIssue] ); @@ -103,6 +162,16 @@ export const InboxIssueMainContent: React.FC = observer((props) => { return ( <>
+ {duplicateIssues.length > 0 && ( + + )} void; + isDuplicateModalOpen: boolean; + handleDuplicateIssueModal: (value: boolean) => void; }; export const defaultIssueData: Partial = { @@ -44,7 +49,7 @@ export const defaultIssueData: Partial = { }; export const InboxIssueCreateRoot: FC = observer((props) => { - const { workspaceSlug, projectId, handleModalClose } = props; + const { workspaceSlug, projectId, handleModalClose, isDuplicateModalOpen, handleDuplicateIssueModal } = props; // states const [uploadedAssetIds, setUploadedAssetIds] = useState([]); // router @@ -53,12 +58,15 @@ export const InboxIssueCreateRoot: FC = observer((props) // refs const descriptionEditorRef = useRef(null); const submitBtnRef = useRef(null); + const formRef = useRef(null); + const modalContainerRef = useRef(null); // hooks const { captureIssueEvent } = useEventTracker(); const { createInboxIssue } = useProjectInbox(); const { getWorkspaceBySlug } = useWorkspace(); const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id; const { isMobile } = usePlatformOS(); + const { getProjectById } = useProject(); // states const [createMore, setCreateMore] = useState(false); const [formSubmitting, setFormSubmitting] = useState(false); @@ -73,8 +81,17 @@ export const InboxIssueCreateRoot: FC = observer((props) [formData] ); + // derived values + const projectDetails = projectId ? getProjectById(projectId) : undefined; + const { getIndex } = getTabIndex(ETabIndices.INTAKE_ISSUE_FORM, isMobile); + // debounced duplicate issues swr + const { duplicateIssues } = useDebouncedDuplicateIssues(projectDetails?.workspace.toString(), projectId, { + name: formData?.name, + description_html: formData?.description_html, + }); + const handleEscKeyDown = (event: KeyboardEvent) => { if (descriptionEditorRef.current?.isEditorReadyToDiscard()) { handleModalClose(); @@ -90,6 +107,23 @@ export const InboxIssueCreateRoot: FC = observer((props) useKeypress("Escape", handleEscKeyDown); + useEffect(() => { + const formElement = formRef?.current; + const modalElement = modalContainerRef?.current; + + if (!formElement || !modalElement) return; + + const resizeObserver = new ResizeObserver(() => { + modalElement.style.maxHeight = `${formElement?.offsetHeight}px`; + }); + + resizeObserver.observe(formElement); + + return () => { + resizeObserver.disconnect(); + }; + }, [formRef, modalContainerRef]); + const handleFormSubmit = async (event: FormEvent) => { event.preventDefault(); @@ -165,74 +199,109 @@ export const InboxIssueCreateRoot: FC = observer((props) const isTitleLengthMoreThan255Character = formData?.name ? formData.name.length > 255 : false; + const shouldRenderDuplicateModal = isDuplicateModalOpen && duplicateIssues?.length > 0; + if (!workspaceSlug || !projectId || !workspaceId) return <>; return ( -
-
-

Create intake issue

-
- - submitBtnRef?.current?.click()} - onAssetUpload={(assetId) => setUploadedAssetIds((prev) => [...prev, assetId])} - /> - -
+
+
+ +
+
+

Create intake issue

+ {duplicateIssues?.length > 0 && ( + + )} +
+
+ + submitBtnRef?.current?.click()} + onAssetUpload={(assetId) => setUploadedAssetIds((prev) => [...prev, assetId])} + /> + +
+
+
+
setCreateMore((prevData) => !prevData)} + role="button" + tabIndex={getIndex("create_more")} + > + {}} size="sm" /> + Create more +
+
+ + +
+
+
-
+ {shouldRenderDuplicateModal && (
setCreateMore((prevData) => !prevData)} - role="button" - tabIndex={getIndex("create_more")} + ref={modalContainerRef} + className="relative flex flex-col gap-2.5 h-full px-3 py-4 rounded-lg shadow-xl bg-pi-50" + style={{ maxHeight: formRef?.current?.offsetHeight ? `${formRef.current.offsetHeight}px` : "436px" }} > - { }} size="sm" /> - Create more +
-
- - -
-
- + )} +
); }); diff --git a/web/core/components/inbox/modals/create-modal/modal.tsx b/web/core/components/inbox/modals/create-modal/modal.tsx index 7af26fbf9..6cfa154c8 100644 --- a/web/core/components/inbox/modals/create-modal/modal.tsx +++ b/web/core/components/inbox/modals/create-modal/modal.tsx @@ -1,4 +1,6 @@ -import { FC } from "react"; +"use-client"; + +import { FC, useState } from "react"; // ui import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; // components @@ -13,15 +15,29 @@ type TInboxIssueCreateModalRoot = { export const InboxIssueCreateModalRoot: FC = (props) => { const { workspaceSlug, projectId, modalState, handleModalClose } = props; + // states + const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false); + // handlers + const handleDuplicateIssueModal = (value: boolean) => setIsDuplicateModalOpen(value); return ( { + handleModalClose(); + setIsDuplicateModalOpen(false); + }} position={EModalPosition.TOP} - width={EModalWidth.XXXXL} + width={isDuplicateModalOpen ? EModalWidth.VIXL : EModalWidth.XXXXL} + className="!bg-transparent rounded-lg shadow-none transition-[width] ease-linear" > - + ); }; diff --git a/web/core/components/issues/archive-issue-modal.tsx b/web/core/components/issues/archive-issue-modal.tsx index 2c9a04ddf..4f944e26a 100644 --- a/web/core/components/issues/archive-issue-modal.tsx +++ b/web/core/components/issues/archive-issue-modal.tsx @@ -2,16 +2,16 @@ import { useState, Fragment } from "react"; import { Dialog, Transition } from "@headlessui/react"; -import { TIssue } from "@plane/types"; -// hooks +// types +import { TDeDupeIssue, TIssue } from "@plane/types"; +// ui import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +// hooks import { useProject } from "@/hooks/store"; import { useIssues } from "@/hooks/store/use-issues"; -// ui -// types type Props = { - data?: TIssue; + data?: TIssue | TDeDupeIssue; dataId?: string | null | undefined; handleClose: () => void; isOpen: boolean; diff --git a/web/core/components/issues/delete-issue-modal.tsx b/web/core/components/issues/delete-issue-modal.tsx index 2f61495ed..f19141234 100644 --- a/web/core/components/issues/delete-issue-modal.tsx +++ b/web/core/components/issues/delete-issue-modal.tsx @@ -2,19 +2,21 @@ import { useEffect, useState } from "react"; // types -import { TIssue } from "@plane/types"; +import { TDeDupeIssue, TIssue } from "@plane/types"; // ui import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui"; // constants import { PROJECT_ERROR_MESSAGES } from "@/constants/project"; // hooks import { useIssues, useProject, useUser, useUserPermissions } from "@/hooks/store"; +// plane-web import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; + type Props = { isOpen: boolean; handleClose: () => void; dataId?: string | null | undefined; - data?: TIssue; + data?: TIssue | TDeDupeIssue; isSubIssue?: boolean; onSubmit?: () => Promise; }; diff --git a/web/core/components/issues/issue-detail/main-content.tsx b/web/core/components/issues/issue-detail/main-content.tsx index 70ac2d250..fb4dbc1fc 100644 --- a/web/core/components/issues/issue-detail/main-content.tsx +++ b/web/core/components/issues/issue-detail/main-content.tsx @@ -13,12 +13,16 @@ import { IssueDetailWidgets, PeekOverviewProperties, } from "@/components/issues"; +// helpers +import { getTextContent } from "@/helpers/editor.helper"; // hooks -import { useIssueDetail, useUser } from "@/hooks/store"; +import { useIssueDetail, useProject, useUser } from "@/hooks/store"; import useReloadConfirmations from "@/hooks/use-reload-confirmation"; import useSize from "@/hooks/use-window-size"; // plane web components +import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe"; import { IssueTypeSwitcher } from "@/plane-web/components/issues"; +import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; // types import { TIssueOperations } from "./root"; @@ -42,8 +46,20 @@ export const IssueMainContent: React.FC = observer((props) => { issue: { getIssueById }, peekIssue, } = useIssueDetail(); + const { getProjectById } = useProject(); const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); + // derived values + const projectDetails = getProjectById(projectId); + const issue = issueId ? getIssueById(issueId) : undefined; + + // debounced duplicate issues swr + const { duplicateIssues } = useDebouncedDuplicateIssues(projectDetails?.workspace.toString(), projectDetails?.id, { + name: issue?.name, + description_html: getTextContent(issue?.description_html), + issueId: issue?.id, + }); + useEffect(() => { if (isSubmitting === "submitted") { setShowAlert(false); @@ -51,7 +67,6 @@ export const IssueMainContent: React.FC = observer((props) => { } else if (isSubmitting === "submitting") setShowAlert(true); }, [isSubmitting, setShowAlert, setIsSubmitting]); - const issue = issueId ? getIssueById(issueId) : undefined; if (!issue || !issue.project_id) return <>; const isPeekModeActive = Boolean(peekIssue); @@ -71,7 +86,19 @@ export const IssueMainContent: React.FC = observer((props) => {
- +
+ + {duplicateIssues?.length > 0 && ( + + )} +
= observer(( const [activeProjectId, setActiveProjectId] = useState(null); const [description, setDescription] = useState(undefined); const [uploadedAssetIds, setUploadedAssetIds] = useState([]); + const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false); // store hooks const { captureIssueEvent } = useEventTracker(); const { workspaceSlug, projectId: routerProjectId, cycleId, moduleId } = useParams(); @@ -139,6 +140,7 @@ export const CreateUpdateIssueModalBase: React.FC = observer(( setActiveProjectId(null); setChangesMade(null); onClose(); + handleDuplicateIssueModal(false); }; const handleCreateIssue = async ( @@ -325,6 +327,8 @@ export const CreateUpdateIssueModalBase: React.FC = observer(( const handleUpdateUploadedAssetIds = (assetId: string) => setUploadedAssetIds((prev) => [...prev, assetId]); + const handleDuplicateIssueModal = (value: boolean) => setIsDuplicateModalOpen(value); + // don't open the modal if there are no projects if (!projectIdsWithCreatePermissions || projectIdsWithCreatePermissions.length === 0 || !activeProjectId) return null; @@ -333,7 +337,8 @@ export const CreateUpdateIssueModalBase: React.FC = observer(( isOpen={isOpen} handleClose={() => handleClose(true)} position={EModalPosition.TOP} - width={EModalWidth.XXXXL} + width={isDuplicateModalOpen ? EModalWidth.VIXL : EModalWidth.XXXXL} + className="!bg-transparent rounded-lg shadow-none transition-[width] ease-linear" > {withDraftIssueWrapper ? ( = observer(( onCreateMoreToggleChange={handleCreateMoreToggleChange} isDraft={isDraft} moveToIssue={moveToIssue} + isDuplicateModalOpen={isDuplicateModalOpen} + handleDuplicateIssueModal={handleDuplicateIssueModal} /> ) : ( = observer(( moveToIssue={moveToIssue} modalTitle={modalTitle} primaryButtonText={primaryButtonText} + isDuplicateModalOpen={isDuplicateModalOpen} + handleDuplicateIssueModal={handleDuplicateIssueModal} /> )} diff --git a/web/core/components/issues/issue-modal/draft-issue-layout.tsx b/web/core/components/issues/issue-modal/draft-issue-layout.tsx index 8146e6cb4..f95b6afeb 100644 --- a/web/core/components/issues/issue-modal/draft-issue-layout.tsx +++ b/web/core/components/issues/issue-modal/draft-issue-layout.tsx @@ -36,6 +36,8 @@ export interface DraftIssueProps { default: string; loading: string; }; + isDuplicateModalOpen: boolean; + handleDuplicateIssueModal: (isOpen: boolean) => void; } export const DraftIssueLayout: React.FC = observer((props) => { @@ -54,6 +56,8 @@ export const DraftIssueLayout: React.FC = observer((props) => { moveToIssue = false, modalTitle, primaryButtonText, + isDuplicateModalOpen, + handleDuplicateIssueModal, } = props; // states const [issueDiscardModal, setIssueDiscardModal] = useState(false); @@ -173,6 +177,8 @@ export const DraftIssueLayout: React.FC = observer((props) => { moveToIssue={moveToIssue} modalTitle={modalTitle} primaryButtonText={primaryButtonText} + isDuplicateModalOpen={isDuplicateModalOpen} + handleDuplicateIssueModal={handleDuplicateIssueModal} /> ); diff --git a/web/core/components/issues/issue-modal/form.tsx b/web/core/components/issues/issue-modal/form.tsx index 74808a003..f9dd9d06d 100644 --- a/web/core/components/issues/issue-modal/form.tsx +++ b/web/core/components/issues/issue-modal/form.tsx @@ -22,6 +22,7 @@ import { CreateLabelModal } from "@/components/labels"; import { ETabIndices } from "@/constants/tab-indices"; // helpers import { cn } from "@/helpers/common.helper"; +import { getTextContent } from "@/helpers/editor.helper"; import { getChangedIssuefields } from "@/helpers/issue.helper"; import { getTabIndex } from "@/helpers/tab-indices.helper"; // hooks @@ -30,7 +31,9 @@ import { useIssueDetail, useProject, useProjectState, useWorkspaceDraftIssues } import { usePlatformOS } from "@/hooks/use-platform-os"; import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties"; // plane web components +import { DeDupeIssueButtonLabel, DuplicateModalRoot } from "@/plane-web/components/de-dupe"; import { IssueAdditionalProperties, IssueTypeSelect } from "@/plane-web/components/issues/issue-modal"; +import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; const defaultValues: Partial = { project_id: "", @@ -66,6 +69,8 @@ export interface IssueFormProps { default: string; loading: string; }; + isDuplicateModalOpen: boolean; + handleDuplicateIssueModal: (isOpen: boolean) => void; } export const IssueFormRoot: FC = observer((props) => { @@ -86,6 +91,8 @@ export const IssueFormRoot: FC = observer((props) => { default: `${data?.id ? "Update" : isDraft ? "Save to Drafts" : "Save"}`, loading: `${data?.id ? "Updating" : "Saving"}`, }, + isDuplicateModalOpen, + handleDuplicateIssueModal, } = props; // states @@ -96,6 +103,8 @@ export const IssueFormRoot: FC = observer((props) => { // refs const editorRef = useRef(null); const submitBtnRef = useRef(null); + const formRef = useRef(null); + const modalContainerRef = useRef(null); // router const { workspaceSlug, projectId: routeProjectId } = useParams(); @@ -134,6 +143,9 @@ export const IssueFormRoot: FC = observer((props) => { watch: watch, }); + // derived values + const projectDetails = projectId ? getProjectById(projectId) : undefined; + const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile); //reset few fields on projectId change @@ -232,6 +244,16 @@ export const IssueFormRoot: FC = observer((props) => { else onChange(null); }; + // debounced duplicate issues swr + const { duplicateIssues } = useDebouncedDuplicateIssues( + projectDetails?.workspace.toString(), + projectId ?? undefined, + { + name: watch("name"), + description_html: getTextContent(watch("description_html")), + } + ); + // executing this useEffect when the parent_id coming from the component prop useEffect(() => { const parentId = watch("parent_id") || undefined; @@ -267,6 +289,27 @@ export const IssueFormRoot: FC = observer((props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDirty]); + useEffect(() => { + const formElement = formRef?.current; + const modalElement = modalContainerRef?.current; + + if (!formElement || !modalElement) return; + + const resizeObserver = new ResizeObserver(() => { + modalElement.style.maxHeight = `${formElement?.offsetHeight}px`; + }); + + resizeObserver.observe(formElement); + + return () => { + resizeObserver.disconnect(); + }; + }, [formRef, modalContainerRef]); + + // TODO: Remove this after the de-dupe feature is implemented + + const shouldRenderDuplicateModal = isDuplicateModalOpen && duplicateIssues?.length > 0; + return ( <> {projectId && ( @@ -280,175 +323,211 @@ export const IssueFormRoot: FC = observer((props) => { }} /> )} -
handleFormSubmit(data))}> -
-

{modalTitle}

- {/* Disable project selection if editing an issue */} -
- - {projectId && ( - - )} -
- {watch("parent_id") && selectedParentIssue && ( -
- -
- )} -
- -
-
-
4 && - "max-h-[45vh] overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-sm" - )} - > -
- - setValue<"description_html">("description_html", description_html) - } - setGptAssistantModal={setGptAssistantModal} - handleGptAssistantClose={() => reset(getValues())} - onAssetUpload={onAssetUpload} - onClose={onClose} - /> -
-
+
+ handleFormSubmit(data))} + className="flex flex-col w-full" > - {projectId && ( - - )} -
+
+

{modalTitle}

+
+
+ + {projectId && ( + + )} +
+ {duplicateIssues.length > 0 && ( + + )} +
+ {watch("parent_id") && selectedParentIssue && ( +
+ +
+ )} +
+ +
+
+
4 && + "max-h-[45vh] overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-sm" + )} + > +
+ + setValue<"description_html">("description_html", description_html) + } + setGptAssistantModal={setGptAssistantModal} + handleGptAssistantClose={() => reset(getValues())} + onAssetUpload={onAssetUpload} + onClose={onClose} + /> +
+
+ {projectId && ( + + )} +
+
+
+
+ +
+
+ {!data?.id && ( +
onCreateMoreToggleChange(!isCreateMoreToggleEnabled)} + onKeyDown={(e) => { + if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled); + }} + tabIndex={getIndex("create_more")} + role="button" + > + {}} size="sm" /> + Create more +
+ )} +
+ + + {moveToIssue && ( + + )} +
+
+
+
-
-
- +
-
- {!data?.id && ( -
onCreateMoreToggleChange(!isCreateMoreToggleEnabled)} - onKeyDown={(e) => { - if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled); - }} - tabIndex={getIndex("create_more")} - role="button" - > - {}} size="sm" /> - Create more -
- )} -
- - - {moveToIssue && ( - - )} -
-
-
- + )} +
); }); diff --git a/web/core/components/issues/peek-overview/issue-detail.tsx b/web/core/components/issues/peek-overview/issue-detail.tsx index 242ebfd0c..74aba71fd 100644 --- a/web/core/components/issues/peek-overview/issue-detail.tsx +++ b/web/core/components/issues/peek-overview/issue-detail.tsx @@ -1,14 +1,19 @@ +"use-client"; import { FC, useEffect } from "react"; import { observer } from "mobx-react"; // components import { IssueParentDetail, TIssueOperations } from "@/components/issues"; +// helpers +import { getTextContent } from "@/helpers/editor.helper"; // store hooks -import { useIssueDetail, useUser } from "@/hooks/store"; +import { useIssueDetail, useProject, useUser } from "@/hooks/store"; // hooks import useReloadConfirmations from "@/hooks/use-reload-confirmation"; // plane web components +import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe"; import { IssueTypeSwitcher } from "@/plane-web/components/issues"; // local components +import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues"; import { IssueDescriptionInput } from "../description-input"; import { IssueReaction } from "../issue-detail/reactions"; import { IssueTitleInput } from "../title-input"; @@ -31,6 +36,7 @@ export const PeekOverviewIssueDetails: FC = observer( const { issue: { getIssueById }, } = useIssueDetail(); + const { getProjectById } = useProject(); // hooks const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); @@ -45,7 +51,16 @@ export const PeekOverviewIssueDetails: FC = observer( } }, [isSubmitting, setShowAlert, setIsSubmitting]); + // derived values const issue = issueId ? getIssueById(issueId) : undefined; + const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined; + // debounced duplicate issues swr + const { duplicateIssues } = useDebouncedDuplicateIssues(projectDetails?.workspace.toString(), projectDetails?.id, { + name: issue?.name, + description_html: getTextContent(issue?.description_html), + issueId: issue?.id, + }); + if (!issue || !issue.project_id) return <>; const issueDescription = @@ -66,7 +81,18 @@ export const PeekOverviewIssueDetails: FC = observer( issueOperations={issueOperations} /> )} - +
+ + {duplicateIssues?.length > 0 && ( + + )} +
{ + if (!jsx) return ""; + + const div = document.createElement("div"); + div.innerHTML = jsx.toString(); + return div.textContent?.trim() ?? ""; +};