diff --git a/web/components/issues/confirm-issue-discard.tsx b/web/components/issues/confirm-issue-discard.tsx index eca797e64..a84e49c49 100644 --- a/web/components/issues/confirm-issue-discard.tsx +++ b/web/components/issues/confirm-issue-discard.tsx @@ -69,15 +69,15 @@ export const ConfirmIssueDiscard: React.FC = (props) => {
-
- -
diff --git a/web/components/issues/form.tsx b/web/components/issues/form.tsx index d2c91d47d..1eed2379e 100644 --- a/web/components/issues/form.tsx +++ b/web/components/issues/form.tsx @@ -134,7 +134,6 @@ export const IssueForm: FC = observer((props) => { const payload: Partial = { name: getValues("name"), - description: getValues("description"), state: getValues("state"), priority: getValues("priority"), assignees: getValues("assignees"), @@ -161,14 +160,6 @@ export const IssueForm: FC = observer((props) => { reset({ ...defaultValues, project: projectId, - description: { - type: "doc", - content: [ - { - type: "paragraph", - }, - ], - }, description_html: "

", }); editorRef?.current?.clearEditor(); @@ -177,7 +168,6 @@ export const IssueForm: FC = observer((props) => { const handleAiAssistance = async (response: string) => { if (!workspaceSlug || !projectId) return; - setValue("description", {}); setValue("description_html", `${watch("description_html")}

${response}

`); editorRef.current?.setEditorValue(`${watch("description_html")}`); }; @@ -392,10 +382,7 @@ export const IssueForm: FC = observer((props) => { : value } customClassName="min-h-[7rem] border-custom-border-100" - onChange={(description: Object, description_html: string) => { - onChange(description_html); - setValue("description", description); - }} + onChange={(description: Object, description_html: string) => onChange(description_html)} mentionHighlights={editorSuggestion.mentionHighlights} mentionSuggestions={editorSuggestion.mentionSuggestions} /> diff --git a/web/components/issues/issue-layouts/roots/project-layout-root.tsx b/web/components/issues/issue-layouts/roots/project-layout-root.tsx index 0539542da..7831f63d8 100644 --- a/web/components/issues/issue-layouts/roots/project-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/project-layout-root.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; @@ -15,8 +15,11 @@ import { ProjectEmptyState, } from "components/issues"; import { Spinner } from "@plane/ui"; +import { ProjectIssueModal } from "components/issues/issue-modal/modal"; export const ProjectLayoutRoot: React.FC = observer(() => { + const [isModalOpen, setIsModalOpen] = useState(true); + const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -41,25 +44,28 @@ export const ProjectLayoutRoot: React.FC = observer(() => { ); return ( -
- - {(activeLayout === "list" || activeLayout === "spreadsheet") && issueCount === 0 ? ( - - ) : ( -
- {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : activeLayout === "calendar" ? ( - - ) : activeLayout === "gantt_chart" ? ( - - ) : activeLayout === "spreadsheet" ? ( - - ) : null} -
- )} -
+ <> + {projectId && setIsModalOpen(false)} />} +
+ + {(activeLayout === "list" || activeLayout === "spreadsheet") && issueCount === 0 ? ( + + ) : ( +
+ {activeLayout === "list" ? ( + + ) : activeLayout === "kanban" ? ( + + ) : activeLayout === "calendar" ? ( + + ) : activeLayout === "gantt_chart" ? ( + + ) : activeLayout === "spreadsheet" ? ( + + ) : null} +
+ )} +
+ ); }); diff --git a/web/components/issues/issue-modal/draft-issue-layout.tsx b/web/components/issues/issue-modal/draft-issue-layout.tsx new file mode 100644 index 000000000..09d9ff290 --- /dev/null +++ b/web/components/issues/issue-modal/draft-issue-layout.tsx @@ -0,0 +1,99 @@ +import React, { useState } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// services +import { IssueDraftService } from "services/issue"; +// hooks +import useToast from "hooks/use-toast"; +// components +import { IssueFormRoot } from "components/issues/issue-modal/form"; +import { ConfirmIssueDiscard } from "components/issues"; +// types +import type { IIssue } from "types"; + +export interface DraftIssueProps { + changesMade: Partial | null; + data?: IIssue; + onChange: (formData: Partial | null) => void; + onClose: () => void; + onSubmit: (formData: Partial) => Promise; + projectId: string; +} + +const issueDraftService = new IssueDraftService(); + +export const DraftIssueLayout: React.FC = observer((props) => { + const { changesMade, data, onChange, onClose, onSubmit } = props; + + const [issueDiscardModal, setIssueDiscardModal] = useState(false); + + const { project: projectStore } = useMobxStore(); + const projects = projectStore.workspaceProjects; + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { setToastAlert } = useToast(); + + const handleClose = () => { + if (changesMade) setIssueDiscardModal(true); + else onClose(); + }; + + const handleCreateDraftIssue = async () => { + if (!changesMade || !workspaceSlug || !projectId) return; + + const payload = { ...changesMade }; + + await issueDraftService + .createDraftIssue(workspaceSlug.toString(), projectId.toString(), payload) + .then(() => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Draft Issue created successfully.", + }); + + onChange(null); + setIssueDiscardModal(false); + handleClose(); + }) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }) + ); + }; + + // don't open the modal if there are no projects + if (!projects || projects.length === 0) return null; + + // if project id is present in the router query, use that as the selected project id, otherwise use the first project id + const selectedProjectId = projectId ? projectId.toString() : projects[0].id; + + return ( + <> + setIssueDiscardModal(false)} + onConfirm={handleCreateDraftIssue} + onDiscard={() => { + onChange(null); + setIssueDiscardModal(false); + onClose(); + }} + /> + + + ); +}); diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx new file mode 100644 index 000000000..08aaf1aa0 --- /dev/null +++ b/web/components/issues/issue-modal/form.tsx @@ -0,0 +1,512 @@ +import React, { FC, useState, useRef, useEffect } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +import { Controller, useForm } from "react-hook-form"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// services +import { AIService } from "services/ai.service"; +import { FileService } from "services/file.service"; +// hooks +import useToast from "hooks/use-toast"; +// components +import { GptAssistantModal } from "components/core"; +import { ParentIssuesListModal } from "components/issues"; +import { + IssueAssigneeSelect, + IssueDateSelect, + IssueEstimateSelect, + IssueLabelSelect, + IssuePrioritySelect, + IssueProjectSelect, + IssueStateSelect, + IssueModuleSelect, + IssueCycleSelect, +} from "components/issues/select"; +import { CreateStateModal } from "components/states"; +import { CreateLabelModal } from "components/labels"; +// ui +import { Button, CustomMenu, Input, ToggleSwitch } from "@plane/ui"; +// icons +import { LayoutPanelTop, Sparkle, X } from "lucide-react"; +// types +import type { IIssue, ISearchIssueResponse } from "types"; +// components +import { RichTextEditorWithRef } from "@plane/rich-text-editor"; +import useEditorSuggestions from "hooks/use-editor-suggestions"; + +const defaultValues: Partial = { + project: "", + name: "", + description_html: "

", + estimate_point: null, + state: "", + parent: null, + priority: "none", + assignees: [], + labels: [], + cycle: null, + module: null, + start_date: null, + target_date: null, +}; + +export interface IssueFormProps { + data?: Partial; + onChange?: (formData: Partial | null) => void; + onClose: () => void; + onSubmit: (values: Partial) => Promise; + projectId: string; +} + +// services +const aiService = new AIService(); +const fileService = new FileService(); + +export const IssueFormRoot: FC = observer((props) => { + const { data, onChange, onClose, onSubmit, projectId } = props; + + const [stateModal, setStateModal] = useState(false); + const [labelModal, setLabelModal] = useState(false); + const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); + const [selectedParentIssue, setSelectedParentIssue] = useState(null); + + const [gptAssistantModal, setGptAssistantModal] = useState(false); + const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); + + const [createMore, setCreateMore] = useState(false); + + const editorRef = useRef(null); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { user: userStore } = useMobxStore(); + + const user = userStore.currentUser; + + const editorSuggestion = useEditorSuggestions(); + + const { setToastAlert } = useToast(); + + const { + formState: { errors, isDirty, isSubmitting }, + handleSubmit, + reset, + watch, + control, + getValues, + setValue, + } = useForm({ + defaultValues: { ...defaultValues, project: projectId, ...data }, + reValidateMode: "onChange", + }); + + const issueName = watch("name"); + + const handleFormSubmit = async (formData: Partial) => { + await onSubmit(formData); + + setGptAssistantModal(false); + + reset({ + ...defaultValues, + project: getValues("project"), + }); + editorRef?.current?.clearEditor(); + }; + + const handleAiAssistance = async (response: string) => { + if (!workspaceSlug || !projectId) return; + + setValue("description_html", `${watch("description_html")}

${response}

`); + editorRef.current?.setEditorValue(`${watch("description_html")}`); + }; + + const handleAutoGenerateDescription = async () => { + if (!workspaceSlug || !projectId || !user) return; + + setIAmFeelingLucky(true); + + aiService + .createGptTask( + workspaceSlug.toString(), + projectId.toString(), + { + prompt: issueName, + task: "Generate a proper description for this issue.", + }, + user + ) + .then((res) => { + if (res.response === "") + setToastAlert({ + type: "error", + title: "Error!", + message: + "Issue title isn't informative enough to generate the description. Please try with a different title.", + }); + else handleAiAssistance(res.response_html); + }) + .catch((err) => { + const error = err?.data?.error; + + if (err.status === 429) + setToastAlert({ + type: "error", + title: "Error!", + message: error || "You have reached the maximum number of requests of 50 requests per month per user.", + }); + else + setToastAlert({ + type: "error", + title: "Error!", + message: error || "Some error occurred. Please try again.", + }); + }) + .finally(() => setIAmFeelingLucky(false)); + }; + + useEffect(() => { + if (!onChange) return; + + if (isDirty) onChange(getValues()); + else onChange(null); + }, [isDirty, onChange, getValues]); + + const startDate = watch("start_date"); + const targetDate = watch("target_date"); + + const minDate = startDate ? new Date(startDate) : null; + minDate?.setDate(minDate.getDate()); + + const maxDate = targetDate ? new Date(targetDate) : null; + maxDate?.setDate(maxDate.getDate()); + + return ( + <> + {projectId && ( + <> + setStateModal(false)} projectId={projectId} /> + setLabelModal(false)} + projectId={projectId} + onSuccess={(response) => setValue("labels", [...watch("labels"), response.id])} + /> + + )} +
+
+
+ {/* Don't show project selection if editing an issue */} + {!data?.id && ( + ( + + )} + /> + )} +

+ {data?.id ? "Update" : "Create"} Issue +

+
+ {watch("parent") && selectedParentIssue && ( +
+
+ + + {selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id} + + {selectedParentIssue.name.substring(0, 50)} + { + setValue("parent", null); + setSelectedParentIssue(null); + }} + /> +
+
+ )} +
+
+ ( + + )} + /> +
+
+ {issueName && issueName !== "" && ( + + )} + +
+ ( +

" : value} + customClassName="min-h-[7rem] border-custom-border-100" + onChange={(description: Object, description_html: string) => onChange(description_html)} + mentionHighlights={editorSuggestion.mentionHighlights} + mentionSuggestions={editorSuggestion.mentionSuggestions} + /> + )} + /> + { + setGptAssistantModal(false); + // this is done so that the title do not reset after gpt popover closed + reset(getValues()); + }} + inset="top-2 left-0" + content="" + htmlContent={watch("description_html")} + onResponse={(response) => { + handleAiAssistance(response); + }} + projectId={watch("project")} + /> +
+
+ ( + + )} + /> + } + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + { + onChange(val); + }} + /> + )} + /> + ( + { + onChange(val); + }} + /> + )} + /> + } + /> + + {watch("parent") ? ( +
+ + + {selectedParentIssue && + `${selectedParentIssue.project__identifier}- + ${selectedParentIssue.sequence_id}`} + +
+ ) : ( +
+ + Add Parent +
+ )} + + } + placement="bottom-start" + > + {watch("parent") ? ( + <> + setParentIssueListModalOpen(true)}> + Change parent issue + + setValue("parent", null)}> + Remove parent issue + + + ) : ( + setParentIssueListModalOpen(true)}> + Select Parent Issue + + )} +
+ ( + setParentIssueListModalOpen(false)} + onChange={(issue) => { + onChange(issue.id); + setSelectedParentIssue(issue); + }} + projectId={watch("project")} + /> + )} + /> +
+
+
+
+
+
setCreateMore((prevData) => !prevData)} + > +
+ {}} size="sm" /> +
+ Create more +
+
+ + +
+
+
+ + ); +}); diff --git a/web/components/issues/issue-modal/modal.tsx b/web/components/issues/issue-modal/modal.tsx new file mode 100644 index 000000000..a1968cd7c --- /dev/null +++ b/web/components/issues/issue-modal/modal.tsx @@ -0,0 +1,176 @@ +import React, { useState } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +import { Dialog, Transition } from "@headlessui/react"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import useToast from "hooks/use-toast"; +import useLocalStorage from "hooks/use-local-storage"; +// components +import { DraftIssueLayout } from "./draft-issue-layout"; +// types +import type { IIssue } from "types"; + +export interface IssuesModalProps { + data?: IIssue; + isOpen: boolean; + onClose: () => void; +} + +export const ProjectIssueModal: React.FC = observer((props) => { + const { data, isOpen, onClose } = props; + + const [changesMade, setChangesMade] = useState | null>(null); + + const { + project: projectStore, + issueDetail: issueDetailStore, + cycleIssue: cycleIssueStore, + moduleIssue: moduleIssueStore, + } = useMobxStore(); + const projects = projectStore.workspaceProjects; + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { setToastAlert } = useToast(); + + const { setValue: setLocalStorageDraftIssue } = useLocalStorage("draftedIssue", {}); + + const handleClose = () => { + if (changesMade) { + const draftIssue = JSON.stringify(changesMade); + + setLocalStorageDraftIssue(draftIssue); + } + + onClose(); + }; + + const createIssue = async (payload: Partial): Promise => { + const issueProject = payload.project; + + if (!workspaceSlug || !issueProject) return null; + + await issueDetailStore + .createIssue(workspaceSlug.toString(), issueProject, payload) + .then(async (res) => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", + }); + + return res; + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }); + }); + + return null; + }; + + const updateIssue = async (payload: Partial): Promise => { + const issueProject = payload.project; + + if (!workspaceSlug || !issueProject || !data?.id) return null; + + await issueDetailStore + .updateIssue(workspaceSlug.toString(), issueProject, data.id, payload) + .then((res) => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue updated successfully.", + }); + + return { ...payload, ...res }; + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be updated. Please try again.", + }); + }); + + return null; + }; + + const handleFormSubmit = async (formData: Partial) => { + if (!workspaceSlug || !formData.project) return; + + const payload: Partial = { + ...formData, + description_html: formData.description_html ?? "

", + }; + + let res: IIssue | null = null; + if (!data?.id) res = await createIssue(payload); + else res = await updateIssue(payload); + + // add issue to cycle if cycle is selected, and cycle is different from current cycle + if (formData.cycle && res && (!data?.id || formData.cycle !== data?.cycle)) + cycleIssueStore.addIssueToCycle(workspaceSlug.toString(), formData.project, formData.cycle, [res.id]); + + // add issue to module if module is selected, and module is different from current module + if (formData.module && res && (!data?.id || formData.module !== data?.module)) + moduleIssueStore.addIssueToModule(workspaceSlug.toString(), formData.project, formData.module, [res.id]); + + handleClose(); + }; + + // don't open the modal if there are no projects + if (!projects || projects.length === 0) return null; + + // if project id is present in the router query, use that as the selected project id, otherwise use the first project id + const selectedProjectId = projectId ? projectId.toString() : projects[0].id; + + return ( + + + +
+ + +
+
+ + + setChangesMade(formData)} + onClose={handleClose} + onSubmit={handleFormSubmit} + projectId={selectedProjectId} + /> + + +
+
+
+
+ ); +}); diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx index 32c11345e..a683008bb 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx @@ -23,7 +23,6 @@ import { NextPageWithLayout } from "types/app"; import { PROJECT_ISSUES_ACTIVITY, ISSUE_DETAILS } from "constants/fetch-keys"; const defaultValues: Partial = { - description: "", description_html: "", estimate_point: null, issue_cycle: null, diff --git a/web/types/issues.d.ts b/web/types/issues.d.ts index 553a12ced..57fb09e5d 100644 --- a/web/types/issues.d.ts +++ b/web/types/issues.d.ts @@ -91,9 +91,8 @@ export interface IIssue { cycle: string | null; cycle_id: string | null; cycle_detail: ICycle | null; - description: any; - description_html: any; - description_stripped: any; + description_html: string; + description_stripped: string; estimate_point: number | null; id: string; // tempId is used for optimistic updates. It is not a part of the API response. @@ -116,7 +115,6 @@ export interface IIssue { project_detail: IProjectLite; sequence_id: number; sort_order: number; - sprints: string | null; start_date: string | null; state: string; state_detail: IState;