[WEB-999] chore: updated UI improvements and workflow updates in the project inbox (#4180)

* chore: snoozed filter in the issue inbox filter

* chore: navigating to the next or previous issue when we accept, decline, or duplicate the issue in inbox

* chore: Implemented state, label, assignee and target_date in the inbox issue description and Implemented issue edit confirmation once we click accept the inbox issue

* chore: removed logs

* chore: inbox issue create response

* chore: update inbox issue response

* chore: updated inbox issue accept workflow and added issue properties in inbox issue create modal

* chore: resolved build errors and upgraded lucide react

* chore: updated inbox issue store hook

* chore: code cleanup and removed validation for inbox description

* fix: renamed the variable isLoading to loader in project-inbox store

* fix: updated set function for issue property update

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
guru_sainath 2024-04-15 12:49:14 +05:30 committed by GitHub
parent a44a032683
commit 20b0edeaa6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 965 additions and 127 deletions

View File

@ -24,6 +24,7 @@ from plane.db.models import (
State, State,
IssueLink, IssueLink,
IssueAttachment, IssueAttachment,
Project,
ProjectMember, ProjectMember,
) )
from plane.app.serializers import ( from plane.app.serializers import (
@ -239,41 +240,75 @@ class InboxIssueViewSet(BaseViewSet):
) )
# create an issue # create an issue
issue = Issue.objects.create( project = Project.objects.get(pk=project_id)
name=request.data.get("issue", {}).get("name"), serializer = IssueCreateSerializer(
description=request.data.get("issue", {}).get("description", {}), data=request.data.get("issue"),
description_html=request.data.get("issue", {}).get( context={
"description_html", "<p></p>" "project_id": project_id,
), "workspace_id": project.workspace_id,
priority=request.data.get("issue", {}).get("priority", "low"), "default_assignee_id": project.default_assignee_id,
project_id=project_id, },
state=state,
) )
if serializer.is_valid():
# Create an Issue Activity serializer.save()
issue_activity.delay( # Create an Issue Activity
type="issue.activity.created", issue_activity.delay(
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), type="issue.activity.created",
actor_id=str(request.user.id), requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
issue_id=str(issue.id), actor_id=str(request.user.id),
project_id=str(project_id), issue_id=str(serializer.data["id"]),
current_instance=None, project_id=str(project_id),
epoch=int(timezone.now().timestamp()), current_instance=None,
notification=True, epoch=int(timezone.now().timestamp()),
origin=request.META.get("HTTP_ORIGIN"), notification=True,
) origin=request.META.get("HTTP_ORIGIN"),
inbox_id = Inbox.objects.filter( )
workspace__slug=slug, project_id=project_id inbox_id = Inbox.objects.filter(
).first() workspace__slug=slug, project_id=project_id
# create an inbox issue ).first()
inbox_issue = InboxIssue.objects.create( # create an inbox issue
inbox_id=inbox_id.id, inbox_issue = InboxIssue.objects.create(
project_id=project_id, inbox_id=inbox_id.id,
issue=issue, project_id=project_id,
source=request.data.get("source", "in-app"), issue_id=serializer.data["id"],
) source=request.data.get("source", "in-app"),
serializer = InboxIssueDetailSerializer(inbox_issue) )
return Response(serializer.data, status=status.HTTP_200_OK) inbox_issue = (
InboxIssue.objects.select_related("issue")
.prefetch_related(
"issue__labels",
"issue__assignees",
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=~Q(issue__labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"issue__assignees__id",
distinct=True,
filter=~Q(issue__assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.get(
inbox_id=inbox_id.id,
issue_id=serializer.data["id"],
project_id=project_id,
)
)
serializer = InboxIssueDetailSerializer(inbox_issue)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
def partial_update(self, request, slug, project_id, issue_id): def partial_update(self, request, slug, project_id, issue_id):
inbox_id = Inbox.objects.filter( inbox_id = Inbox.objects.filter(
@ -395,6 +430,42 @@ class InboxIssueViewSet(BaseViewSet):
issue.state = state issue.state = state
issue.save() issue.save()
inbox_issue = (
InboxIssue.objects.select_related("issue")
.prefetch_related(
"issue__labels",
"issue__assignees",
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=~Q(issue__labels__id__isnull=True),
),
Value(
[],
output_field=ArrayField(UUIDField()),
),
),
assignee_ids=Coalesce(
ArrayAgg(
"issue__assignees__id",
distinct=True,
filter=~Q(issue__assignees__id__isnull=True),
),
Value(
[],
output_field=ArrayField(UUIDField()),
),
),
)
.get(
inbox_id=inbox_id.id,
issue_id=serializer.data["id"],
project_id=project_id,
)
)
serializer = InboxIssueDetailSerializer(inbox_issue).data serializer = InboxIssueDetailSerializer(inbox_issue).data
return Response(serializer, status=status.HTTP_200_OK) return Response(serializer, status=status.HTTP_200_OK)
return Response( return Response(

View File

@ -6,7 +6,7 @@ import { Plus, RefreshCcw } from "lucide-react";
import { Breadcrumbs, Button, LayersIcon } from "@plane/ui"; import { Breadcrumbs, Button, LayersIcon } from "@plane/ui";
// components // components
import { BreadcrumbLink } from "@/components/common"; import { BreadcrumbLink } from "@/components/common";
import { CreateInboxIssueModal } from "@/components/inbox"; import { InboxIssueCreateEditModalRoot } from "@/components/inbox";
import { ProjectLogo } from "@/components/project"; import { ProjectLogo } from "@/components/project";
// hooks // hooks
import { useProject, useProjectInbox } from "@/hooks/store"; import { useProject, useProjectInbox } from "@/hooks/store";
@ -16,10 +16,10 @@ export const ProjectInboxHeader: FC = observer(() => {
const [createIssueModal, setCreateIssueModal] = useState(false); const [createIssueModal, setCreateIssueModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug, projectId } = router.query;
// store hooks // store hooks
const { currentProjectDetails } = useProject(); const { currentProjectDetails } = useProject();
const { isLoading } = useProjectInbox(); const { loader } = useProjectInbox();
return ( return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4"> <div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
@ -51,7 +51,7 @@ export const ProjectInboxHeader: FC = observer(() => {
/> />
</Breadcrumbs> </Breadcrumbs>
{isLoading === "pagination-loading" && ( {loader === "pagination-loading" && (
<div className="flex items-center gap-1.5 text-custom-text-300"> <div className="flex items-center gap-1.5 text-custom-text-300">
<RefreshCcw className="h-3.5 w-3.5 animate-spin" /> <RefreshCcw className="h-3.5 w-3.5 animate-spin" />
<p className="text-sm">Syncing...</p> <p className="text-sm">Syncing...</p>
@ -60,9 +60,16 @@ export const ProjectInboxHeader: FC = observer(() => {
</div> </div>
</div> </div>
{currentProjectDetails?.inbox_view && ( {currentProjectDetails?.inbox_view && workspaceSlug && projectId && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CreateInboxIssueModal isOpen={createIssueModal} onClose={() => setCreateIssueModal(false)} /> <InboxIssueCreateEditModalRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
modalState={createIssueModal}
handleModalClose={() => setCreateIssueModal(false)}
issue={undefined}
/>
<Button variant="primary" prependIcon={<Plus />} size="sm" onClick={() => setCreateIssueModal(true)}> <Button variant="primary" prependIcon={<Plus />} size="sm" onClick={() => setCreateIssueModal(true)}>
Add Issue Add Issue
</Button> </Button>

View File

@ -1,13 +1,23 @@
import { FC, useCallback, useEffect, useState } from "react"; import { FC, useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { ChevronDown, ChevronUp, Clock, ExternalLink, FileStack, Link, Trash2 } from "lucide-react"; import {
CircleCheck,
CircleX,
ChevronDown,
ChevronUp,
Clock,
ExternalLink,
FileStack,
Link,
Trash2,
} from "lucide-react";
import { Button, ControlLink, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; import { Button, ControlLink, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { import {
AcceptIssueModal,
DeclineIssueModal, DeclineIssueModal,
DeleteInboxIssueModal, DeleteInboxIssueModal,
InboxIssueCreateEditModalRoot,
InboxIssueSnoozeModal, InboxIssueSnoozeModal,
InboxIssueStatus, InboxIssueStatus,
SelectDuplicateInboxIssueModal, SelectDuplicateInboxIssueModal,
@ -39,7 +49,7 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
const [declineIssueModal, setDeclineIssueModal] = useState(false); const [declineIssueModal, setDeclineIssueModal] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
// store // store
const { deleteInboxIssue, inboxIssuesArray } = useProjectInbox(); const { currentTab, deleteInboxIssue, inboxIssuesArray } = useProjectInbox();
const { const {
currentUser, currentUser,
membership: { currentProjectRole }, membership: { currentProjectRole },
@ -60,25 +70,50 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
const issueLink = `${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`; const issueLink = `${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`;
const redirectIssue = (): string | undefined => {
let nextOrPreviousIssueId: string | undefined = undefined;
const currentIssueIndex = inboxIssuesArray.findIndex((i) => i.issue.id === currentInboxIssueId);
if (inboxIssuesArray[currentIssueIndex + 1])
nextOrPreviousIssueId = inboxIssuesArray[currentIssueIndex + 1].issue.id;
else if (inboxIssuesArray[currentIssueIndex - 1])
nextOrPreviousIssueId = inboxIssuesArray[currentIssueIndex - 1].issue.id;
else nextOrPreviousIssueId = undefined;
return nextOrPreviousIssueId;
};
const handleRedirection = (nextOrPreviousIssueId: string | undefined) => {
if (nextOrPreviousIssueId)
router.push(
`/${workspaceSlug}/projects/${projectId}/inbox?currentTab=${currentTab}&inboxIssueId=${nextOrPreviousIssueId}`
);
else router.push(`/${workspaceSlug}/projects/${projectId}/inbox?currentTab=${currentTab}`);
};
const handleInboxIssueAccept = async () => { const handleInboxIssueAccept = async () => {
const nextOrPreviousIssueId = redirectIssue();
await inboxIssue?.updateInboxIssueStatus(EInboxIssueStatus.ACCEPTED); await inboxIssue?.updateInboxIssueStatus(EInboxIssueStatus.ACCEPTED);
setAcceptIssueModal(false); setAcceptIssueModal(false);
handleRedirection(nextOrPreviousIssueId);
}; };
const handleInboxIssueDecline = async () => { const handleInboxIssueDecline = async () => {
const nextOrPreviousIssueId = redirectIssue();
await inboxIssue?.updateInboxIssueStatus(EInboxIssueStatus.DECLINED); await inboxIssue?.updateInboxIssueStatus(EInboxIssueStatus.DECLINED);
setDeclineIssueModal(false); setDeclineIssueModal(false);
handleRedirection(nextOrPreviousIssueId);
};
const handleInboxSIssueSnooze = async (date: Date) => {
const nextOrPreviousIssueId = redirectIssue();
await inboxIssue?.updateInboxIssueSnoozeTill(date);
setIsSnoozeDateModalOpen(false);
handleRedirection(nextOrPreviousIssueId);
}; };
const handleInboxIssueDuplicate = async (issueId: string) => { const handleInboxIssueDuplicate = async (issueId: string) => {
await inboxIssue?.updateInboxIssueDuplicateTo(issueId); await inboxIssue?.updateInboxIssueDuplicateTo(issueId);
}; };
const handleInboxSIssueSnooze = async (date: Date) => {
await inboxIssue?.updateInboxIssueSnoozeTill(date);
setIsSnoozeDateModalOpen(false);
};
const handleInboxIssueDelete = async () => { const handleInboxIssueDelete = async () => {
if (!inboxIssue || !currentInboxIssueId) return; if (!inboxIssue || !currentInboxIssueId) return;
await deleteInboxIssue(workspaceSlug, projectId, currentInboxIssueId).finally(() => { await deleteInboxIssue(workspaceSlug, projectId, currentInboxIssueId).finally(() => {
@ -143,10 +178,12 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
onSubmit={handleInboxIssueDuplicate} onSubmit={handleInboxIssueDuplicate}
/> />
<AcceptIssueModal <InboxIssueCreateEditModalRoot
data={inboxIssue?.issue} workspaceSlug={workspaceSlug.toString()}
isOpen={acceptIssueModal} projectId={projectId.toString()}
onClose={() => setAcceptIssueModal(false)} modalState={acceptIssueModal}
handleModalClose={() => setAcceptIssueModal(false)}
issue={inboxIssue?.issue}
onSubmit={handleInboxIssueAccept} onSubmit={handleInboxIssueAccept}
/> />
@ -156,14 +193,12 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
onClose={() => setDeclineIssueModal(false)} onClose={() => setDeclineIssueModal(false)}
onSubmit={handleInboxIssueDecline} onSubmit={handleInboxIssueDecline}
/> />
<DeleteInboxIssueModal <DeleteInboxIssueModal
data={inboxIssue?.issue} data={inboxIssue?.issue}
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
onClose={() => setDeleteIssueModal(false)} onClose={() => setDeleteIssueModal(false)}
onSubmit={handleInboxIssueDelete} onSubmit={handleInboxIssueDelete}
/> />
<InboxIssueSnoozeModal <InboxIssueSnoozeModal
isOpen={isSnoozeDateModalOpen} isOpen={isSnoozeDateModalOpen}
handleClose={() => setIsSnoozeDateModalOpen(false)} handleClose={() => setIsSnoozeDateModalOpen(false)}
@ -206,7 +241,13 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{canMarkAsAccepted && ( {canMarkAsAccepted && (
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Button variant="neutral-primary" size="sm" onClick={() => setAcceptIssueModal(true)}> <Button
variant="neutral-primary"
size="sm"
prependIcon={<CircleCheck className="w-3 h-3" />}
className="text-green-500 border-0.5 border-green-500 bg-green-500/20 focus:bg-green-500/20 focus:text-green-500 hover:bg-green-500/40 bg-opacity-20"
onClick={() => setAcceptIssueModal(true)}
>
Accept Accept
</Button> </Button>
</div> </div>
@ -214,7 +255,13 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
{canMarkAsDeclined && ( {canMarkAsDeclined && (
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Button variant="neutral-primary" size="sm" onClick={() => setDeclineIssueModal(true)}> <Button
variant="neutral-primary"
size="sm"
prependIcon={<CircleX className="w-3 h-3" />}
className="text-red-500 border-0.5 border-red-500 bg-red-500/20 focus:bg-red-500/20 focus:text-red-500 hover:bg-red-500/40 bg-opacity-20"
onClick={() => setDeclineIssueModal(true)}
>
Decline Decline
</Button> </Button>
</div> </div>

View File

@ -21,7 +21,7 @@ type Props = {
duplicateIssueDetails: TInboxDuplicateIssueDetails | undefined; duplicateIssueDetails: TInboxDuplicateIssueDetails | undefined;
}; };
export const InboxIssueProperties: React.FC<Props> = observer((props) => { export const InboxIssueContentProperties: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issue, issueOperations, isEditable, duplicateIssueDetails } = props; const { workspaceSlug, projectId, issue, issueOperations, isEditable, duplicateIssueDetails } = props;
const router = useRouter(); const router = useRouter();

View File

@ -2,9 +2,9 @@ import { Dispatch, SetStateAction, useEffect, useMemo } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui"; import { Loader, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { InboxIssueProperties } from "@/components/inbox/content"; import { InboxIssueContentProperties } from "@/components/inbox/content";
import { import {
IssueDescriptionInput, IssueDescriptionInput,
IssueTitleInput, IssueTitleInput,
@ -13,7 +13,7 @@ import {
TIssueOperations, TIssueOperations,
} from "@/components/issues"; } from "@/components/issues";
// hooks // hooks
import { useEventTracker, useUser } from "@/hooks/store"; import { useEventTracker, useProjectInbox, useUser } from "@/hooks/store";
import useReloadConfirmations from "@/hooks/use-reload-confirmation"; import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// store types // store types
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
@ -36,6 +36,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
const { currentUser } = useUser(); const { currentUser } = useUser();
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
const { captureIssueEvent } = useEventTracker(); const { captureIssueEvent } = useEventTracker();
const { loader } = useProjectInbox();
useEffect(() => { useEffect(() => {
if (isSubmitting === "submitted") { if (isSubmitting === "submitted") {
@ -126,16 +127,22 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
value={issue.name} value={issue.name}
/> />
<IssueDescriptionInput {loader === "issue-loading" ? (
workspaceSlug={workspaceSlug} <Loader className="min-h-[6rem] rounded-md border border-custom-border-200">
projectId={issue.project_id} <Loader.Item width="100%" height="140px" />
issueId={issue.id} </Loader>
swrIssueDescription={swrIssueDescription} ) : (
initialValue={issue.description_html ?? "<p></p>"} <IssueDescriptionInput
disabled={!isEditable} workspaceSlug={workspaceSlug}
issueOperations={issueOperations} projectId={issue.project_id}
setIsSubmitting={(value) => setIsSubmitting(value)} issueId={issue.id}
/> swrIssueDescription={swrIssueDescription}
initialValue={issue.description_html ?? "<p></p>"}
disabled={!isEditable}
issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)}
/>
)}
{currentUser && ( {currentUser && (
<IssueReaction <IssueReaction
@ -147,7 +154,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
)} )}
</div> </div>
<InboxIssueProperties <InboxIssueContentProperties
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
issue={issue} issue={issue}

View File

@ -0,0 +1,147 @@
import { FC, useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { EditorRefApi } from "@plane/rich-text-editor";
import { TIssue } from "@plane/types";
import { Button, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
// components
import {
InboxIssueTitle,
InboxIssueDescription,
InboxIssueProperties,
} from "@/components/inbox/modals/create-edit-modal";
// constants
import { ISSUE_CREATED } from "@/constants/event-tracker";
// helpers
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// hooks
import { useEventTracker, useProjectInbox, useWorkspace } from "@/hooks/store";
type TInboxIssueCreateRoot = {
workspaceSlug: string;
projectId: string;
handleModalClose: () => void;
};
export const defaultIssueData: Partial<TIssue> = {
id: undefined,
name: "",
description_html: "",
priority: "none",
state_id: "",
label_ids: [],
assignee_ids: [],
start_date: renderFormattedPayloadDate(new Date()),
target_date: "",
};
export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props) => {
const { workspaceSlug, projectId, handleModalClose } = props;
const router = useRouter();
// refs
const descriptionEditorRef = useRef<EditorRefApi>(null);
// hooks
const { captureIssueEvent } = useEventTracker();
const { createInboxIssue } = useProjectInbox();
const { getWorkspaceBySlug } = useWorkspace();
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id;
// states
const [createMore, setCreateMore] = useState<boolean>(false);
const [formSubmitting, setFormSubmitting] = useState(false);
const [formData, setFormData] = useState<Partial<TIssue>>(defaultIssueData);
const handleFormData = useCallback(
<T extends keyof Partial<TIssue>>(issueKey: T, issueValue: Partial<TIssue>[T]) => {
setFormData({
...formData,
[issueKey]: issueValue,
});
},
[formData]
);
const handleFormSubmit = async () => {
const payload: Partial<TIssue> = {
name: formData.name || "",
description_html: formData.description_html || "<p></p>",
priority: formData.priority || "none",
state_id: formData.state_id || "",
label_ids: formData.label_ids || [],
assignee_ids: formData.assignee_ids || [],
target_date: formData.target_date || null,
};
setFormSubmitting(true);
await createInboxIssue(workspaceSlug, projectId, payload)
.then((res) => {
if (!createMore) {
router.push(`/${workspaceSlug}/projects/${projectId}/inbox/?currentTab=open&inboxIssueId=${res?.issue?.id}`);
handleModalClose();
} else {
descriptionEditorRef?.current?.clearEditor();
setFormData(defaultIssueData);
}
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: {
...formData,
state: "SUCCESS",
element: "Inbox page",
},
path: router.pathname,
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: `${TOAST_TYPE.SUCCESS}!`,
message: "Issue created successfully.",
});
})
.catch((error) => {
console.error(error);
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: {
...formData,
state: "FAILED",
element: "Inbox page",
},
path: router.pathname,
});
setToast({
type: TOAST_TYPE.ERROR,
title: `${TOAST_TYPE.ERROR}!`,
message: "Some error occurred. Please try again.",
});
});
setFormSubmitting(false);
};
if (!workspaceSlug || !projectId || !workspaceId) return <></>;
return (
<div className="relative space-y-4">
<InboxIssueTitle data={formData} handleData={handleFormData} />
<InboxIssueDescription
workspaceSlug={workspaceSlug}
projectId={projectId}
workspaceId={workspaceId}
data={formData}
handleData={handleFormData}
editorRef={descriptionEditorRef}
/>
<InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} />
<div className="relative flex justify-between items-center gap-3">
<div className="flex cursor-pointer items-center gap-1" onClick={() => setCreateMore((prevData) => !prevData)}>
<span className="text-xs">Create more</span>
<ToggleSwitch value={createMore} onChange={() => {}} size="md" />
</div>
<div className="relative flex items-center gap-3">
<Button variant="neutral-primary" size="sm" type="button" onClick={handleModalClose}>
Discard
</Button>
<Button variant="primary" size="sm" type="button" loading={formSubmitting} onClick={handleFormSubmit}>
{formSubmitting ? "Adding Issue..." : "Add Issue"}
</Button>
</div>
</div>
</div>
);
});

View File

@ -0,0 +1,147 @@
import { FC, useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { EditorRefApi } from "@plane/rich-text-editor";
import { TIssue } from "@plane/types";
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// components
import {
InboxIssueTitle,
InboxIssueDescription,
InboxIssueProperties,
} from "@/components/inbox/modals/create-edit-modal";
// constants
import { ISSUE_UPDATED } from "@/constants/event-tracker";
// helpers
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// hooks
import { useEventTracker, useInboxIssues, useWorkspace } from "@/hooks/store";
type TInboxIssueEditRoot = {
workspaceSlug: string;
projectId: string;
issueId: string;
issue: Partial<TIssue>;
handleModalClose: () => void;
onSubmit?: () => void;
};
export const InboxIssueEditRoot: FC<TInboxIssueEditRoot> = observer((props) => {
const { workspaceSlug, projectId, issueId, issue, handleModalClose, onSubmit } = props;
const router = useRouter();
// refs
const descriptionEditorRef = useRef<EditorRefApi>(null);
// hooks
const { captureIssueEvent } = useEventTracker();
const { updateProjectIssue } = useInboxIssues(issueId);
const { getWorkspaceBySlug } = useWorkspace();
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id;
// states
const [formSubmitting, setFormSubmitting] = useState(false);
const [formData, setFormData] = useState<Partial<TIssue> | undefined>(undefined);
const handleFormData = useCallback(
<T extends keyof Partial<TIssue>>(issueKey: T, issueValue: Partial<TIssue>[T]) => {
setFormData({
...formData,
[issueKey]: issueValue,
});
},
[formData]
);
useEffect(() => {
if (formData?.id != issue?.id)
setFormData({
id: issue?.id || undefined,
name: issue?.name ?? "",
description_html: issue?.description_html ?? "<p></p>",
priority: issue?.priority ?? "none",
state_id: issue?.state_id ?? "",
label_ids: issue?.label_ids ?? [],
assignee_ids: issue?.assignee_ids ?? [],
start_date: renderFormattedPayloadDate(issue?.start_date) ?? "",
target_date: renderFormattedPayloadDate(issue?.target_date) ?? "",
});
}, [issue, formData]);
const handleFormSubmit = async () => {
const payload: Partial<TIssue> = {
name: formData?.name || "",
description_html: formData?.description_html || "<p></p>",
priority: formData?.priority || "none",
state_id: formData?.state_id || "",
label_ids: formData?.label_ids || [],
assignee_ids: formData?.assignee_ids || [],
start_date: formData?.start_date || undefined,
target_date: formData?.target_date || undefined,
cycle_id: formData?.cycle_id || "",
module_ids: formData?.module_ids || [],
estimate_point: formData?.estimate_point || undefined,
parent_id: formData?.parent_id || "",
};
setFormSubmitting(true);
onSubmit && (await onSubmit());
await updateProjectIssue(payload)
.then(async () => {
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: {
...formData,
state: "SUCCESS",
element: "Inbox page",
},
path: router.pathname,
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: `${TOAST_TYPE.SUCCESS}!`,
message: "Issue created successfully.",
});
descriptionEditorRef?.current?.clearEditor();
handleModalClose();
})
.catch((error) => {
console.error(error);
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: {
...formData,
state: "FAILED",
element: "Inbox page",
},
path: router.pathname,
});
setToast({
type: TOAST_TYPE.ERROR,
title: `${TOAST_TYPE.ERROR}!`,
message: "Some error occurred. Please try again.",
});
});
setFormSubmitting(false);
};
if (!workspaceSlug || !projectId || !workspaceId || !formData) return <></>;
return (
<div className="relative space-y-4">
<InboxIssueTitle data={formData} handleData={handleFormData} />
<InboxIssueDescription
workspaceSlug={workspaceSlug}
projectId={projectId}
workspaceId={workspaceId}
data={formData}
handleData={handleFormData}
editorRef={descriptionEditorRef}
/>
<InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} isVisible />
<div className="relative flex justify-end items-center gap-3">
<Button variant="neutral-primary" size="sm" type="button" onClick={handleModalClose}>
Cancel
</Button>
<Button variant="primary" size="sm" type="button" loading={formSubmitting} onClick={handleFormSubmit}>
{formSubmitting ? "Adding..." : "Add to project"}
</Button>
</div>
</div>
);
});

View File

@ -0,0 +1,6 @@
export * from "./modal";
export * from "./create-root";
export * from "./edit-root";
export * from "./issue-title";
export * from "./issue-description";
export * from "./issue-properties";

View File

@ -0,0 +1,47 @@
import { FC, RefObject } from "react";
import { observer } from "mobx-react";
import { EditorRefApi } from "@plane/rich-text-editor";
import { TIssue } from "@plane/types";
import { Loader } from "@plane/ui";
// components
import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor";
// hooks
import { useProjectInbox } from "@/hooks/store";
type TInboxIssueDescription = {
workspaceSlug: string;
projectId: string;
workspaceId: string;
data: Partial<TIssue>;
handleData: (issueKey: keyof Partial<TIssue>, issueValue: Partial<TIssue>[keyof Partial<TIssue>]) => void;
editorRef: RefObject<EditorRefApi>;
};
// TODO: have to implement GPT Assistance
export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props) => {
const { workspaceSlug, projectId, workspaceId, data, handleData, editorRef } = props;
// hooks
const { loader } = useProjectInbox();
if (loader === "issue-loading")
return (
<Loader className="min-h-[6rem] rounded-md border border-custom-border-200">
<Loader.Item width="100%" height="140px" />
</Loader>
);
return (
<div className="relative">
<RichTextEditor
initialValue={!data?.description_html || data?.description_html === "" ? "<p></p>" : data?.description_html}
ref={editorRef}
workspaceSlug={workspaceSlug}
workspaceId={workspaceId}
projectId={projectId}
dragDropEnabled={false}
onChange={(_description: object, description_html: string) => {
handleData("description_html", description_html);
}}
/>
</div>
);
});

View File

@ -0,0 +1,186 @@
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { LayoutPanelTop } from "lucide-react";
import { ISearchIssueResponse, TIssue } from "@plane/types";
// components
import {
CycleDropdown,
DateDropdown,
EstimateDropdown,
ModuleDropdown,
PriorityDropdown,
MemberDropdown,
StateDropdown,
} from "@/components/dropdowns";
import { ParentIssuesListModal } from "@/components/issues";
import { IssueLabelSelect } from "@/components/issues/select";
// helpers
import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper";
// hooks
import { useEstimate } from "@/hooks/store";
type TInboxIssueProperties = {
projectId: string;
data: Partial<TIssue>;
handleData: (issueKey: keyof Partial<TIssue>, issueValue: Partial<TIssue>[keyof Partial<TIssue>]) => void;
isVisible?: boolean;
};
export const InboxIssueProperties: FC<TInboxIssueProperties> = observer((props) => {
const { projectId, data, handleData, isVisible = false } = props;
// hooks
const { areEstimatesEnabledForProject } = useEstimate();
// states
const [parentIssueModalOpen, setParentIssueModalOpen] = useState(false);
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | undefined>(undefined);
true;
const startDate = data?.start_date;
const targetDate = data?.target_date;
const minDate = getDate(startDate);
minDate?.setDate(minDate.getDate());
const maxDate = getDate(targetDate);
maxDate?.setDate(maxDate.getDate());
return (
<div className="relative flex flex-wrap gap-2 items-center">
{/* state */}
<div className="h-7">
<StateDropdown
value={data?.state_id || ""}
onChange={(stateId) => handleData("state_id", stateId)}
projectId={projectId}
buttonVariant="border-with-text"
/>
</div>
{/* priority */}
<div className="h-7">
<PriorityDropdown
value={data?.priority || "none"}
onChange={(priority) => handleData("priority", priority)}
buttonVariant="border-with-text"
/>
</div>
{/* Assignees */}
<div className="h-7">
<MemberDropdown
projectId={projectId}
value={data?.assignee_ids || []}
onChange={(assigneeIds) => handleData("assignee_ids", assigneeIds)}
buttonVariant={(data?.assignee_ids || [])?.length > 0 ? "transparent-without-text" : "border-with-text"}
buttonClassName={(data?.assignee_ids || [])?.length > 0 ? "hover:bg-transparent" : ""}
placeholder="Assignees"
multiple
/>
</div>
{/* labels */}
<div className="h-7">
<IssueLabelSelect
createLabelEnabled={false}
setIsOpen={() => {}}
value={data?.label_ids || []}
onChange={(labelIds) => handleData("label_ids", labelIds)}
projectId={projectId}
/>
</div>
{/* start date */}
{isVisible && (
<div className="h-7">
<DateDropdown
value={data?.start_date || null}
onChange={(date) => (date ? handleData("start_date", renderFormattedPayloadDate(date)) : null)}
buttonVariant="border-with-text"
minDate={minDate ?? undefined}
placeholder="Start date"
/>
</div>
)}
{/* due date */}
<div className="h-7">
<DateDropdown
value={data?.target_date || null}
onChange={(date) => (date ? handleData("target_date", renderFormattedPayloadDate(date)) : null)}
buttonVariant="border-with-text"
minDate={minDate ?? undefined}
placeholder="Due date"
/>
</div>
{/* cycle */}
{isVisible && (
<div className="h-7">
<CycleDropdown
value={data?.cycle_id || ""}
onChange={(cycleId) => handleData("cycle_id", cycleId)}
projectId={projectId}
placeholder="Cycle"
buttonVariant="border-with-text"
/>
</div>
)}
{/* module */}
{isVisible && (
<div className="h-7">
<ModuleDropdown
value={data?.module_ids || []}
onChange={(moduleIds) => handleData("module_ids", moduleIds)}
projectId={projectId}
placeholder="Modules"
buttonVariant="border-with-text"
multiple
showCount
/>
</div>
)}
{/* estimate */}
{isVisible && areEstimatesEnabledForProject(projectId) && (
<div className="h-7">
<EstimateDropdown
value={data?.estimate_point || null}
onChange={(estimatePoint) => handleData("estimate_point", estimatePoint)}
projectId={projectId}
buttonVariant="border-with-text"
placeholder="Estimate"
/>
</div>
)}
{/* add parent */}
{isVisible && (
<>
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
onClick={() => setParentIssueModalOpen(true)}
>
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">
{selectedParentIssue
? `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
: `Add parent`}
</span>
</button>
<ParentIssuesListModal
isOpen={parentIssueModalOpen}
handleClose={() => setParentIssueModalOpen(false)}
onChange={(issue) => {
handleData("parent_id", issue?.id);
setSelectedParentIssue(issue);
}}
projectId={projectId}
issueId={data?.id}
/>
</>
)}
</div>
);
});

View File

@ -0,0 +1,27 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { TIssue } from "@plane/types";
import { Input } from "@plane/ui";
type TInboxIssueTitle = {
data: Partial<TIssue>;
handleData: (issueKey: keyof Partial<TIssue>, issueValue: Partial<TIssue>[keyof Partial<TIssue>]) => void;
};
export const InboxIssueTitle: FC<TInboxIssueTitle> = observer((props) => {
const { data, handleData } = props;
return (
<div className="relative flex flex-wrap gap-2 items-center">
<Input
id="name"
name="name"
type="text"
value={data?.name}
onChange={(e) => handleData("name", e.target.value)}
placeholder="Title"
className="w-full resize-none text-xl"
/>
</div>
);
});

View File

@ -0,0 +1,84 @@
import { FC, Fragment } from "react";
import { observer } from "mobx-react";
import { Transition, Dialog } from "@headlessui/react";
import { TIssue } from "@plane/types";
// components
import { InboxIssueCreateRoot, InboxIssueEditRoot } from "@/components/inbox/modals/create-edit-modal";
// hooks
import { useProject } from "@/hooks/store";
type TInboxIssueCreateEditModalRoot = {
workspaceSlug: string;
projectId: string;
modalState: boolean;
handleModalClose: () => void;
issue: Partial<TIssue> | undefined;
onSubmit?: () => void;
};
export const InboxIssueCreateEditModalRoot: FC<TInboxIssueCreateEditModalRoot> = observer((props) => {
const { workspaceSlug, projectId, modalState, handleModalClose, issue, onSubmit } = props;
// hooks
const { currentProjectDetails } = useProject();
return (
<div>
<Transition.Root show={modalState} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleModalClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 text-left shadow-custom-shadow-md transition-all w-full lg:max-w-4xl">
{issue && issue?.id ? (
<div className="space-y-4">
<h3 className="text-xl font-medium text-custom-text-100">
Move {currentProjectDetails?.identifier}-{issue?.sequence_id} to project issues
</h3>
<InboxIssueEditRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issue.id}
issue={issue}
handleModalClose={handleModalClose}
onSubmit={onSubmit}
/>
</div>
) : (
<div className="space-y-4">
<h3 className="text-xl font-medium text-custom-text-100">Create Inbox Issue</h3>
<InboxIssueCreateRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
handleModalClose={handleModalClose}
/>
</div>
)}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
</div>
);
});

View File

@ -1,5 +1,5 @@
export * from "./accept-issue-modal"; export * from "./accept-issue-modal";
export * from "./create-issue-modal"; export * from "./create-edit-modal";
export * from "./decline-issue-modal"; export * from "./decline-issue-modal";
export * from "./delete-issue-modal"; export * from "./delete-issue-modal";
export * from "./select-duplicate"; export * from "./select-duplicate";

View File

@ -21,7 +21,7 @@ type TInboxIssueRoot = {
export const InboxIssueRoot: FC<TInboxIssueRoot> = observer((props) => { export const InboxIssueRoot: FC<TInboxIssueRoot> = observer((props) => {
const { workspaceSlug, projectId, inboxIssueId, inboxAccessible } = props; const { workspaceSlug, projectId, inboxIssueId, inboxAccessible } = props;
// hooks // hooks
const { isLoading, error, fetchInboxIssues } = useProjectInbox(); const { loader, error, fetchInboxIssues } = useProjectInbox();
useSWR( useSWR(
inboxAccessible && workspaceSlug && projectId ? `PROJECT_INBOX_ISSUES_${workspaceSlug}_${projectId}` : null, inboxAccessible && workspaceSlug && projectId ? `PROJECT_INBOX_ISSUES_${workspaceSlug}_${projectId}` : null,
@ -35,7 +35,7 @@ export const InboxIssueRoot: FC<TInboxIssueRoot> = observer((props) => {
); );
// loader // loader
if (isLoading === "init-loading") if (loader === "init-loading")
return ( return (
<div className="relative flex w-full h-full flex-col"> <div className="relative flex w-full h-full flex-col">
<InboxLayoutLoader /> <InboxLayoutLoader />

View File

@ -42,7 +42,7 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
const { const {
currentTab, currentTab,
handleCurrentTab, handleCurrentTab,
isLoading, loader,
inboxIssuesArray, inboxIssuesArray,
inboxIssuePaginationInfo, inboxIssuePaginationInfo,
fetchInboxPaginationIssues, fetchInboxPaginationIssues,
@ -100,7 +100,7 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
<InboxIssueAppliedFilters /> <InboxIssueAppliedFilters />
{isLoading != undefined && isLoading === "filter-loading" && !inboxIssuePaginationInfo?.next_page_results ? ( {loader != undefined && loader === "filter-loading" && !inboxIssuePaginationInfo?.next_page_results ? (
<InboxSidebarLoader /> <InboxSidebarLoader />
) : ( ) : (
<div <div

View File

@ -649,6 +649,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
projectId={projectId} projectId={projectId}
buttonVariant="border-with-text" buttonVariant="border-with-text"
tabIndex={getTabIndex("estimate_point")} tabIndex={getTabIndex("estimate_point")}
placeholder="Estimate"
/> />
</div> </div>
)} )}

View File

@ -20,10 +20,11 @@ type Props = {
label?: JSX.Element; label?: JSX.Element;
disabled?: boolean; disabled?: boolean;
tabIndex?: number; tabIndex?: number;
createLabelEnabled?: boolean;
}; };
export const IssueLabelSelect: React.FC<Props> = observer((props) => { export const IssueLabelSelect: React.FC<Props> = observer((props) => {
const { setIsOpen, value, onChange, projectId, label, disabled = false, tabIndex } = props; const { setIsOpen, value, onChange, projectId, label, disabled = false, tabIndex, createLabelEnabled = true } = props;
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -221,14 +222,16 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
) : ( ) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p> <p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)} )}
<button {createLabelEnabled && (
type="button" <button
className="flex items-center gap-2 w-full select-none rounded px-1 py-2 hover:bg-custom-background-80" type="button"
onClick={() => setIsOpen(true)} className="flex items-center gap-2 w-full select-none rounded px-1 py-2 hover:bg-custom-background-80"
> onClick={() => setIsOpen(true)}
<Plus className="h-3 w-3" aria-hidden="true" /> >
<span className="whitespace-nowrap">Create new label</span> <Plus className="h-3 w-3" aria-hidden="true" />
</button> <span className="whitespace-nowrap">Create new label</span>
</button>
)}
</div> </div>
</div> </div>
</Combobox.Options> </Combobox.Options>

View File

@ -1,9 +1,10 @@
import { useContext } from "react"; import { useContext } from "react";
// mobx store // mobx store
import { StoreContext } from "contexts/store-context"; import { StoreContext } from "contexts/store-context";
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
export const useInboxIssues = (inboxIssueId: string) => { export const useInboxIssues = (inboxIssueId: string): IInboxIssueStore => {
const context = useContext(StoreContext); const context = useContext(StoreContext);
if (context === undefined) throw new Error("useInboxIssues must be used within StoreProvider"); if (context === undefined) throw new Error("useInboxIssues must be used within StoreProvider");
return context.projectInbox.getIssueInboxByIssueId(inboxIssueId) || {}; return context.projectInbox.getIssueInboxByIssueId(inboxIssueId);
}; };

View File

@ -37,7 +37,7 @@
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.294.0", "lucide-react": "^0.368.0",
"mobx": "^6.10.0", "mobx": "^6.10.0",
"mobx-react": "^9.1.0", "mobx-react": "^9.1.0",
"mobx-utils": "^6.0.8", "mobx-utils": "^6.0.8",

View File

@ -1,3 +1,4 @@
import clone from "lodash/clone";
import set from "lodash/set"; import set from "lodash/set";
import { makeObservable, observable, runInAction, action } from "mobx"; import { makeObservable, observable, runInAction, action } from "mobx";
import { TIssue, TInboxIssue, TInboxIssueStatus, TInboxDuplicateIssueDetails } from "@plane/types"; import { TIssue, TInboxIssue, TInboxIssueStatus, TInboxDuplicateIssueDetails } from "@plane/types";
@ -5,6 +6,7 @@ import { TIssue, TInboxIssue, TInboxIssueStatus, TInboxDuplicateIssueDetails } f
import { EInboxIssueStatus } from "@/helpers/inbox.helper"; import { EInboxIssueStatus } from "@/helpers/inbox.helper";
// services // services
import { InboxIssueService } from "@/services/inbox"; import { InboxIssueService } from "@/services/inbox";
import { IssueService } from "@/services/issue";
// root store // root store
import { RootStore } from "@/store/root.store"; import { RootStore } from "@/store/root.store";
@ -22,6 +24,8 @@ export interface IInboxIssueStore {
updateInboxIssueDuplicateTo: (issueId: string) => Promise<void>; // connecting the inbox issue to the project existing issue updateInboxIssueDuplicateTo: (issueId: string) => Promise<void>; // connecting the inbox issue to the project existing issue
updateInboxIssueSnoozeTill: (date: Date) => Promise<void>; // snooze the issue updateInboxIssueSnoozeTill: (date: Date) => Promise<void>; // snooze the issue
updateIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue updateIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue
updateProjectIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue
fetchIssueActivity: () => Promise<void>; // fetching the issue activity
} }
export class InboxIssueStore implements IInboxIssueStore { export class InboxIssueStore implements IInboxIssueStore {
@ -38,6 +42,7 @@ export class InboxIssueStore implements IInboxIssueStore {
projectId: string; projectId: string;
// services // services
inboxIssueService; inboxIssueService;
issueService;
constructor(workspaceSlug: string, projectId: string, data: TInboxIssue, private store: RootStore) { constructor(workspaceSlug: string, projectId: string, data: TInboxIssue, private store: RootStore) {
this.id = data.id; this.id = data.id;
@ -51,6 +56,7 @@ export class InboxIssueStore implements IInboxIssueStore {
this.projectId = projectId; this.projectId = projectId;
// services // services
this.inboxIssueService = new InboxIssueService(); this.inboxIssueService = new InboxIssueService();
this.issueService = new IssueService();
// observable variables should be defined after the initialization of the values // observable variables should be defined after the initialization of the values
makeObservable(this, { makeObservable(this, {
id: observable, id: observable,
@ -65,6 +71,8 @@ export class InboxIssueStore implements IInboxIssueStore {
updateInboxIssueDuplicateTo: action, updateInboxIssueDuplicateTo: action,
updateInboxIssueSnoozeTill: action, updateInboxIssueSnoozeTill: action,
updateIssue: action, updateIssue: action,
updateProjectIssue: action,
fetchIssueActivity: action,
}); });
} }
@ -72,12 +80,13 @@ export class InboxIssueStore implements IInboxIssueStore {
const previousData: Partial<TInboxIssue> = { const previousData: Partial<TInboxIssue> = {
status: this.status, status: this.status,
}; };
try { try {
if (!this.issue.id) return; if (!this.issue.id) return;
set(this, "status", status); const inboxIssue = await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, {
await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, {
status: status, status: status,
}); });
runInAction(() => set(this, "status", inboxIssue?.status));
} catch { } catch {
runInAction(() => set(this, "status", previousData.status)); runInAction(() => set(this, "status", previousData.status));
} }
@ -85,20 +94,19 @@ export class InboxIssueStore implements IInboxIssueStore {
updateInboxIssueDuplicateTo = async (issueId: string) => { updateInboxIssueDuplicateTo = async (issueId: string) => {
const inboxStatus = EInboxIssueStatus.DUPLICATE; const inboxStatus = EInboxIssueStatus.DUPLICATE;
const previousData: Partial<TInboxIssue> = { const previousData: Partial<TInboxIssue> = {
status: this.status, status: this.status,
duplicate_to: this.duplicate_to, duplicate_to: this.duplicate_to,
}; };
try { try {
if (!this.issue.id) return; if (!this.issue.id) return;
set(this, "status", inboxStatus);
set(this, "duplicate_to", issueId);
const issueResponse = await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, { const issueResponse = await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, {
status: inboxStatus, status: inboxStatus,
duplicate_to: issueId, duplicate_to: issueId,
}); });
runInAction(() => { runInAction(() => {
this.status = issueResponse.status;
this.duplicate_to = issueResponse.duplicate_to; this.duplicate_to = issueResponse.duplicate_to;
this.duplicate_issue_detail = issueResponse.duplicate_issue_detail; this.duplicate_issue_detail = issueResponse.duplicate_issue_detail;
}); });
@ -112,19 +120,21 @@ export class InboxIssueStore implements IInboxIssueStore {
updateInboxIssueSnoozeTill = async (date: Date) => { updateInboxIssueSnoozeTill = async (date: Date) => {
const inboxStatus = EInboxIssueStatus.SNOOZED; const inboxStatus = EInboxIssueStatus.SNOOZED;
const previousData: Partial<TInboxIssue> = { const previousData: Partial<TInboxIssue> = {
status: this.status, status: this.status,
snoozed_till: this.snoozed_till, snoozed_till: this.snoozed_till,
}; };
try { try {
if (!this.issue.id) return; if (!this.issue.id) return;
set(this, "status", inboxStatus); const issueResponse = await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, {
set(this, "snoozed_till", date);
await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, {
status: inboxStatus, status: inboxStatus,
snoozed_till: new Date(date), snoozed_till: new Date(date),
}); });
runInAction(() => {
this.status = issueResponse?.status;
this.snoozed_till = issueResponse?.snoozed_till ? new Date(issueResponse.snoozed_till) : undefined;
});
} catch { } catch {
runInAction(() => { runInAction(() => {
set(this, "status", previousData.status); set(this, "status", previousData.status);
@ -134,21 +144,49 @@ export class InboxIssueStore implements IInboxIssueStore {
}; };
updateIssue = async (issue: Partial<TIssue>) => { updateIssue = async (issue: Partial<TIssue>) => {
const inboxIssue = this.issue; const inboxIssue = clone(this.issue);
try { try {
if (!this.issue.id) return; if (!this.issue.id) return;
Object.keys(issue).forEach((key) => { Object.keys(issue).forEach((key) => {
const issueKey = key as keyof TIssue; const issueKey = key as keyof TIssue;
set(inboxIssue, issueKey, issue[issueKey]); set(this.issue, issueKey, issue[issueKey]);
}); });
await this.inboxIssueService.updateIssue(this.workspaceSlug, this.projectId, this.issue.id, issue); await this.inboxIssueService.updateIssue(this.workspaceSlug, this.projectId, this.issue.id, issue);
// fetching activity // fetching activity
await this.store.issue.issueDetail.fetchActivities(this.workspaceSlug, this.projectId, this.issue.id); this.fetchIssueActivity();
} catch { } catch {
Object.keys(issue).forEach((key) => { Object.keys(issue).forEach((key) => {
const issueKey = key as keyof TIssue; const issueKey = key as keyof TIssue;
set(inboxIssue, issueKey, inboxIssue[issueKey]); set(this.issue, issueKey, inboxIssue[issueKey]);
}); });
} }
}; };
updateProjectIssue = async (issue: Partial<TIssue>) => {
const inboxIssue = clone(this.issue);
try {
if (!this.issue.id) return;
Object.keys(issue).forEach((key) => {
const issueKey = key as keyof TIssue;
set(this.issue, issueKey, issue[issueKey]);
});
await this.issueService.patchIssue(this.workspaceSlug, this.projectId, this.issue.id, issue);
// fetching activity
this.fetchIssueActivity();
} catch {
Object.keys(issue).forEach((key) => {
const issueKey = key as keyof TIssue;
set(this.issue, issueKey, inboxIssue[issueKey]);
});
}
};
fetchIssueActivity = async () => {
try {
if (!this.issue.id) return;
await this.store.issue.issueDetail.fetchActivities(this.workspaceSlug, this.projectId, this.issue.id);
} catch {
console.error("Failed to fetch issue activity");
}
};
} }

View File

@ -31,7 +31,7 @@ type TLoader =
export interface IProjectInboxStore { export interface IProjectInboxStore {
currentTab: TInboxIssueCurrentTab; currentTab: TInboxIssueCurrentTab;
isLoading: TLoader; loader: TLoader;
error: { message: string; status: "init-error" | "pagination-error" } | undefined; error: { message: string; status: "init-error" | "pagination-error" } | undefined;
currentInboxProjectId: string; currentInboxProjectId: string;
inboxFilters: Partial<TInboxIssueFilter>; inboxFilters: Partial<TInboxIssueFilter>;
@ -42,7 +42,7 @@ export interface IProjectInboxStore {
getAppliedFiltersCount: number; getAppliedFiltersCount: number;
inboxIssuesArray: IInboxIssueStore[]; inboxIssuesArray: IInboxIssueStore[];
// helper actions // helper actions
getIssueInboxByIssueId: (issueId: string) => IInboxIssueStore | undefined; getIssueInboxByIssueId: (issueId: string) => IInboxIssueStore;
inboxIssueSorting: (issues: IInboxIssueStore[]) => IInboxIssueStore[]; inboxIssueSorting: (issues: IInboxIssueStore[]) => IInboxIssueStore[];
inboxIssueQueryParams: ( inboxIssueQueryParams: (
inboxFilters: Partial<TInboxIssueFilter>, inboxFilters: Partial<TInboxIssueFilter>,
@ -70,7 +70,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
PER_PAGE_COUNT = 10; PER_PAGE_COUNT = 10;
// observables // observables
currentTab: TInboxIssueCurrentTab = EInboxIssueCurrentTab.OPEN; currentTab: TInboxIssueCurrentTab = EInboxIssueCurrentTab.OPEN;
isLoading: TLoader = "init-loading"; loader: TLoader = "init-loading";
error: { message: string; status: "init-error" | "pagination-error" } | undefined = undefined; error: { message: string; status: "init-error" | "pagination-error" } | undefined = undefined;
currentInboxProjectId: string = ""; currentInboxProjectId: string = "";
inboxFilters: Partial<TInboxIssueFilter> = { inboxFilters: Partial<TInboxIssueFilter> = {
@ -88,7 +88,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
constructor(private store: RootStore) { constructor(private store: RootStore) {
makeObservable(this, { makeObservable(this, {
currentTab: observable.ref, currentTab: observable.ref,
isLoading: observable.ref, loader: observable.ref,
error: observable, error: observable,
currentInboxProjectId: observable.ref, currentInboxProjectId: observable.ref,
inboxFilters: observable, inboxFilters: observable,
@ -123,17 +123,17 @@ export class ProjectInboxStore implements IProjectInboxStore {
} }
get inboxIssuesArray() { get inboxIssuesArray() {
let appliedFilters =
this.currentTab === EInboxIssueCurrentTab.OPEN
? [EInboxIssueStatus.PENDING, EInboxIssueStatus.SNOOZED]
: [EInboxIssueStatus.ACCEPTED, EInboxIssueStatus.DECLINED, EInboxIssueStatus.DUPLICATE];
appliedFilters = appliedFilters.filter((filter) => this.inboxFilters?.status?.includes(filter));
return this.inboxIssueSorting( return this.inboxIssueSorting(
Object.values(this.inboxIssues || {}).filter((inbox) => Object.values(this.inboxIssues || {}).filter((inbox) => appliedFilters.includes(inbox.status))
(this.currentTab === EInboxIssueCurrentTab.OPEN
? [EInboxIssueStatus.PENDING, EInboxIssueStatus.SNOOZED]
: [EInboxIssueStatus.ACCEPTED, EInboxIssueStatus.DECLINED, EInboxIssueStatus.DUPLICATE]
).includes(inbox.status)
)
); );
} }
getIssueInboxByIssueId = computedFn((issueId: string) => this.inboxIssues?.[issueId] || undefined); getIssueInboxByIssueId = computedFn((issueId: string) => this.inboxIssues?.[issueId]);
// helpers // helpers
inboxIssueSorting = (issues: IInboxIssueStore[]) => { inboxIssueSorting = (issues: IInboxIssueStore[]) => {
@ -252,9 +252,9 @@ export class ProjectInboxStore implements IProjectInboxStore {
set(this, ["inboxIssues"], {}); set(this, ["inboxIssues"], {});
set(this, ["inboxIssuePaginationInfo"], undefined); set(this, ["inboxIssuePaginationInfo"], undefined);
} }
if (Object.keys(this.inboxIssues).length === 0) this.isLoading = "init-loading"; if (Object.keys(this.inboxIssues).length === 0) this.loader = "init-loading";
else this.isLoading = "mutation-loading"; else this.loader = "mutation-loading";
if (loadingType) this.isLoading = loadingType; if (loadingType) this.loader = loadingType;
const queryParams = this.inboxIssueQueryParams( const queryParams = this.inboxIssueQueryParams(
this.inboxFilters, this.inboxFilters,
@ -265,7 +265,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
const { results, ...paginationInfo } = await this.inboxIssueService.list(workspaceSlug, projectId, queryParams); const { results, ...paginationInfo } = await this.inboxIssueService.list(workspaceSlug, projectId, queryParams);
runInAction(() => { runInAction(() => {
this.isLoading = undefined; this.loader = undefined;
set(this, "inboxIssuePaginationInfo", paginationInfo); set(this, "inboxIssuePaginationInfo", paginationInfo);
if (results && results.length > 0) if (results && results.length > 0)
results.forEach((value: TInboxIssue) => { results.forEach((value: TInboxIssue) => {
@ -279,7 +279,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
}); });
} catch (error) { } catch (error) {
console.error("Error fetching the inbox issues", error); console.error("Error fetching the inbox issues", error);
this.isLoading = undefined; this.loader = undefined;
this.error = { this.error = {
message: "Error fetching the inbox issues please try again later.", message: "Error fetching the inbox issues please try again later.",
status: "init-error", status: "init-error",
@ -301,7 +301,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
(this.inboxIssuePaginationInfo?.total_results && (this.inboxIssuePaginationInfo?.total_results &&
this.inboxIssuesArray.length < this.inboxIssuePaginationInfo?.total_results)) this.inboxIssuesArray.length < this.inboxIssuePaginationInfo?.total_results))
) { ) {
this.isLoading = "pagination-loading"; this.loader = "pagination-loading";
const queryParams = this.inboxIssueQueryParams( const queryParams = this.inboxIssueQueryParams(
this.inboxFilters, this.inboxFilters,
@ -312,7 +312,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
const { results, ...paginationInfo } = await this.inboxIssueService.list(workspaceSlug, projectId, queryParams); const { results, ...paginationInfo } = await this.inboxIssueService.list(workspaceSlug, projectId, queryParams);
runInAction(() => { runInAction(() => {
this.isLoading = undefined; this.loader = undefined;
set(this, "inboxIssuePaginationInfo", paginationInfo); set(this, "inboxIssuePaginationInfo", paginationInfo);
if (results && results.length > 0) if (results && results.length > 0)
results.forEach((value: TInboxIssue) => { results.forEach((value: TInboxIssue) => {
@ -327,7 +327,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
} else set(this, ["inboxIssuePaginationInfo", "next_page_results"], false); } else set(this, ["inboxIssuePaginationInfo", "next_page_results"], false);
} catch (error) { } catch (error) {
console.error("Error fetching the inbox issues", error); console.error("Error fetching the inbox issues", error);
this.isLoading = undefined; this.loader = undefined;
this.error = { this.error = {
message: "Error fetching the paginated inbox issues please try again later.", message: "Error fetching the paginated inbox issues please try again later.",
status: "pagination-error", status: "pagination-error",
@ -348,7 +348,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
inboxIssueId: string inboxIssueId: string
): Promise<TInboxIssue> => { ): Promise<TInboxIssue> => {
try { try {
this.isLoading = "issue-loading"; this.loader = "issue-loading";
const inboxIssue = await this.inboxIssueService.retrieve(workspaceSlug, projectId, inboxIssueId); const inboxIssue = await this.inboxIssueService.retrieve(workspaceSlug, projectId, inboxIssueId);
const issueId = inboxIssue?.issue?.id || undefined; const issueId = inboxIssue?.issue?.id || undefined;
@ -362,12 +362,12 @@ export class ProjectInboxStore implements IProjectInboxStore {
await this.store.issue.issueDetail.fetchActivities(workspaceSlug, projectId, issueId); await this.store.issue.issueDetail.fetchActivities(workspaceSlug, projectId, issueId);
// fetching comments // fetching comments
await this.store.issue.issueDetail.fetchComments(workspaceSlug, projectId, issueId); await this.store.issue.issueDetail.fetchComments(workspaceSlug, projectId, issueId);
this.isLoading = undefined; this.loader = undefined;
} }
return inboxIssue; return inboxIssue;
} catch (error) { } catch (error) {
console.error("Error fetching the inbox issue with inbox issue id"); console.error("Error fetching the inbox issue with inbox issue id");
this.isLoading = undefined; this.loader = undefined;
throw error; throw error;
} }
}; };

View File

@ -5874,6 +5874,11 @@ lucide-react@^0.309.0:
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.309.0.tgz#7369893cb4b074a0a0b1d3acdc6fd9a8bdb5add1" resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.309.0.tgz#7369893cb4b074a0a0b1d3acdc6fd9a8bdb5add1"
integrity sha512-zNVPczuwFrCfksZH3zbd1UDE6/WYhYAdbe2k7CImVyPAkXLgIwbs6eXQ4loigqDnUFjyFYCI5jZ1y10Kqal0dg== integrity sha512-zNVPczuwFrCfksZH3zbd1UDE6/WYhYAdbe2k7CImVyPAkXLgIwbs6eXQ4loigqDnUFjyFYCI5jZ1y10Kqal0dg==
lucide-react@^0.368.0:
version "0.368.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.368.0.tgz#3c0ee63f4f7d30ae63b621b2b8f04f9e409ee6e7"
integrity sha512-soryVrCjheZs8rbXKdINw9B8iPi5OajBJZMJ1HORig89ljcOcEokKKAgGbg3QWxSXel7JwHOfDFUdDHAKyUAMw==
magic-string@^0.25.0, magic-string@^0.25.7: magic-string@^0.25.0, magic-string@^0.25.7:
version "0.25.9" version "0.25.9"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
@ -7815,8 +7820,16 @@ streamx@^2.15.0:
fast-fifo "^1.1.0" fast-fifo "^1.1.0"
queue-tick "^1.0.1" queue-tick "^1.0.1"
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: "string-width-cjs@npm:string-width@^4.2.0":
name string-width-cjs version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0:
version "4.2.3" version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -7892,8 +7905,14 @@ stringify-object@^3.3.0:
is-obj "^1.0.1" is-obj "^1.0.1"
is-regexp "^1.0.0" is-regexp "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: "strip-ansi-cjs@npm:strip-ansi@^6.0.1":
name strip-ansi-cjs version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==