diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py
index 113b54d0e..57539f24c 100644
--- a/apiserver/plane/api/serializers/issue.py
+++ b/apiserver/plane/api/serializers/issue.py
@@ -293,12 +293,12 @@ class IssueLabelSerializer(BaseSerializer):
class IssueRelationSerializer(BaseSerializer):
- related_issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
+ issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
class Meta:
model = IssueRelation
fields = [
- "related_issue_detail",
+ "issue_detail",
"relation_type",
"related_issue",
"issue",
diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py
index 16dce6f47..79141d78d 100644
--- a/apiserver/plane/api/views/issue.py
+++ b/apiserver/plane/api/views/issue.py
@@ -53,6 +53,7 @@ from plane.api.serializers import (
CommentReactionSerializer,
IssueVoteSerializer,
IssueRelationSerializer,
+ RelatedIssueSerializer,
IssuePublicSerializer,
)
from plane.api.permissions import (
@@ -2085,9 +2086,10 @@ class IssueRelationViewSet(BaseViewSet):
def create(self, request, slug, project_id, issue_id):
try:
related_list = request.data.get("related_list", [])
+ relation = request.data.get("relation", None)
project = Project.objects.get(pk=project_id)
- issueRelation = IssueRelation.objects.bulk_create(
+ issue_relation = IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=related_issue["issue"],
@@ -2112,11 +2114,17 @@ class IssueRelationViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=None,
)
-
- return Response(
- IssueRelationSerializer(issueRelation, many=True).data,
- status=status.HTTP_201_CREATED,
- )
+
+ if relation == "blocking":
+ return Response(
+ RelatedIssueSerializer(issue_relation, many=True).data,
+ status=status.HTTP_201_CREATED,
+ )
+ else:
+ return Response(
+ IssueRelationSerializer(issue_relation, many=True).data,
+ status=status.HTTP_201_CREATED,
+ )
except IntegrityError as e:
if "already exists" in str(e):
return Response(
diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py
index 73fd54a7e..6385b1401 100644
--- a/apiserver/plane/bgtasks/issue_activites_task.py
+++ b/apiserver/plane/bgtasks/issue_activites_task.py
@@ -1048,6 +1048,25 @@ def create_issue_relation_activity(
)
if current_instance is None and requested_data.get("related_list") is not None:
for issue_relation in requested_data.get("related_list"):
+ if issue_relation.get("relation_type") == "blocked_by":
+ relation_type = "blocking"
+ else:
+ relation_type = issue_relation.get("relation_type")
+ issue = Issue.objects.get(pk=issue_relation.get("issue"))
+ issue_activities.append(
+ IssueActivity(
+ issue_id=issue_relation.get("related_issue"),
+ actor=actor,
+ verb="created",
+ old_value="",
+ new_value=f"{project.identifier}-{issue.sequence_id}",
+ field=relation_type,
+ project=project,
+ workspace=project.workspace,
+ comment=f'added {relation_type} relation',
+ old_identifier=issue_relation.get("issue"),
+ )
+ )
issue = Issue.objects.get(pk=issue_relation.get("related_issue"))
issue_activities.append(
IssueActivity(
@@ -1060,7 +1079,7 @@ def create_issue_relation_activity(
project=project,
workspace=project.workspace,
comment=f'added {issue_relation.get("relation_type")} relation',
- old_identifier=issue_relation.get("issue"),
+ old_identifier=issue_relation.get("related_issue"),
)
)
@@ -1073,21 +1092,40 @@ def delete_issue_relation_activity(
json.loads(current_instance) if current_instance is not None else None
)
if current_instance is not None and requested_data.get("related_list") is None:
- issue = Issue.objects.get(pk=current_instance.get("issue"))
- issue_activities.append(
- IssueActivity(
- issue_id=current_instance.get("issue"),
- actor=actor,
- verb="deleted",
- old_value=f"{project.identifier}-{issue.sequence_id}",
- new_value="",
- field=f'{current_instance.get("relation_type")}',
- project=project,
- workspace=project.workspace,
- comment=f'deleted the {current_instance.get("relation_type")} relation',
- old_identifier=current_instance.get("issue"),
+ if current_instance.get("relation_type") == "blocked_by":
+ relation_type = "blocking"
+ else:
+ relation_type = current_instance.get("relation_type")
+ issue = Issue.objects.get(pk=current_instance.get("issue"))
+ issue_activities.append(
+ IssueActivity(
+ issue_id=current_instance.get("related_issue"),
+ actor=actor,
+ verb="deleted",
+ old_value="",
+ new_value=f"{project.identifier}-{issue.sequence_id}",
+ field=relation_type,
+ project=project,
+ workspace=project.workspace,
+ comment=f'deleted {relation_type} relation',
+ old_identifier=current_instance.get("issue"),
+ )
+ )
+ issue = Issue.objects.get(pk=current_instance.get("related_issue"))
+ issue_activities.append(
+ IssueActivity(
+ issue_id=current_instance.get("issue"),
+ actor=actor,
+ verb="deleted",
+ old_value="",
+ new_value=f"{project.identifier}-{issue.sequence_id}",
+ field=f'{current_instance.get("relation_type")}',
+ project=project,
+ workspace=project.workspace,
+ comment=f'deleted {current_instance.get("relation_type")} relation',
+ old_identifier=current_instance.get("related_issue"),
+ )
)
- )
def create_draft_issue_activity(
diff --git a/apiserver/plane/db/migrations/0045_auto_20230915_0655.py b/apiserver/plane/db/migrations/0045_auto_20230915_0655.py
new file mode 100644
index 000000000..7bd907e29
--- /dev/null
+++ b/apiserver/plane/db/migrations/0045_auto_20230915_0655.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.2.3 on 2023-09-15 06:55
+
+from django.db import migrations
+
+def update_issue_activity(apps, schema_editor):
+ IssueActivityModel = apps.get_model("db", "IssueActivity")
+ updated_issue_activity = []
+ for obj in IssueActivityModel.objects.all():
+ if obj.field == "blocks":
+ obj.field = "blocked_by"
+ updated_issue_activity.append(obj)
+ IssueActivityModel.objects.bulk_update(updated_issue_activity, ["field"], batch_size=100)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('db', '0044_auto_20230913_0709'),
+ ]
+
+ operations = [
+ migrations.RunPython(update_issue_activity),
+ ]
diff --git a/turbo.json b/turbo.json
index 47b92f0db..59bbe741f 100644
--- a/turbo.json
+++ b/turbo.json
@@ -15,17 +15,20 @@
"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",
+ "NEXT_PUBLIC_POSTHOG_KEY",
+ "NEXT_PUBLIC_POSTHOG_HOST",
+ "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/core/filters/issues-view-filter.tsx b/web/components/core/filters/issues-view-filter.tsx
index 6354625dc..afb7eb2b0 100644
--- a/web/components/core/filters/issues-view-filter.tsx
+++ b/web/components/core/filters/issues-view-filter.tsx
@@ -52,10 +52,22 @@ const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
},
];
+const issueViewForDraftIssues: { type: TIssueViewOptions; Icon: any }[] = [
+ {
+ type: "list",
+ Icon: FormatListBulletedOutlined,
+ },
+ {
+ type: "kanban",
+ Icon: GridViewOutlined,
+ },
+];
+
export const IssuesFilterView: React.FC = () => {
const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues");
+ const isDraftIssues = router.pathname.includes("draft-issues");
const {
displayFilters,
@@ -75,7 +87,7 @@ export const IssuesFilterView: React.FC = () => {
return (
- {!isArchivedIssues && (
+ {!isArchivedIssues && !isDraftIssues && (
{issueViewOptions.map((option) => (
{
))}
)}
+ {isDraftIssues && (
+
+ {issueViewForDraftIssues.map((option) => (
+ {replaceUnderscoreIfSnakeCase(option.type)} View
+ }
+ position="bottom"
+ >
+
+
+ ))}
+
+ )}
{
diff --git a/web/components/core/views/all-views.tsx b/web/components/core/views/all-views.tsx
index 750c1a552..67804e5e6 100644
--- a/web/components/core/views/all-views.tsx
+++ b/web/components/core/views/all-views.tsx
@@ -50,6 +50,7 @@ type Props = {
secondaryButton?: React.ReactNode;
};
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
+ handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleOnDragEnd: (result: DropResult) => Promise;
openIssuesListModal: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
@@ -66,6 +67,7 @@ export const AllViews: React.FC = ({
dragDisabled = false,
emptyState,
handleIssueAction,
+ handleDraftIssueAction,
handleOnDragEnd,
openIssuesListModal,
removeIssue,
@@ -132,6 +134,7 @@ export const AllViews: React.FC = ({
states={states}
addIssueToGroup={addIssueToGroup}
handleIssueAction={handleIssueAction}
+ handleDraftIssueAction={handleDraftIssueAction}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
removeIssue={removeIssue}
myIssueProjectId={myIssueProjectId}
@@ -149,6 +152,7 @@ export const AllViews: React.FC = ({
disableAddIssueOption={disableAddIssueOption}
dragDisabled={dragDisabled}
handleIssueAction={handleIssueAction}
+ handleDraftIssueAction={handleDraftIssueAction}
handleTrashBox={handleTrashBox}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
myIssueProjectId={myIssueProjectId}
diff --git a/web/components/core/views/board-view/all-boards.tsx b/web/components/core/views/board-view/all-boards.tsx
index ca0dd59a2..ea5ebb2b1 100644
--- a/web/components/core/views/board-view/all-boards.tsx
+++ b/web/components/core/views/board-view/all-boards.tsx
@@ -20,6 +20,7 @@ type Props = {
disableAddIssueOption?: boolean;
dragDisabled: boolean;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
+ handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
@@ -37,6 +38,7 @@ export const AllBoards: React.FC = ({
disableAddIssueOption = false,
dragDisabled,
handleIssueAction,
+ handleDraftIssueAction,
handleTrashBox,
openIssuesListModal,
myIssueProjectId,
@@ -94,6 +96,7 @@ export const AllBoards: React.FC = ({
dragDisabled={dragDisabled}
groupTitle={singleGroup}
handleIssueAction={handleIssueAction}
+ handleDraftIssueAction={handleDraftIssueAction}
handleTrashBox={handleTrashBox}
openIssuesListModal={openIssuesListModal ?? null}
handleMyIssueOpen={handleMyIssueOpen}
diff --git a/web/components/core/views/board-view/single-board.tsx b/web/components/core/views/board-view/single-board.tsx
index 5b87f8aba..1981e1f7c 100644
--- a/web/components/core/views/board-view/single-board.tsx
+++ b/web/components/core/views/board-view/single-board.tsx
@@ -24,6 +24,7 @@ type Props = {
dragDisabled: boolean;
groupTitle: string;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
+ handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null;
handleMyIssueOpen?: (issue: IIssue) => void;
@@ -41,6 +42,7 @@ export const SingleBoard: React.FC = ({
disableAddIssueOption = false,
dragDisabled,
handleIssueAction,
+ handleDraftIssueAction,
handleTrashBox,
openIssuesListModal,
handleMyIssueOpen,
@@ -136,6 +138,16 @@ export const SingleBoard: React.FC = ({
editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
+ handleDraftIssueEdit={
+ handleDraftIssueAction
+ ? () => handleDraftIssueAction(issue, "edit")
+ : undefined
+ }
+ handleDraftIssueDelete={() =>
+ handleDraftIssueAction
+ ? handleDraftIssueAction(issue, "delete")
+ : undefined
+ }
handleTrashBox={handleTrashBox}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={() => {
@@ -155,7 +167,7 @@ export const SingleBoard: React.FC = ({
display: displayFilters?.order_by === "sort_order" ? "inline" : "none",
}}
>
- {provided.placeholder}
+ <>{provided.placeholder}>
{displayFilters?.group_by !== "created_by" && (
diff --git a/web/components/core/views/board-view/single-issue.tsx b/web/components/core/views/board-view/single-issue.tsx
index ffd4747d9..2c15f0a48 100644
--- a/web/components/core/views/board-view/single-issue.tsx
+++ b/web/components/core/views/board-view/single-issue.tsx
@@ -60,6 +60,8 @@ type Props = {
handleMyIssueOpen?: (issue: IIssue) => void;
removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
+ handleDraftIssueEdit?: () => void;
+ handleDraftIssueDelete?: () => void;
handleTrashBox: (isDragging: boolean) => void;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
@@ -79,6 +81,8 @@ export const SingleBoardIssue: React.FC = ({
removeIssue,
groupTitle,
handleDeleteIssue,
+ handleDraftIssueEdit,
+ handleDraftIssueDelete,
handleTrashBox,
disableUserActions,
user,
@@ -99,6 +103,8 @@ export const SingleBoardIssue: React.FC = ({
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
+ const isDraftIssue = router.pathname.includes("draft-issues");
+
const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback(
@@ -211,29 +217,47 @@ export const SingleBoardIssue: React.FC = ({
>
{!isNotAllowed && (
<>
-
+ {
+ if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
+ else editIssue();
+ }}
+ >
Edit issue
-
- Make a copy...
-
- handleDeleteIssue(issue)}>
+ {!isDraftIssue && (
+
+ Make a copy...
+
+ )}
+ {
+ if (isDraftIssue && handleDraftIssueDelete) handleDraftIssueDelete();
+ else handleDeleteIssue(issue);
+ }}
+ >
Delete issue
>
)}
-
- Copy issue link
-
-
-
- Open issue in new tab
+ {!isDraftIssue && (
+
+ Copy issue link
-
+ )}
+ {!isDraftIssue && (
+
+
+ Open issue in new tab
+
+
+ )}
= ({
}
>
-
+ {
+ if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
+ else editIssue();
+ }}
+ >
- {type !== "issue" && removeIssue && (
+ {type !== "issue" && removeIssue && !isDraftIssue && (
@@ -282,18 +311,25 @@ export const SingleBoardIssue: React.FC
= ({
)}
- handleDeleteIssue(issue)}>
+ {
+ if (isDraftIssue && handleDraftIssueDelete) handleDraftIssueDelete();
+ else handleDeleteIssue(issue);
+ }}
+ >
Delete issue
-
-
-
- Copy issue Link
-
-
+ {!isDraftIssue && (
+
+
+
+ Copy issue Link
+
+
+ )}
)}
@@ -308,7 +344,10 @@ export const SingleBoardIssue: React.FC = ({
diff --git a/web/components/core/views/issues-view.tsx b/web/components/core/views/issues-view.tsx
index e0e7e8c94..98b687207 100644
--- a/web/components/core/views/issues-view.tsx
+++ b/web/components/core/views/issues-view.tsx
@@ -19,7 +19,13 @@ 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,
+ DeleteDraftIssueModal,
+ IssuePeekOverview,
+ CreateUpdateDraftIssueModal,
+} from "components/issues";
import { CreateUpdateViewModal } from "components/views";
// ui
import { PrimaryButton, SecondaryButton } from "components/ui";
@@ -70,8 +76,13 @@ export const IssuesView: React.FC = ({
// trash box
const [trashBox, setTrashBox] = useState(false);
+ // selected draft issue
+ const [selectedDraftIssue, setSelectedDraftIssue] = useState(null);
+ const [selectedDraftForDelete, setSelectDraftForDelete] = useState(null);
+
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
+ const isDraftIssues = router.asPath.includes("draft-issues");
const { user } = useUserAuth();
@@ -106,6 +117,9 @@ export const IssuesView: React.FC = ({
[setDeleteIssueModal, setIssueToDelete]
);
+ const handleDraftIssueClick = useCallback((issue: any) => setSelectedDraftIssue(issue), []);
+ const handleDraftIssueDelete = useCallback((issue: any) => setSelectDraftForDelete(issue), []);
+
const handleOnDragEnd = useCallback(
async (result: DropResult) => {
setTrashBox(false);
@@ -343,6 +357,14 @@ export const IssuesView: React.FC = ({
[makeIssueCopy, handleEditIssue, handleDeleteIssue]
);
+ const handleDraftIssueAction = useCallback(
+ (issue: IIssue, action: "edit" | "delete") => {
+ if (action === "edit") handleDraftIssueClick(issue);
+ else if (action === "delete") handleDraftIssueDelete(issue);
+ },
+ [handleDraftIssueClick, handleDraftIssueDelete]
+ );
+
const removeIssueFromCycle = useCallback(
(bridgeId: string, issueId: string) => {
if (!workspaceSlug || !projectId || !cycleId) return;
@@ -451,6 +473,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)}
@@ -462,6 +505,11 @@ export const IssuesView: React.FC = ({
data={issueToDelete}
user={user}
/>
+ setSelectDraftForDelete(null)}
+ />
{areFiltersApplied && (
<>
@@ -518,23 +566,28 @@ export const IssuesView: React.FC = ({
displayFilters.group_by === "assignees"
}
emptyState={{
- title: cycleId
+ title: isDraftIssues
+ ? "Draft issues will appear here"
+ : cycleId
? "Cycle issues will appear here"
: moduleId
? "Module issues will appear here"
: "Project issues will appear here",
- description:
- "Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done.",
- primaryButton: {
- icon: ,
- text: "New Issue",
- onClick: () => {
- const e = new KeyboardEvent("keydown", {
- key: "c",
- });
- document.dispatchEvent(e);
- },
- },
+ description: isDraftIssues
+ ? "Draft issues are issues that are not yet created."
+ : "Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done.",
+ primaryButton: !isDraftIssues
+ ? {
+ icon: ,
+ text: "New Issue",
+ onClick: () => {
+ const e = new KeyboardEvent("keydown", {
+ key: "c",
+ });
+ document.dispatchEvent(e);
+ },
+ }
+ : undefined,
secondaryButton:
cycleId || moduleId ? (
= ({
}}
handleOnDragEnd={handleOnDragEnd}
handleIssueAction={handleIssueAction}
+ handleDraftIssueAction={handleDraftIssueAction}
openIssuesListModal={openIssuesListModal ?? null}
removeIssue={cycleId ? removeIssueFromCycle : moduleId ? removeIssueFromModule : null}
trashBox={trashBox}
diff --git a/web/components/core/views/list-view/all-lists.tsx b/web/components/core/views/list-view/all-lists.tsx
index bb0a7c0fb..58025b328 100644
--- a/web/components/core/views/list-view/all-lists.tsx
+++ b/web/components/core/views/list-view/all-lists.tsx
@@ -15,6 +15,7 @@ type Props = {
states: IState[] | undefined;
addIssueToGroup: (groupTitle: string) => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
+ handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
openIssuesListModal?: (() => void) | null;
myIssueProjectId?: string | null;
handleMyIssueOpen?: (issue: IIssue) => void;
@@ -36,6 +37,7 @@ export const AllLists: React.FC = ({
myIssueProjectId,
removeIssue,
states,
+ handleDraftIssueAction,
user,
userAuth,
viewProps,
@@ -82,6 +84,7 @@ export const AllLists: React.FC = ({
groupTitle={singleGroup}
currentState={currentState}
addIssueToGroup={() => addIssueToGroup(singleGroup)}
+ handleDraftIssueAction={handleDraftIssueAction}
handleIssueAction={handleIssueAction}
handleMyIssueOpen={handleMyIssueOpen}
openIssuesListModal={openIssuesListModal}
diff --git a/web/components/core/views/list-view/single-issue.tsx b/web/components/core/views/list-view/single-issue.tsx
index ab5c080ca..0bcd98d09 100644
--- a/web/components/core/views/list-view/single-issue.tsx
+++ b/web/components/core/views/list-view/single-issue.tsx
@@ -1,6 +1,5 @@
import React, { useCallback, useState } from "react";
-import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
@@ -18,6 +17,7 @@ import {
ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect,
+ CreateUpdateDraftIssueModal,
} from "components/issues";
// ui
import { Tooltip, CustomMenu, ContextMenu } from "components/ui";
@@ -61,6 +61,8 @@ type Props = {
makeIssueCopy: () => void;
removeIssue?: (() => void) | null;
handleDeleteIssue: (issue: IIssue) => void;
+ handleDraftIssueSelect?: (issue: IIssue) => void;
+ handleDraftIssueDelete?: (issue: IIssue) => void;
handleMyIssueOpen?: (issue: IIssue) => void;
disableUserActions: boolean;
user: ICurrentUserResponse | undefined;
@@ -76,12 +78,14 @@ export const SingleListIssue: React.FC = ({
makeIssueCopy,
removeIssue,
groupTitle,
+ handleDraftIssueDelete,
handleDeleteIssue,
handleMyIssueOpen,
disableUserActions,
user,
userAuth,
viewProps,
+ handleDraftIssueSelect,
}) => {
// context menu
const [contextMenu, setContextMenu] = useState(false);
@@ -90,6 +94,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 +183,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) => {
@@ -203,26 +210,45 @@ export const SingleListIssue: React.FC = ({
>
{!isNotAllowed && (
<>
-
+ {
+ if (isDraftIssues && handleDraftIssueSelect) handleDraftIssueSelect(issue);
+ else editIssue();
+ }}
+ >
Edit issue
-
- Make a copy...
-
- handleDeleteIssue(issue)}>
+ {!isDraftIssues && (
+
+ Make a copy...
+
+ )}
+ {
+ if (isDraftIssues && handleDraftIssueDelete) handleDraftIssueDelete(issue);
+ else handleDeleteIssue(issue);
+ }}
+ >
Delete issue
>
)}
-
- Copy issue link
-
-
-
- Open issue in new tab
-
-
+ {!isDraftIssues && (
+ <>
+
+ Copy issue link
+
+
+
+ Open issue in new tab
+
+
+ >
+ )}
+
{
@@ -247,7 +273,10 @@ export const SingleListIssue: React.FC
= ({
@@ -345,7 +374,12 @@ export const SingleListIssue: React.FC = ({
)}
{type && !isNotAllowed && (
-
+ {
+ if (isDraftIssues && handleDraftIssueSelect) handleDraftIssueSelect(issue);
+ else editIssue();
+ }}
+ >
Edit issue
@@ -359,18 +393,25 @@ export const SingleListIssue: React.FC
= ({
)}
- handleDeleteIssue(issue)}>
+ {
+ if (isDraftIssues && handleDraftIssueDelete) handleDraftIssueDelete(issue);
+ else handleDeleteIssue(issue);
+ }}
+ >
Delete issue
-
-
-
- Copy issue link
-
-
+ {!isDraftIssues && (
+
+
+
+ Copy issue link
+
+
+ )}
)}
diff --git a/web/components/core/views/list-view/single-list.tsx b/web/components/core/views/list-view/single-list.tsx
index 0ee7388ac..9a1212141 100644
--- a/web/components/core/views/list-view/single-list.tsx
+++ b/web/components/core/views/list-view/single-list.tsx
@@ -40,6 +40,7 @@ type Props = {
groupTitle: string;
addIssueToGroup: () => void;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
+ handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
openIssuesListModal?: (() => void) | null;
handleMyIssueOpen?: (issue: IIssue) => void;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
@@ -56,6 +57,7 @@ export const SingleList: React.FC = ({
addIssueToGroup,
handleIssueAction,
openIssuesListModal,
+ handleDraftIssueAction,
handleMyIssueOpen,
removeIssue,
disableUserActions,
@@ -253,6 +255,16 @@ export const SingleList: React.FC = ({
editIssue={() => handleIssueAction(issue, "edit")}
makeIssueCopy={() => handleIssueAction(issue, "copy")}
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
+ handleDraftIssueSelect={
+ handleDraftIssueAction
+ ? () => handleDraftIssueAction(issue, "edit")
+ : undefined
+ }
+ handleDraftIssueDelete={
+ handleDraftIssueAction
+ ? () => handleDraftIssueAction(issue, "delete")
+ : undefined
+ }
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 (
+
+
+
+ );
+};
diff --git a/web/components/issues/delete-draft-issue-modal.tsx b/web/components/issues/delete-draft-issue-modal.tsx
new file mode 100644
index 000000000..ddbe2a269
--- /dev/null
+++ b/web/components/issues/delete-draft-issue-modal.tsx
@@ -0,0 +1,145 @@
+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 issueServices from "services/issues.service";
+// hooks
+import useIssuesView from "hooks/use-issues-view";
+import useToast from "hooks/use-toast";
+// icons
+import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
+// ui
+import { SecondaryButton, DangerButton } from "components/ui";
+// types
+import type { IIssue, ICurrentUserResponse } from "types";
+// fetch-keys
+import { PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS } from "constants/fetch-keys";
+
+type Props = {
+ isOpen: boolean;
+ handleClose: () => void;
+ data: IIssue | null;
+ user?: ICurrentUserResponse;
+ onSubmit?: () => Promise | void;
+};
+
+export const DeleteDraftIssueModal: React.FC = (props) => {
+ const { isOpen, handleClose, data, user, onSubmit } = props;
+
+ const [isDeleteLoading, setIsDeleteLoading] = useState(false);
+
+ const router = useRouter();
+ const { workspaceSlug, projectId } = router.query;
+
+ const { params } = useIssuesView();
+
+ const { setToastAlert } = useToast();
+
+ useEffect(() => {
+ setIsDeleteLoading(false);
+ }, [isOpen]);
+
+ const onClose = () => {
+ setIsDeleteLoading(false);
+ handleClose();
+ };
+
+ const handleDeletion = async () => {
+ if (!workspaceSlug || !data) return;
+
+ setIsDeleteLoading(true);
+
+ await issueServices
+ .deleteDraftIssue(workspaceSlug as string, data.project, data.id)
+ .then(() => {
+ setIsDeleteLoading(false);
+ handleClose();
+ mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
+ setToastAlert({
+ title: "Success",
+ message: "Draft Issue deleted successfully",
+ type: "success",
+ });
+ })
+ .catch((error) => {
+ console.log(error);
+ handleClose();
+ setToastAlert({
+ title: "Error",
+ message: "Something went wrong",
+ type: "error",
+ });
+ setIsDeleteLoading(false);
+ });
+ if (onSubmit) await onSubmit();
+ };
+
+ return (
+
+
+
+ );
+};
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]);
+ }}
+ />
+ >
+ )}
+
+ >
+ );
+};
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..e6b4f9e08 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,23 @@ export const IssueForm: FC = ({
const issueName = watch("name");
+ const payload: Partial = {
+ name: getValues("name"),
+ description: getValues("description"),
+ state: getValues("state"),
+ priority: getValues("priority"),
+ assignees: getValues("assignees"),
+ target_date: getValues("target_date"),
+ labels: getValues("labels"),
+ project: getValues("project"),
+ };
+
+ 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 +569,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..1c51031f3 100644
--- a/web/components/issues/index.ts
+++ b/web/components/issues/index.ts
@@ -16,3 +16,9 @@ export * from "./sub-issues-list";
export * from "./label";
export * from "./issue-reaction";
export * from "./peek-overview";
+export * from "./confirm-issue-discard";
+
+// draft issue
+export * from "./draft-issue-form";
+export * from "./draft-issue-modal";
+export * from "./delete-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 (
-
-