From 4e0e02522f10ccec989c681a5d8e3f8ea99d2b3d Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Thu, 14 Sep 2023 15:29:35 +0530 Subject: [PATCH 1/5] fix: changed payload for issue subgroups (#2181) * fix: sub groups in cycle module and my issues * fix: changed payload for issue subgroups --- apiserver/plane/utils/grouper.py | 88 +++++++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 6 deletions(-) diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py index 70762e7b4..9e134042a 100644 --- a/apiserver/plane/utils/grouper.py +++ b/apiserver/plane/utils/grouper.py @@ -39,14 +39,90 @@ def group_results(results_data, group_by, sub_group_by=False): for value in results_data: main_group_attribute = resolve_keys(sub_group_by, value) - if str(main_group_attribute) not in main_responsive_dict: - main_responsive_dict[str(main_group_attribute)] = {} group_attribute = resolve_keys(group_by, value) - if str(group_attribute) in main_responsive_dict: - main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value) + if isinstance(main_group_attribute, list) and not isinstance(group_attribute, list): + if len(main_group_attribute): + for attrib in main_group_attribute: + if str(attrib) not in main_responsive_dict: + main_responsive_dict[str(attrib)] = {} + if str(group_attribute) in main_responsive_dict[str(attrib)]: + main_responsive_dict[str(attrib)][str(group_attribute)].append(value) + else: + main_responsive_dict[str(attrib)][str(group_attribute)] = [] + main_responsive_dict[str(attrib)][str(group_attribute)].append(value) + else: + if str(None) not in main_responsive_dict: + main_responsive_dict[str(None)] = {} + + if str(group_attribute) in main_responsive_dict[str(None)]: + main_responsive_dict[str(None)][str(group_attribute)].append(value) + else: + main_responsive_dict[str(None)][str(group_attribute)] = [] + main_responsive_dict[str(None)][str(group_attribute)].append(value) + + elif isinstance(group_attribute, list) and not isinstance(main_group_attribute, list): + if str(main_group_attribute) not in main_responsive_dict: + main_responsive_dict[str(main_group_attribute)] = {} + if len(group_attribute): + for attrib in group_attribute: + if str(attrib) in main_responsive_dict[str(main_group_attribute)]: + main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value) + else: + main_responsive_dict[str(main_group_attribute)][str(attrib)] = [] + main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value) + else: + if str(None) in main_responsive_dict[str(main_group_attribute)]: + main_responsive_dict[str(main_group_attribute)][str(None)].append(value) + else: + main_responsive_dict[str(main_group_attribute)][str(None)] = [] + main_responsive_dict[str(main_group_attribute)][str(None)].append(value) + + elif isinstance(group_attribute, list) and isinstance(main_group_attribute, list): + if len(main_group_attribute): + for main_attrib in main_group_attribute: + if str(main_attrib) not in main_responsive_dict: + main_responsive_dict[str(main_attrib)] = {} + if len(group_attribute): + for attrib in group_attribute: + if str(attrib) in main_responsive_dict[str(main_attrib)]: + main_responsive_dict[str(main_attrib)][str(attrib)].append(value) + else: + main_responsive_dict[str(main_attrib)][str(attrib)] = [] + main_responsive_dict[str(main_attrib)][str(attrib)].append(value) + else: + if str(None) in main_responsive_dict[str(main_attrib)]: + main_responsive_dict[str(main_attrib)][str(None)].append(value) + else: + main_responsive_dict[str(main_attrib)][str(None)] = [] + main_responsive_dict[str(main_attrib)][str(None)].append(value) + else: + if str(None) not in main_responsive_dict: + main_responsive_dict[str(None)] = {} + if len(group_attribute): + for attrib in group_attribute: + if str(attrib) in main_responsive_dict[str(None)]: + main_responsive_dict[str(None)][str(attrib)].append(value) + else: + main_responsive_dict[str(None)][str(attrib)] = [] + main_responsive_dict[str(None)][str(attrib)].append(value) + else: + if str(None) in main_responsive_dict[str(None)]: + main_responsive_dict[str(None)][str(None)].append(value) + else: + main_responsive_dict[str(None)][str(None)] = [] + main_responsive_dict[str(None)][str(None)].append(value) else: - main_responsive_dict[str(main_group_attribute)][str(group_attribute)] = [] - main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value) + main_group_attribute = resolve_keys(sub_group_by, value) + group_attribute = resolve_keys(group_by, value) + + if str(main_group_attribute) not in main_responsive_dict: + main_responsive_dict[str(main_group_attribute)] = {} + + if str(group_attribute) in main_responsive_dict[str(main_group_attribute)]: + main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value) + else: + main_responsive_dict[str(main_group_attribute)][str(group_attribute)] = [] + main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value) return main_responsive_dict From a53b428bbd147a72d364b968fd0b95386fe26f1d Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Thu, 14 Sep 2023 15:38:11 +0530 Subject: [PATCH 2/5] chore: endpoints and history logs for issue draft (#2180) * chore: history logs for issue draft * fix: created seperated endpoints for issue drafts * fix: fixed the typo --- apiserver/plane/api/serializers/issue.py | 1 + apiserver/plane/api/urls.py | 2 + apiserver/plane/api/views/issue.py | 78 +++++++++++++++- .../plane/bgtasks/issue_activites_task.py | 89 ++++++++++++++++--- 4 files changed, 155 insertions(+), 15 deletions(-) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 5888b759c..113b54d0e 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -49,6 +49,7 @@ class IssueFlatSerializer(BaseSerializer): "target_date", "sequence_id", "sort_order", + "is_draft", ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 1d4a16eb6..2b83b0b94 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -1038,6 +1038,7 @@ urlpatterns = [ IssueDraftViewSet.as_view( { "get": "list", + "post": "create", } ), name="project-issue-draft", @@ -1047,6 +1048,7 @@ urlpatterns = [ IssueDraftViewSet.as_view( { "get": "retrieve", + "patch": "partial_update", "delete": "destroy", } ), diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index b6dcb88d5..16dce6f47 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -508,7 +508,7 @@ class IssueActivityEndpoint(BaseAPIView): issue_activities = ( IssueActivity.objects.filter(issue_id=issue_id) .filter( - ~Q(field__in=["comment", "vote", "reaction"]), + ~Q(field__in=["comment", "vote", "reaction", "draft"]), project__project_projectmember__member=self.request.user, ) .select_related("actor", "workspace", "issue", "project") @@ -2358,6 +2358,47 @@ class IssueDraftViewSet(BaseViewSet): serializer_class = IssueFlatSerializer model = Issue + + def perform_update(self, serializer): + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) + current_instance = ( + self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() + ) + if current_instance is not None: + issue_activity.delay( + type="issue_draft.activity.updated", + requested_data=requested_data, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("pk", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + IssueSerializer(current_instance).data, cls=DjangoJSONEncoder + ), + ) + + return super().perform_update(serializer) + + + def perform_destroy(self, instance): + current_instance = ( + self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() + ) + if current_instance is not None: + issue_activity.delay( + type="issue_draft.activity.deleted", + requested_data=json.dumps( + {"issue_id": str(self.kwargs.get("pk", None))} + ), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("pk", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + IssueSerializer(current_instance).data, cls=DjangoJSONEncoder + ), + ) + return super().perform_destroy(instance) + + def get_queryset(self): return ( Issue.objects.annotate( @@ -2383,6 +2424,7 @@ class IssueDraftViewSet(BaseViewSet): ) ) + @method_decorator(gzip_page) def list(self, request, slug, project_id): try: @@ -2492,6 +2534,40 @@ class IssueDraftViewSet(BaseViewSet): ) + def create(self, request, slug, project_id): + try: + project = Project.objects.get(pk=project_id) + + serializer = IssueCreateSerializer( + data=request.data, + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + "default_assignee_id": project.default_assignee_id, + }, + ) + + if serializer.is_valid(): + serializer.save(is_draft=True) + + # Track the issue + issue_activity.delay( + type="issue_draft.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(serializer.data.get("id", None)), + project_id=str(project_id), + current_instance=None, + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + except Project.DoesNotExist: + return Response( + {"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND + ) + + def retrieve(self, request, slug, project_id, pk=None): try: issue = Issue.objects.get( diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 2d13afc35..73fd54a7e 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -396,16 +396,16 @@ def track_assignees( def create_issue_activity( requested_data, current_instance, issue_id, project, actor, issue_activities ): - issue_activities.append( - IssueActivity( - issue_id=issue_id, - project=project, - workspace=project.workspace, - comment=f"created the issue", - verb="created", - actor=actor, + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"created the issue", + verb="created", + actor=actor, + ) ) - ) def track_estimate_points( @@ -518,11 +518,6 @@ def update_issue_activity( "closed_to": track_closed_to, } - requested_data = json.loads(requested_data) if requested_data is not None else None - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) - for key in requested_data: func = ISSUE_ACTIVITY_MAPPER.get(key, None) if func is not None: @@ -1095,6 +1090,69 @@ def delete_issue_relation_activity( ) +def create_draft_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"drafted the issue", + field="draft", + verb="created", + actor=actor, + ) + ) + + +def update_draft_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + if requested_data.get("is_draft") is not None and requested_data.get("is_draft") == False: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"created the issue", + verb="updated", + actor=actor, + ) + ) + else: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"updated the draft issue", + field="draft", + verb="updated", + actor=actor, + ) + ) + + + +def delete_draft_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + issue_activities.append( + IssueActivity( + project=project, + workspace=project.workspace, + comment=f"deleted the draft issue", + field="draft", + verb="deleted", + actor=actor, + ) + ) + # Receive message from room group @shared_task def issue_activity( @@ -1166,6 +1224,9 @@ def issue_activity( "comment_reaction.activity.deleted": delete_comment_reaction_activity, "issue_vote.activity.created": create_issue_vote_activity, "issue_vote.activity.deleted": delete_issue_vote_activity, + "issue_draft.activity.created": create_draft_issue_activity, + "issue_draft.activity.updated": update_draft_issue_activity, + "issue_draft.activity.deleted": delete_draft_issue_activity, } func = ACTIVITY_MAPPER.get(type) From 6659cfc8b03efe45053c3a0772d02a535b3bc32c Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Thu, 14 Sep 2023 16:05:31 +0530 Subject: [PATCH 3/5] fix: track events issue and env variables fixes (#2184) * fix: track event fixes * fix: adding env variables to trubo --- turbo.json | 15 +- web/components/workspace/sidebar-dropdown.tsx | 6 +- web/components/workspace/sidebar-menu.tsx | 3 - .../workspace/single-invitation.tsx | 2 +- web/lib/mobx/store-init.tsx | 2 - web/next.config.js | 1 + .../me/profile/preferences.tsx | 7 +- web/pages/api/slack-redirect.ts | 32 +- web/pages/api/track-event.ts | 22 +- web/pages/api/unsplash.ts | 11 +- web/services/ai.service.ts | 5 +- web/services/cycles.service.ts | 11 +- web/services/estimates.service.ts | 12 +- web/services/inbox.service.ts | 16 +- web/services/integration/csv.services.ts | 18 +- web/services/integration/github.service.ts | 8 +- web/services/integration/index.ts | 6 +- web/services/integration/jira.service.ts | 8 +- web/services/issues.service.ts | 142 +- web/services/modules.service.ts | 40 +- web/services/pages.service.ts | 29 +- web/services/project-publish.service.ts | 52 +- web/services/project.service.ts | 51 +- web/services/reaction.service.ts | 31 +- web/services/state.service.ts | 12 +- web/services/track-event.service.ts | 58 +- web/services/user.service.ts | 24 +- web/services/views.service.ts | 19 +- web/services/workspace.service.ts | 22 +- yarn.lock | 1209 +++++++++-------- 30 files changed, 903 insertions(+), 971 deletions(-) diff --git a/turbo.json b/turbo.json index 47b92f0db..7c02fdd7f 100644 --- a/turbo.json +++ b/turbo.json @@ -15,17 +15,18 @@ "NEXT_PUBLIC_UNSPLASH_ACCESS", "NEXT_PUBLIC_UNSPLASH_ENABLED", "NEXT_PUBLIC_TRACK_EVENTS", - "TRACKER_ACCESS_KEY", + "NEXT_PUBLIC_PLAUSIBLE_DOMAIN", "NEXT_PUBLIC_CRISP_ID", "NEXT_PUBLIC_ENABLE_SESSION_RECORDER", "NEXT_PUBLIC_SESSION_RECORDER_KEY", "NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS", - "NEXT_PUBLIC_SLACK_CLIENT_ID", - "NEXT_PUBLIC_SLACK_CLIENT_SECRET", - "NEXT_PUBLIC_SUPABASE_URL", - "NEXT_PUBLIC_SUPABASE_ANON_KEY", - "NEXT_PUBLIC_PLAUSIBLE_DOMAIN", - "NEXT_PUBLIC_DEPLOY_WITH_NGINX" + "NEXT_PUBLIC_DEPLOY_WITH_NGINX", + "SLACK_OAUTH_URL", + "SLACK_CLIENT_ID", + "SLACK_CLIENT_SECRET", + "JITSU_TRACKER_ACCESS_KEY", + "JITSU_TRACKER_HOST", + "UNSPLASH_ACCESS_KEY" ], "pipeline": { "build": { diff --git a/web/components/workspace/sidebar-dropdown.tsx b/web/components/workspace/sidebar-dropdown.tsx index 807a7de8b..f3bbc029b 100644 --- a/web/components/workspace/sidebar-dropdown.tsx +++ b/web/components/workspace/sidebar-dropdown.tsx @@ -1,8 +1,6 @@ import { Fragment } from "react"; - import { useRouter } from "next/router"; import Link from "next/link"; - // headless ui import { Menu, Transition } from "@headlessui/react"; // next-themes @@ -63,8 +61,6 @@ export const WorkspaceSidebarDropdown = () => { const { user, mutateUser } = useUser(); - const { collapsed: sidebarCollapse } = useThemeHook(); - const { setTheme } = useTheme(); const { setToastAlert } = useToast(); @@ -155,7 +151,7 @@ export const WorkspaceSidebarDropdown = () => { {workspaces.length > 0 ? ( workspaces.map((workspace) => ( - {({ active }) => ( + {() => ( 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;