diff --git a/web/components/core/views/all-views.tsx b/web/components/core/views/all-views.tsx index 750c1a552..a83ca322b 100644 --- a/web/components/core/views/all-views.tsx +++ b/web/components/core/views/all-views.tsx @@ -49,7 +49,7 @@ type Props = { }; secondaryButton?: React.ReactNode; }; - handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; + handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit" | "updateDraft") => void; handleOnDragEnd: (result: DropResult) => Promise; openIssuesListModal: (() => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null; diff --git a/web/components/core/views/board-view/all-boards.tsx b/web/components/core/views/board-view/all-boards.tsx index ca0dd59a2..a172d466c 100644 --- a/web/components/core/views/board-view/all-boards.tsx +++ b/web/components/core/views/board-view/all-boards.tsx @@ -19,7 +19,7 @@ type Props = { disableUserActions: boolean; disableAddIssueOption?: boolean; dragDisabled: boolean; - handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; + handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit" | "updateDraft") => void; handleTrashBox: (isDragging: boolean) => void; openIssuesListModal?: (() => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null; diff --git a/web/components/core/views/issues-view.tsx b/web/components/core/views/issues-view.tsx index e0e7e8c94..be6e20271 100644 --- a/web/components/core/views/issues-view.tsx +++ b/web/components/core/views/issues-view.tsx @@ -19,7 +19,12 @@ import useIssuesProperties from "hooks/use-issue-properties"; import useProjectMembers from "hooks/use-project-members"; // components import { FiltersList, AllViews } from "components/core"; -import { CreateUpdateIssueModal, DeleteIssueModal, IssuePeekOverview } from "components/issues"; +import { + CreateUpdateIssueModal, + DeleteIssueModal, + IssuePeekOverview, + CreateUpdateDraftIssueModal, +} from "components/issues"; import { CreateUpdateViewModal } from "components/views"; // ui import { PrimaryButton, SecondaryButton } from "components/ui"; @@ -70,6 +75,9 @@ export const IssuesView: React.FC = ({ // trash box const [trashBox, setTrashBox] = useState(false); + // selected draft issue + const [selectedDraftIssue, setSelectedDraftIssue] = useState(null); + const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; @@ -106,6 +114,8 @@ export const IssuesView: React.FC = ({ [setDeleteIssueModal, setIssueToDelete] ); + const handleDraftIssueClick = (issue: any) => setSelectedDraftIssue(issue); + const handleOnDragEnd = useCallback( async (result: DropResult) => { setTrashBox(false); @@ -335,10 +345,11 @@ export const IssuesView: React.FC = ({ ); const handleIssueAction = useCallback( - (issue: IIssue, action: "copy" | "edit" | "delete") => { + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { if (action === "copy") makeIssueCopy(issue); else if (action === "edit") handleEditIssue(issue); else if (action === "delete") handleDeleteIssue(issue); + else if (action === "updateDraft") handleDraftIssueClick(issue); }, [makeIssueCopy, handleEditIssue, handleDeleteIssue] ); @@ -451,6 +462,27 @@ export const IssuesView: React.FC = ({ ...preloadedData, }} /> + setSelectedDraftIssue(null)} + data={ + selectedDraftIssue + ? { + ...selectedDraftIssue, + is_draft: true, + } + : null + } + fieldsToShow={[ + "name", + "description", + "label", + "assignee", + "priority", + "dueDate", + "priority", + ]} + /> setEditIssueModal(false)} diff --git a/web/components/core/views/list-view/all-lists.tsx b/web/components/core/views/list-view/all-lists.tsx index bb0a7c0fb..c84684d61 100644 --- a/web/components/core/views/list-view/all-lists.tsx +++ b/web/components/core/views/list-view/all-lists.tsx @@ -14,7 +14,7 @@ import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from type Props = { states: IState[] | undefined; addIssueToGroup: (groupTitle: string) => void; - handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; + handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit" | "updateDraft") => void; openIssuesListModal?: (() => void) | null; myIssueProjectId?: string | null; handleMyIssueOpen?: (issue: IIssue) => void; diff --git a/web/components/core/views/list-view/single-issue.tsx b/web/components/core/views/list-view/single-issue.tsx index ab5c080ca..aad6af6c8 100644 --- a/web/components/core/views/list-view/single-issue.tsx +++ b/web/components/core/views/list-view/single-issue.tsx @@ -61,6 +61,7 @@ type Props = { makeIssueCopy: () => void; removeIssue?: (() => void) | null; handleDeleteIssue: (issue: IIssue) => void; + handleDraftIssueSelect?: (issue: IIssue) => void; handleMyIssueOpen?: (issue: IIssue) => void; disableUserActions: boolean; user: ICurrentUserResponse | undefined; @@ -82,6 +83,7 @@ export const SingleListIssue: React.FC = ({ user, userAuth, viewProps, + handleDraftIssueSelect, }) => { // context menu const [contextMenu, setContextMenu] = useState(false); @@ -90,6 +92,7 @@ export const SingleListIssue: React.FC = ({ const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId, userId } = router.query; const isArchivedIssues = router.pathname.includes("archived-issues"); + const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues"; const { setToastAlert } = useToast(); @@ -178,6 +181,8 @@ export const SingleListIssue: React.FC = ({ const issuePath = isArchivedIssues ? `/${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}` + : isDraftIssues + ? `#` : `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`; const openPeekOverview = (issue: IIssue) => { @@ -247,7 +252,11 @@ export const SingleListIssue: React.FC = ({ diff --git a/web/components/core/views/list-view/single-list.tsx b/web/components/core/views/list-view/single-list.tsx index 0ee7388ac..3bf58a703 100644 --- a/web/components/core/views/list-view/single-list.tsx +++ b/web/components/core/views/list-view/single-list.tsx @@ -39,7 +39,7 @@ type Props = { currentState?: IState | null; groupTitle: string; addIssueToGroup: () => void; - handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; + handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit" | "updateDraft") => void; openIssuesListModal?: (() => void) | null; handleMyIssueOpen?: (issue: IIssue) => void; removeIssue: ((bridgeId: string, issueId: string) => void) | null; @@ -253,6 +253,7 @@ export const SingleList: React.FC = ({ editIssue={() => handleIssueAction(issue, "edit")} makeIssueCopy={() => handleIssueAction(issue, "copy")} handleDeleteIssue={() => handleIssueAction(issue, "delete")} + handleDraftIssueSelect={() => handleIssueAction(issue, "updateDraft")} handleMyIssueOpen={handleMyIssueOpen} removeIssue={() => { if (removeIssue !== null && issue.bridge_id) diff --git a/web/components/issues/confirm-issue-discard.tsx b/web/components/issues/confirm-issue-discard.tsx new file mode 100644 index 000000000..1294913cc --- /dev/null +++ b/web/components/issues/confirm-issue-discard.tsx @@ -0,0 +1,93 @@ +import React, { useState } from "react"; + +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// ui +import { SecondaryButton, PrimaryButton } from "components/ui"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + onDiscard: () => void; + onConfirm: () => Promise; +}; + +export const ConfirmIssueDiscard: React.FC = (props) => { + const { isOpen, handleClose, onDiscard, onConfirm } = props; + + const [isLoading, setIsLoading] = useState(false); + + const onClose = () => { + handleClose(); + setIsLoading(false); + }; + + const handleDeletion = async () => { + setIsLoading(true); + await onConfirm(); + setIsLoading(false); + }; + + return ( + + + +
+ + +
+
+ + +
+
+
+ + Draft Issue + +
+

+ Would you like to save this issue in drafts? +

+
+
+
+
+
+
+ Discard +
+
+ Cancel + + {isLoading ? "Saving..." : "Save Draft"} + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/web/components/issues/draft-issue-form.tsx b/web/components/issues/draft-issue-form.tsx new file mode 100644 index 000000000..f5818c587 --- /dev/null +++ b/web/components/issues/draft-issue-form.tsx @@ -0,0 +1,580 @@ +import React, { FC, useState, useEffect, useRef } from "react"; + +import { useRouter } from "next/router"; + +// react-hook-form +import { Controller, useForm } from "react-hook-form"; +// services +import aiService from "services/ai.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, +} from "components/issues/select"; +import { CreateStateModal } from "components/states"; +import { CreateLabelModal } from "components/labels"; +// ui +import { CustomMenu, Input, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui"; +import { TipTapEditor } from "components/tiptap"; +// icons +import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline"; +// types +import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types"; + +const defaultValues: Partial = { + project: "", + name: "", + description: { + type: "doc", + content: [ + { + type: "paragraph", + }, + ], + }, + description_html: "

", + estimate_point: null, + state: "", + parent: null, + priority: "none", + assignees: [], + assignees_list: [], + labels: [], + labels_list: [], + start_date: null, + target_date: null, +}; + +interface IssueFormProps { + handleFormSubmit: (formData: Partial) => Promise; + data?: Partial | null; + prePopulatedData?: Partial | null; + projectId: string; + setActiveProject: React.Dispatch>; + createMore: boolean; + setCreateMore: React.Dispatch>; + handleClose: () => void; + status: boolean; + user: ICurrentUserResponse | undefined; + fieldsToShow: ( + | "project" + | "name" + | "description" + | "state" + | "priority" + | "assignee" + | "label" + | "startDate" + | "dueDate" + | "estimate" + | "parent" + | "all" + )[]; +} + +export const DraftIssueForm: FC = (props) => { + const { + handleFormSubmit, + data, + prePopulatedData, + projectId, + setActiveProject, + createMore, + setCreateMore, + handleClose, + status, + user, + fieldsToShow, + } = 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 editorRef = useRef(null); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { setToastAlert } = useToast(); + + const { + register, + formState: { errors, isSubmitting }, + handleSubmit, + reset, + watch, + control, + getValues, + setValue, + setFocus, + } = useForm({ + defaultValues: prePopulatedData ?? defaultValues, + reValidateMode: "onChange", + }); + + const issueName = watch("name"); + + const onClose = () => { + handleClose(); + }; + + const handleCreateUpdateIssue = async ( + formData: Partial, + action: "saveDraft" | "createToNewIssue" = "saveDraft" + ) => { + await handleFormSubmit({ + ...formData, + is_draft: action === "saveDraft", + }); + + setGptAssistantModal(false); + + reset({ + ...defaultValues, + project: projectId, + description: { + type: "doc", + content: [ + { + type: "paragraph", + }, + ], + }, + description_html: "

", + }); + editorRef?.current?.clearEditor(); + }; + + const handleAiAssistance = async (response: string) => { + if (!workspaceSlug || !projectId) return; + + setValue("description", {}); + setValue("description_html", `${watch("description_html")}

${response}

`); + editorRef.current?.setEditorValue(`${watch("description_html")}`); + }; + + const handleAutoGenerateDescription = async () => { + if (!workspaceSlug || !projectId) return; + + setIAmFeelingLucky(true); + + aiService + .createGptTask( + workspaceSlug as string, + projectId as string, + { + 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(() => { + setFocus("name"); + + reset({ + ...defaultValues, + ...(prePopulatedData ?? {}), + ...(data ?? {}), + }); + }, [setFocus, prePopulatedData, reset, data]); + + // update projectId in form when projectId changes + useEffect(() => { + reset({ + ...getValues(), + project: projectId, + }); + }, [getValues, projectId, reset]); + + 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} + user={user} + /> + setLabelModal(false)} + projectId={projectId} + user={user} + onSuccess={(response) => { + setValue("labels", [...watch("labels"), response.id]); + setValue("labels_list", [...watch("labels_list"), response.id]); + }} + /> + + )} +
handleCreateUpdateIssue(formData, "createToNewIssue"))} + > +
+
+ {(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && ( + ( + { + onChange(val); + setActiveProject(val); + }} + /> + )} + /> + )} +

+ {status ? "Update" : "Create"} Issue +

+
+ {watch("parent") && + (fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && + selectedParentIssue && ( +
+
+ + + {selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id} + + + {selectedParentIssue.name.substring(0, 50)} + + { + setValue("parent", null); + setSelectedParentIssue(null); + }} + /> +
+
+ )} +
+
+ {(fieldsToShow.includes("all") || fieldsToShow.includes("name")) && ( +
+ +
+ )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && ( +
+
+ {issueName && issueName !== "" && ( + + )} + +
+ { + if (!value && !watch("description_html")) return <>; + + return ( + { + onChange(description_html); + setValue("description", description); + }} + /> + ); + }} + /> + { + 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={projectId} + /> +
+ )} +
+ {(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && ( + ( + + )} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && ( + ( + + )} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && ( + ( + + )} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && ( + ( + + )} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && ( +
+ ( + + )} + /> +
+ )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && ( +
+ ( + + )} + /> +
+ )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && ( +
+ ( + + )} + /> +
+ )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( + ( + setParentIssueListModalOpen(false)} + onChange={(issue) => { + onChange(issue.id); + setSelectedParentIssue(issue); + }} + projectId={projectId} + /> + )} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( + + {watch("parent") ? ( + <> + setParentIssueListModalOpen(true)} + > + Change parent issue + + setValue("parent", null)} + > + Remove parent issue + + + ) : ( + setParentIssueListModalOpen(true)} + > + Select Parent Issue + + )} + + )} +
+
+
+
+
+
setCreateMore((prevData) => !prevData)} + > + Create more + {}} size="md" /> +
+
+ Discard + handleCreateUpdateIssue(formData, "saveDraft"))} + > + {isSubmitting ? "Saving..." : "Save Draft"} + + {data && ( + + {isSubmitting ? "Saving..." : "Add Issue"} + + )} +
+
+
+ + ); +}; diff --git a/web/components/issues/draft-issue-modal.tsx b/web/components/issues/draft-issue-modal.tsx new file mode 100644 index 000000000..489a09d18 --- /dev/null +++ b/web/components/issues/draft-issue-modal.tsx @@ -0,0 +1,285 @@ +import React, { useEffect, useState } from "react"; + +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// services +import issuesService from "services/issues.service"; +// hooks +import useUser from "hooks/use-user"; +import useIssuesView from "hooks/use-issues-view"; +import useCalendarIssuesView from "hooks/use-calendar-issues-view"; +import useToast from "hooks/use-toast"; +import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; +import useProjects from "hooks/use-projects"; +import useMyIssues from "hooks/my-issues/use-my-issues"; +// components +import { DraftIssueForm } from "components/issues"; +// types +import type { IIssue } from "types"; +// fetch-keys +import { + PROJECT_ISSUES_DETAILS, + USER_ISSUE, + SUB_ISSUES, + PROJECT_ISSUES_LIST_WITH_PARAMS, + CYCLE_ISSUES_WITH_PARAMS, + MODULE_ISSUES_WITH_PARAMS, + VIEW_ISSUES, + PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS, +} from "constants/fetch-keys"; + +interface IssuesModalProps { + data?: IIssue | null; + handleClose: () => void; + isOpen: boolean; + isUpdatingSingleIssue?: boolean; + prePopulateData?: Partial; + fieldsToShow?: ( + | "project" + | "name" + | "description" + | "state" + | "priority" + | "assignee" + | "label" + | "startDate" + | "dueDate" + | "estimate" + | "parent" + | "all" + )[]; + onSubmit?: (data: Partial) => Promise | void; +} + +export const CreateUpdateDraftIssueModal: React.FC = ({ + data, + handleClose, + isOpen, + isUpdatingSingleIssue = false, + prePopulateData, + fieldsToShow = ["all"], + onSubmit, +}) => { + // states + const [createMore, setCreateMore] = useState(false); + const [activeProject, setActiveProject] = useState(null); + + const router = useRouter(); + const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + + const { displayFilters, params } = useIssuesView(); + const { params: calendarParams } = useCalendarIssuesView(); + const { ...viewGanttParams } = params; + const { params: spreadsheetParams } = useSpreadsheetIssuesView(); + + const { user } = useUser(); + const { projects } = useProjects(); + + const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); + + const { setToastAlert } = useToast(); + + if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string }; + if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string }; + if (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) + prePopulateData = { + ...prePopulateData, + assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""], + }; + + const onClose = () => { + handleClose(); + setActiveProject(null); + }; + + useEffect(() => { + // if modal is closed, reset active project to null + // and return to avoid activeProject being set to some other project + if (!isOpen) { + setActiveProject(null); + return; + } + + // if data is present, set active project to the project of the + // issue. This has more priority than the project in the url. + if (data && data.project) { + setActiveProject(data.project); + return; + } + + // if data is not present, set active project to the project + // in the url. This has the least priority. + if (projects && projects.length > 0 && !activeProject) + setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null); + }, [activeProject, data, projectId, projects, isOpen]); + + const calendarFetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams) + : viewId + ? VIEW_ISSUES(viewId.toString(), calendarParams) + : PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "", calendarParams); + + const spreadsheetFetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), spreadsheetParams) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams) + : viewId + ? VIEW_ISSUES(viewId.toString(), spreadsheetParams) + : PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "", spreadsheetParams); + + const ganttFetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString()) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString()) + : viewId + ? VIEW_ISSUES(viewId.toString(), viewGanttParams) + : PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? ""); + + const createIssue = async (payload: Partial) => { + if (!workspaceSlug || !activeProject || !user) return; + + await issuesService + .createDraftIssue(workspaceSlug as string, activeProject ?? "", payload, user) + .then(async () => { + mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); + mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); + + if (displayFilters.layout === "calendar") mutate(calendarFetchKey); + if (displayFilters.layout === "gantt_chart") + mutate(ganttFetchKey, { + start_target_date: true, + order_by: "sort_order", + }); + if (displayFilters.layout === "spreadsheet") mutate(spreadsheetFetchKey); + if (groupedIssues) mutateMyIssues(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", + }); + + if (payload.assignees_list?.some((assignee) => assignee === user?.id)) + mutate(USER_ISSUE(workspaceSlug as string)); + + if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }); + }); + + if (!createMore) onClose(); + }; + + const updateIssue = async (payload: Partial) => { + if (!user) return; + + await issuesService + .updateDraftIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload, user) + .then((res) => { + if (isUpdatingSingleIssue) { + mutate(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false); + } else { + if (displayFilters.layout === "calendar") mutate(calendarFetchKey); + if (displayFilters.layout === "spreadsheet") mutate(spreadsheetFetchKey); + if (payload.parent) mutate(SUB_ISSUES(payload.parent.toString())); + mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); + mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); + } + + if (!createMore) onClose(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue updated successfully.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be updated. Please try again.", + }); + }); + }; + + const handleFormSubmit = async (formData: Partial) => { + if (!workspaceSlug || !activeProject) return; + + const payload: Partial = { + ...formData, + assignees_list: formData.assignees ?? [], + labels_list: formData.labels ?? [], + description: formData.description ?? "", + description_html: formData.description_html ?? "

", + }; + + if (!data) await createIssue(payload); + else await updateIssue(payload); + + if (onSubmit) await onSubmit(payload); + }; + + if (!projects || projects.length === 0) return null; + + return ( + <> + + + +
+ + +
+
+ + + + + +
+
+
+
+ + ); +}; diff --git a/web/components/issues/form.tsx b/web/components/issues/form.tsx index ae8a01896..043210123 100644 --- a/web/components/issues/form.tsx +++ b/web/components/issues/form.tsx @@ -8,6 +8,7 @@ import { Controller, useForm } from "react-hook-form"; import aiService from "services/ai.service"; // hooks import useToast from "hooks/use-toast"; +import useLocalStorage from "hooks/use-local-storage"; // components import { GptAssistantModal } from "components/core"; import { ParentIssuesListModal } from "components/issues"; @@ -62,8 +63,11 @@ export interface IssueFormProps { createMore: boolean; setCreateMore: React.Dispatch>; handleClose: () => void; + handleDiscardClose: () => void; status: boolean; user: ICurrentUserResponse | undefined; + setIsConfirmDiscardOpen: React.Dispatch>; + handleFormDirty: (payload: Partial | null) => void; fieldsToShow: ( | "project" | "name" @@ -80,18 +84,21 @@ export interface IssueFormProps { )[]; } -export const IssueForm: FC = ({ - handleFormSubmit, - initialData, - projectId, - setActiveProject, - createMore, - setCreateMore, - handleClose, - status, - user, - fieldsToShow, -}) => { +export const IssueForm: FC = (props) => { + const { + handleFormSubmit, + initialData, + projectId, + setActiveProject, + createMore, + setCreateMore, + handleDiscardClose, + status, + user, + fieldsToShow, + handleFormDirty, + } = props; + const [stateModal, setStateModal] = useState(false); const [labelModal, setLabelModal] = useState(false); const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); @@ -100,6 +107,8 @@ export const IssueForm: FC = ({ const [gptAssistantModal, setGptAssistantModal] = useState(false); const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); + const { setValue: setValueInLocalStorage } = useLocalStorage("draftedIssue", null); + const editorRef = useRef(null); const router = useRouter(); @@ -109,7 +118,7 @@ export const IssueForm: FC = ({ const { register, - formState: { errors, isSubmitting }, + formState: { errors, isSubmitting, isDirty }, handleSubmit, reset, watch, @@ -124,6 +133,17 @@ export const IssueForm: FC = ({ const issueName = watch("name"); + const payload = { + name: getValues("name"), + description: getValues("description"), + }; + + useEffect(() => { + if (isDirty) handleFormDirty(payload); + else handleFormDirty(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(payload), isDirty]); + const handleCreateUpdateIssue = async (formData: Partial) => { await handleFormSubmit(formData); @@ -543,7 +563,15 @@ export const IssueForm: FC = ({ {}} size="md" />
- Discard + { + const data = JSON.stringify(getValues()); + setValueInLocalStorage(data); + handleDiscardClose(); + }} + > + Discard + {status ? isSubmitting diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index d0ab71e1c..65928a640 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -16,3 +16,6 @@ export * from "./sub-issues-list"; export * from "./label"; export * from "./issue-reaction"; export * from "./peek-overview"; +export * from "./confirm-issue-discard"; +export * from "./draft-issue-form"; +export * from "./draft-issue-modal"; diff --git a/web/components/issues/modal.tsx b/web/components/issues/modal.tsx index 2dfd4e2c4..d6ab43491 100644 --- a/web/components/issues/modal.tsx +++ b/web/components/issues/modal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState } from "react"; import { useRouter } from "next/router"; @@ -20,7 +20,7 @@ import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; import useProjects from "hooks/use-projects"; import useMyIssues from "hooks/my-issues/use-my-issues"; // components -import { IssueForm } from "components/issues"; +import { IssueForm, ConfirmIssueDiscard } from "components/issues"; // types import type { IIssue } from "types"; // fetch-keys @@ -35,6 +35,7 @@ import { MODULE_DETAILS, VIEW_ISSUES, INBOX_ISSUES, + PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS, } from "constants/fetch-keys"; // constants import { INBOX_ISSUE_SOURCE } from "constants/inbox"; @@ -73,6 +74,8 @@ export const CreateUpdateIssueModal: React.FC = ({ }) => { // states const [createMore, setCreateMore] = useState(false); + const [formDirtyState, setFormDirtyState] = useState(null); + const [showConfirmDiscard, setShowConfirmDiscard] = useState(false); const [activeProject, setActiveProject] = useState(null); const router = useRouter(); @@ -80,7 +83,7 @@ export const CreateUpdateIssueModal: React.FC = ({ const { displayFilters, params } = useIssuesView(); const { params: calendarParams } = useCalendarIssuesView(); - const { order_by, group_by, ...viewGanttParams } = params; + const { ...viewGanttParams } = params; const { params: inboxParams } = useInboxView(); const { params: spreadsheetParams } = useSpreadsheetIssuesView(); @@ -99,10 +102,23 @@ export const CreateUpdateIssueModal: React.FC = ({ assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""], }; - const onClose = useCallback(() => { + const onClose = () => { + if (formDirtyState !== null) { + setShowConfirmDiscard(true); + } else { + handleClose(); + setActiveProject(null); + } + }; + + const onDiscardClose = () => { handleClose(); setActiveProject(null); - }, [handleClose]); + }; + + const handleFormDirty = (data: any) => { + setFormDirtyState(data); + }; useEffect(() => { // if modal is closed, reset active project to null @@ -275,10 +291,50 @@ export const CreateUpdateIssueModal: React.FC = ({ }); }); - if (!createMore) onClose(); + if (!createMore) onDiscardClose(); + }; + + const createDraftIssue = async () => { + if (!workspaceSlug || !activeProject || !user) return; + + const payload: Partial = { + ...formDirtyState, + }; + + await issuesService + .createDraftIssue(workspaceSlug as string, activeProject ?? "", payload, user) + .then(() => { + mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); + if (groupedIssues) mutateMyIssues(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Draft Issue created successfully.", + }); + + handleClose(); + setActiveProject(null); + setFormDirtyState(null); + setShowConfirmDiscard(false); + + if (payload.assignees_list?.some((assignee) => assignee === user?.id)) + mutate(USER_ISSUE(workspaceSlug as string)); + + if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }); + }); }; const updateIssue = async (payload: Partial) => { + if (!user) return; + await issuesService .patchIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload, user) .then((res) => { @@ -294,7 +350,7 @@ export const CreateUpdateIssueModal: React.FC = ({ if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle); if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module); - if (!createMore) onClose(); + if (!createMore) onDiscardClose(); setToastAlert({ type: "success", @@ -331,49 +387,66 @@ export const CreateUpdateIssueModal: React.FC = ({ if (!projects || projects.length === 0) return null; return ( - - - -
- + <> + setShowConfirmDiscard(false)} + onConfirm={createDraftIssue} + onDiscard={() => { + handleClose(); + setActiveProject(null); + setFormDirtyState(null); + setShowConfirmDiscard(false); + }} + /> -
-
- - - - - + + + +
+ + +
+
+ + + + + +
-
-
-
+
+
+ ); }; diff --git a/web/components/issues/my-issues/my-issues-view.tsx b/web/components/issues/my-issues/my-issues-view.tsx index 7dc5c8d20..ced16b321 100644 --- a/web/components/issues/my-issues/my-issues-view.tsx +++ b/web/components/issues/my-issues/my-issues-view.tsx @@ -205,7 +205,7 @@ export const MyIssuesView: React.FC = ({ ); const handleIssueAction = useCallback( - (issue: IIssue, action: "copy" | "edit" | "delete") => { + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { if (action === "copy") makeIssueCopy(issue); else if (action === "edit") handleEditIssue(issue); else if (action === "delete") handleDeleteIssue(issue); diff --git a/web/components/profile/profile-issues-view.tsx b/web/components/profile/profile-issues-view.tsx index 619c0a083..b0337ecd4 100644 --- a/web/components/profile/profile-issues-view.tsx +++ b/web/components/profile/profile-issues-view.tsx @@ -204,7 +204,7 @@ export const ProfileIssuesView = () => { ); const handleIssueAction = useCallback( - (issue: IIssue, action: "copy" | "edit" | "delete") => { + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { if (action === "copy") makeIssueCopy(issue); else if (action === "edit") handleEditIssue(issue); else if (action === "delete") handleDeleteIssue(issue); diff --git a/web/components/project/single-sidebar-project.tsx b/web/components/project/single-sidebar-project.tsx index ebc8bc974..d43a82064 100644 --- a/web/components/project/single-sidebar-project.tsx +++ b/web/components/project/single-sidebar-project.tsx @@ -25,6 +25,7 @@ import { PhotoFilterOutlined, SettingsOutlined, } from "@mui/icons-material"; +import { PenSquare } from "lucide-react"; // helpers import { renderEmoji } from "helpers/emoji.helper"; // types @@ -288,6 +289,16 @@ export const SingleSidebarProject: React.FC = observer((props) => {
)} + + router.push(`/${workspaceSlug}/projects/${project?.id}/draft-issues`) + } + > +
+ + Draft Issues +
+
router.push(`/${workspaceSlug}/projects/${project?.id}/settings`)} > diff --git a/web/components/workspace/sidebar-quick-action.tsx b/web/components/workspace/sidebar-quick-action.tsx index 3f6982903..534bab729 100644 --- a/web/components/workspace/sidebar-quick-action.tsx +++ b/web/components/workspace/sidebar-quick-action.tsx @@ -1,47 +1,142 @@ -import React from "react"; +import React, { useState } from "react"; // ui import { Icon } from "components/ui"; +import { ChevronDown, PenSquare } from "lucide-react"; +// headless ui +import { Menu, Transition } from "@headlessui/react"; +// hooks +import useLocalStorage from "hooks/use-local-storage"; +// components +import { CreateUpdateDraftIssueModal } from "components/issues"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; export const WorkspaceSidebarQuickAction = () => { const store: any = useMobxStore(); - return ( -
- + const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false); - -
+
+ + + {storedValue &&
} + + {storedValue && ( +
+ + {({ open }) => ( + <> +
+ + + +
+ + +
+ + + +
+
+
+ + )} +
+
+ )} +
+ + +
+ ); }; diff --git a/web/constants/fetch-keys.ts b/web/constants/fetch-keys.ts index 14d34a96a..0f0643c66 100644 --- a/web/constants/fetch-keys.ts +++ b/web/constants/fetch-keys.ts @@ -140,6 +140,15 @@ export const PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS = (projectId: string, para return `PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS_${projectId.toUpperCase()}_${paramsKey}`; }; + +export const PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS = (projectId: string, params?: any) => { + if (!params) return `PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS${projectId.toUpperCase()}`; + + const paramsKey = paramsToKey(params); + + return `PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS${projectId.toUpperCase()}_${paramsKey}`; +}; + export const PROJECT_ISSUES_DETAILS = (issueId: string) => `PROJECT_ISSUES_DETAILS_${issueId.toUpperCase()}`; export const PROJECT_ISSUES_PROPERTIES = (projectId: string) => diff --git a/web/hooks/use-issues-view.tsx b/web/hooks/use-issues-view.tsx index 111b6971f..80cabda21 100644 --- a/web/hooks/use-issues-view.tsx +++ b/web/hooks/use-issues-view.tsx @@ -20,6 +20,7 @@ import { CYCLE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS, PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS, + PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS, PROJECT_ISSUES_LIST_WITH_PARAMS, STATES_LIST, VIEW_ISSUES, @@ -38,6 +39,7 @@ const useIssuesView = () => { const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId, viewId, archivedIssueId } = router.query; const isArchivedIssues = router.pathname.includes("archived-issues"); + const isDraftIssues = router.pathname.includes("draft-issues"); const params: any = { order_by: displayFilters?.order_by, @@ -72,6 +74,15 @@ const useIssuesView = () => { : null ); + const { data: draftIssues, mutate: mutateDraftIssues } = useSWR( + workspaceSlug && projectId && params && isDraftIssues && !archivedIssueId + ? PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(projectId as string, params) + : null, + workspaceSlug && projectId && params && isDraftIssues && !archivedIssueId + ? () => issuesService.getDraftIssues(workspaceSlug as string, projectId as string, params) + : null + ); + const { data: cycleIssues, mutate: mutateCycleIssues } = useSWR( workspaceSlug && projectId && cycleId && params ? CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params) @@ -151,6 +162,8 @@ const useIssuesView = () => { ? viewIssues : isArchivedIssues ? projectArchivedIssues + : isDraftIssues + ? draftIssues : projectIssues; if (Array.isArray(issuesToGroup)) return { allIssues: issuesToGroup }; @@ -169,6 +182,8 @@ const useIssuesView = () => { moduleId, viewId, isArchivedIssues, + isDraftIssues, + draftIssues, emptyStatesObject, ]); @@ -191,6 +206,8 @@ const useIssuesView = () => { ? mutateViewIssues : isArchivedIssues ? mutateProjectArchivedIssues + : isDraftIssues + ? mutateDraftIssues : mutateProjectIssues, filters, setFilters, diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/draft-issues/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/draft-issues/index.tsx new file mode 100644 index 000000000..0645ff264 --- /dev/null +++ b/web/pages/[workspaceSlug]/projects/[projectId]/draft-issues/index.tsx @@ -0,0 +1,73 @@ +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// services +import projectService from "services/project.service"; +// layouts +import { ProjectAuthorizationWrapper } from "layouts/auth-layout"; +// contexts +import { IssueViewContextProvider } from "contexts/issue-view.context"; +// helper +import { truncateText } from "helpers/string.helper"; +// components +import { IssuesFilterView, IssuesView } from "components/core"; +// ui +import { Icon } from "components/ui"; +import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; +// icons +import { X, PenSquare } from "lucide-react"; +// types +import type { NextPage } from "next"; +// fetch-keys +import { PROJECT_DETAILS } from "constants/fetch-keys"; + +const ProjectDraftIssues: NextPage = () => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { data: projectDetails } = useSWR( + workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null, + workspaceSlug && projectId + ? () => projectService.getProject(workspaceSlug as string, projectId as string) + : null + ); + + return ( + + + + + + } + right={ +
+ +
+ } + > +
+
+ +
+ +
+
+
+ ); +}; + +export default ProjectDraftIssues; diff --git a/web/services/issues.service.ts b/web/services/issues.service.ts index c3108f62a..8a4852ad0 100644 --- a/web/services/issues.service.ts +++ b/web/services/issues.service.ts @@ -617,6 +617,68 @@ class ProjectIssuesServices extends APIService { throw error?.response?.data; }); } + + async getDraftIssues(workspaceSlug: string, projectId: string, params?: any): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/`, { + params, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async createDraftIssue( + workspaceSlug: string, + projectId: string, + data: any, + user: ICurrentUserResponse + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async updateDraftIssue( + workspaceSlug: string, + projectId: string, + issueId: string, + data: any, + user: ICurrentUserResponse + ): Promise { + return this.patch( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/${issueId}/`, + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async deleteDraftIssue(workspaceSlug: string, projectId: string, issueId: string): Promise { + return this.delete( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/${issueId}/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async getDraftIssueById(workspaceSlug: string, projectId: string, issueId: string): Promise { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/${issueId}/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } } -export default new ProjectIssuesServices(); +const projectIssuesServices = new ProjectIssuesServices(); + +export default projectIssuesServices; diff --git a/web/types/issues.d.ts b/web/types/issues.d.ts index cc95dfa66..3e09872d4 100644 --- a/web/types/issues.d.ts +++ b/web/types/issues.d.ts @@ -118,6 +118,7 @@ export interface IIssue { issue_module: IIssueModule | null; labels: string[]; label_details: any[]; + is_draft: boolean; labels_list: string[]; links_list: IIssueLink[]; link_count: number;