chore: inbox issue restructure the components and store (#3456)

* chore: inbox-issues store and type updates

* chore: issue inbox payload change for GET and POST

* chore: issue inbox payload change for PATCH

* chore: inbox-issue new hooks and store updates

* chore: update inbox issue template.

* chore: UI root

* chore: sidebar issues render

* chore: inbox issue details page layout.

* chore: inbox issue filters

* chore: inbox issue status card.

* chore: add loader.

* chore: active inbox issue styles.

* chore: inbox filters

* chore: inbox applied filters UI

* chore: inbox issue approval header

* chore: inbox issue approval header operations

* chore: issue reaction and activity fetch in issue_inbox store

* chore: posthog enabled

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
This commit is contained in:
guru_sainath 2024-01-24 20:33:54 +05:30 committed by GitHub
parent 911211cf3d
commit b66f07845a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 1998 additions and 1457 deletions

View File

@ -111,6 +111,7 @@ from .inbox import (
InboxSerializer,
InboxIssueSerializer,
IssueStateInboxSerializer,
InboxIssueLiteSerializer,
)
from .analytic import AnalyticViewSerializer

View File

@ -60,6 +60,7 @@ class DynamicBaseSerializer(BaseSerializer):
CycleIssueSerializer,
IssueFlatSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer
)
# Expansion mapper
@ -80,9 +81,10 @@ class DynamicBaseSerializer(BaseSerializer):
"issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer,
"issue_relation": IssueRelationSerializer,
"issue_inbox" : InboxIssueLiteSerializer,
}
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation"] else False)
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation", "issue_inbox"] else False)
return self.fields
@ -103,6 +105,7 @@ class DynamicBaseSerializer(BaseSerializer):
LabelSerializer,
CycleIssueSerializer,
IssueRelationSerializer,
InboxIssueLiteSerializer
)
# Expansion mapper
@ -122,7 +125,8 @@ class DynamicBaseSerializer(BaseSerializer):
"labels": LabelSerializer,
"issue_cycle": CycleIssueSerializer,
"parent": IssueSerializer,
"issue_relation": IssueRelationSerializer
"issue_relation": IssueRelationSerializer,
"issue_inbox" : InboxIssueLiteSerializer,
}
# Check if field in expansion then expand the field
if expand in expansion:

View File

@ -88,39 +88,24 @@ class InboxIssueViewSet(BaseViewSet):
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(
Q(snoozed_till__gte=timezone.now())
| Q(snoozed_till__isnull=True),
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
inbox_id=self.kwargs.get("inbox_id"),
)
.select_related("issue", "workspace", "project")
)
def list(self, request, slug, project_id, inbox_id):
filters = issue_filters(request.query_params, "GET")
issues = (
return (
Issue.objects.filter(
issue_inbox__inbox_id=inbox_id,
workspace__slug=slug,
project_id=project_id,
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_inbox__inbox_id=self.kwargs.get("inbox_id")
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
.prefetch_related("labels", "assignees")
.prefetch_related(
Prefetch(
"issue_inbox",
queryset=InboxIssue.objects.only(
"status", "duplicate_to", "snoozed_till", "source"
),
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@ -135,16 +120,20 @@ class InboxIssueViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_inbox",
queryset=InboxIssue.objects.only(
"status", "duplicate_to", "snoozed_till", "source"
),
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
issues_data = IssueStateInboxSerializer(issues, many=True).data
).distinct()
def list(self, request, slug, project_id, inbox_id):
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters).order_by("issue_inbox__snoozed_till", "issue_inbox__status")
issues_data = IssueSerializer(issue_queryset, expand=self.expand, many=True).data
return Response(
issues_data,
status=status.HTTP_200_OK,
@ -211,7 +200,8 @@ class InboxIssueViewSet(BaseViewSet):
source=request.data.get("source", "in-app"),
)
serializer = IssueStateInboxSerializer(issue)
issue = (self.get_queryset().filter(pk=issue.id).first())
serializer = IssueSerializer(issue ,expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, inbox_id, issue_id):
@ -331,22 +321,20 @@ class InboxIssueViewSet(BaseViewSet):
if state is not None:
issue.state = state
issue.save()
issue = (self.get_queryset().filter(pk=issue_id).first())
serializer = IssueSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
else:
return Response(
InboxIssueSerializer(inbox_issue).data,
status=status.HTTP_200_OK,
)
issue = (self.get_queryset().filter(pk=issue_id).first())
serializer = IssueSerializer(issue ,expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, inbox_id, issue_id):
issue = Issue.objects.get(
pk=issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueStateInboxSerializer(issue)
issue = self.get_queryset().filter(pk=issue_id).first()
serializer = IssueSerializer(issue, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, inbox_id, issue_id):

View File

@ -68,7 +68,7 @@ from plane.bgtasks.project_invitation_task import project_invitation
class ProjectViewSet(WebhookMixin, BaseViewSet):
serializer_class = ProjectSerializer
serializer_class = ProjectListSerializer
model = Project
webhook_event = "project"
@ -76,11 +76,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
ProjectBasePermission,
]
def get_serializer_class(self, *args, **kwargs):
if self.action in ["update", "partial_update"]:
return ProjectSerializer
return ProjectDetailSerializer
def get_queryset(self):
return self.filter_queryset(
super()

View File

@ -0,0 +1,65 @@
import { TIssue } from "../issues/base";
export enum EInboxStatus {
PENDING = -2,
REJECT = -1,
SNOOZED = 0,
ACCEPTED = 1,
DUPLICATE = 2,
}
export type TInboxStatus =
| EInboxStatus.PENDING
| EInboxStatus.REJECT
| EInboxStatus.SNOOZED
| EInboxStatus.ACCEPTED
| EInboxStatus.DUPLICATE;
export type TInboxIssueDetail = {
id?: string;
source: "in-app";
status: TInboxStatus;
duplicate_to: string | undefined;
snoozed_till: Date | undefined;
};
export type TInboxIssueDetailMap = Record<
string,
Record<string, TInboxIssueDetail>
>; // inbox_id -> issue_id -> TInboxIssueDetail
export type TInboxIssueDetailIdMap = Record<string, string[]>; // inbox_id -> issue_id[]
export type TInboxIssueExtendedDetail = TIssue & {
issue_inbox: TInboxIssueDetail[];
};
// property type checks
export type TInboxPendingStatus = {
status: EInboxStatus.PENDING;
};
export type TInboxRejectStatus = {
status: EInboxStatus.REJECT;
};
export type TInboxSnoozedStatus = {
status: EInboxStatus.SNOOZED;
snoozed_till: Date;
};
export type TInboxAcceptedStatus = {
status: EInboxStatus.ACCEPTED;
};
export type TInboxDuplicateStatus = {
status: EInboxStatus.DUPLICATE;
duplicate_to: string; // issue_id
};
export type TInboxDetailedStatus =
| TInboxPendingStatus
| TInboxRejectStatus
| TInboxSnoozedStatus
| TInboxAcceptedStatus
| TInboxDuplicateStatus;

27
packages/types/src/inbox/inbox.d.ts vendored Normal file
View File

@ -0,0 +1,27 @@
export type TInboxIssueFilterOptions = {
priority: string[];
inbox_status: number[];
};
export type TInboxIssueQueryParams = "priority" | "inbox_status";
export type TInboxIssueFilters = { filters: TInboxIssueFilterOptions };
export type TInbox = {
id: string;
name: string;
description: string;
workspace: string;
project: string;
is_default: boolean;
view_props: TInboxIssueFilters;
created_by: string;
updated_by: string;
created_at: Date;
updated_at: Date;
pending_issue_count: number;
};
export type TInboxDetailMap = Record<string, TInbox>; // inbox_id -> TInbox
export type TInboxDetailIdMap = Record<string, string[]>; // project_id -> inbox_id[]

2
packages/types/src/inbox/root.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
export * from "./inbox";
export * from "./inbox-issue";

View File

@ -13,7 +13,11 @@ export * from "./pages";
export * from "./ai";
export * from "./estimate";
export * from "./importer";
// FIXME: Remove this after development and the refactor/mobx-store-issue branch is stable
export * from "./inbox";
export * from "./inbox/root";
export * from "./analytics";
export * from "./calendar";
export * from "./notifications";

View File

@ -51,12 +51,14 @@ export const ProjectInboxHeader: FC = observer(() => {
</div>
</div>
<div className="flex items-center gap-2">
<CreateInboxIssueModal isOpen={createIssueModal} onClose={() => setCreateIssueModal(false)} />
<Button variant="primary" prependIcon={<Plus />} size="sm" onClick={() => setCreateIssueModal(true)}>
Add Issue
</Button>
</div>
{currentProjectDetails?.inbox_view && (
<div className="flex items-center gap-2">
<CreateInboxIssueModal isOpen={createIssueModal} onClose={() => setCreateIssueModal(false)} />
<Button variant="primary" prependIcon={<Plus />} size="sm" onClick={() => setCreateIssueModal(true)}>
Add Issue
</Button>
</div>
)}
</div>
);
});

View File

@ -32,7 +32,6 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.PROJECT);
const { inboxesList, isInboxEnabled, getInboxId } = useInbox();
const {
commandPalette: { toggleCreateIssueModal },
eventTracker: { setTrackElement },
@ -43,6 +42,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
const { currentProjectDetails } = useProject();
const { projectStates } = useProjectState();
const { projectLabels } = useLabel();
const { getInboxesByProjectId, getInboxById } = useInbox();
const activeLayout = issueFilters?.displayFilters?.layout;
@ -89,7 +89,9 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
[workspaceSlug, projectId, updateFilters]
);
const inboxDetails = projectId ? inboxesList?.[projectId]?.[0] : undefined;
const inboxesMap = currentProjectDetails?.inbox_view ? getInboxesByProjectId(currentProjectDetails.id) : undefined;
const inboxDetails = inboxesMap && inboxesMap.length > 0 ? getInboxById(inboxesMap[0]) : undefined;
const deployUrl = process.env.NEXT_PUBLIC_DEPLOY_URL;
const canUserCreateIssue =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
@ -190,14 +192,15 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
handleDisplayPropertiesUpdate={handleDisplayProperties}
/>
</FiltersDropdown>
{projectId && isInboxEnabled && inboxDetails && (
<Link href={`/${workspaceSlug}/projects/${projectId}/inbox/${getInboxId(projectId)}`}>
{currentProjectDetails?.inbox_view && inboxDetails && (
<Link href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxDetails?.id}`}>
<span>
<Button variant="neutral-primary" size="sm" className="relative">
Inbox
{inboxDetails.pending_issue_count > 0 && (
{inboxDetails?.pending_issue_count > 0 && (
<span className="absolute -right-1.5 -top-1.5 h-4 w-4 rounded-full border border-custom-sidebar-border-200 bg-custom-sidebar-background-80 text-custom-text-100">
{inboxDetails.pending_issue_count}
{inboxDetails?.pending_issue_count}
</span>
)}
</Button>

View File

@ -1,247 +0,0 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import DatePicker from "react-datepicker";
import { Popover } from "@headlessui/react";
// hooks
import { useUser, useInboxIssues } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import {
AcceptIssueModal,
DeclineIssueModal,
DeleteInboxIssueModal,
FiltersDropdown,
SelectDuplicateInboxIssueModal,
} from "components/inbox";
// ui
import { Button } from "@plane/ui";
// icons
import { CheckCircle2, Clock, FileStack, Inbox, Trash2, XCircle } from "lucide-react";
// types
import type { TInboxStatus } from "@plane/types";
import { EUserProjectRoles } from "constants/project";
export const InboxActionsHeader = observer(() => {
// states
const [date, setDate] = useState(new Date());
const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false);
const [acceptIssueModal, setAcceptIssueModal] = useState(false);
const [declineIssueModal, setDeclineIssueModal] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
// router
const router = useRouter();
const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query;
// store hooks
const { updateIssueStatus, getIssueById } = useInboxIssues();
const {
currentUser,
membership: { currentProjectRole },
} = useUser();
// toast
const { setToastAlert } = useToast();
// derived values
const issue = getIssueById(inboxId as string, inboxIssueId as string);
const markInboxStatus = async (data: TInboxStatus) => {
if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId || !issue) return;
await updateIssueStatus(
workspaceSlug.toString(),
projectId.toString(),
inboxId.toString(),
issue.issue_inbox[0].id!,
data
).catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong while updating inbox status. Please try again.",
})
);
};
// const currentIssueIndex = issuesList?.findIndex((issue) => issue.issue_inbox[0].id === inboxIssueId) ?? 0;
useEffect(() => {
if (!issue?.issue_inbox[0].snoozed_till) return;
setDate(new Date(issue.issue_inbox[0].snoozed_till));
}, [issue]);
const issueStatus = issue?.issue_inbox[0].status;
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
return (
<>
{issue && (
<>
<SelectDuplicateInboxIssueModal
isOpen={selectDuplicateIssue}
onClose={() => setSelectDuplicateIssue(false)}
value={issue?.issue_inbox[0].duplicate_to}
onSubmit={(dupIssueId) => {
markInboxStatus({
status: 2,
duplicate_to: dupIssueId,
}).finally(() => setSelectDuplicateIssue(false));
}}
/>
<AcceptIssueModal
data={issue}
isOpen={acceptIssueModal}
onClose={() => setAcceptIssueModal(false)}
onSubmit={async () => {
await markInboxStatus({
status: 1,
}).finally(() => setAcceptIssueModal(false));
}}
/>
<DeclineIssueModal
data={issue}
isOpen={declineIssueModal}
onClose={() => setDeclineIssueModal(false)}
onSubmit={async () => {
await markInboxStatus({
status: -1,
}).finally(() => setDeclineIssueModal(false));
}}
/>
<DeleteInboxIssueModal data={issue} isOpen={deleteIssueModal} onClose={() => setDeleteIssueModal(false)} />
</>
)}
<div className="grid grid-cols-4 divide-x divide-custom-border-200 border-b border-custom-border-200">
<div className="col-span-1 flex justify-between p-4">
<div className="flex items-center gap-2">
<Inbox className="text-custom-text-200" size={16} strokeWidth={2} />
<h3 className="font-medium">Inbox</h3>
</div>
<FiltersDropdown />
</div>
{inboxIssueId && (
<div className="col-span-3 flex items-center justify-between gap-4 px-4">
{/* <div className="flex items-center gap-x-2">
<button
type="button"
className="rounded border border-custom-border-200 bg-custom-background-90 p-1.5 hover:bg-custom-background-80"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "ArrowUp" });
document.dispatchEvent(e);
}}
>
<ChevronUp size={14} strokeWidth={2} />
</button>
<button
type="button"
className="rounded border border-custom-border-200 bg-custom-background-90 p-1.5 hover:bg-custom-background-80"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "ArrowDown" });
document.dispatchEvent(e);
}}
>
<ChevronDown size={14} strokeWidth={2} />
</button>
<div className="text-sm">
{currentIssueIndex + 1}/{issuesList?.length ?? 0}
</div>
</div> */}
<div className="flex flex-wrap items-center gap-3">
{isAllowed && (issueStatus === 0 || issueStatus === -2) && (
<div className="flex-shrink-0">
<Popover className="relative">
<Popover.Button as="button" type="button">
<Button variant="neutral-primary" prependIcon={<Clock size={14} strokeWidth={2} />} size="sm">
Snooze
</Button>
</Popover.Button>
<Popover.Panel className="absolute right-0 z-10 mt-2 w-80 rounded-md bg-custom-background-100 p-2 shadow-lg">
{({ close }) => (
<div className="flex h-full w-full flex-col gap-y-1">
<DatePicker
selected={date ? new Date(date) : null}
onChange={(val) => {
if (!val) return;
setDate(val);
}}
dateFormat="dd-MM-yyyy"
minDate={tomorrow}
inline
/>
<Button
variant="primary"
onClick={() => {
close();
markInboxStatus({
status: 0,
snoozed_till: new Date(date),
});
}}
>
Snooze
</Button>
</div>
)}
</Popover.Panel>
</Popover>
</div>
)}
{isAllowed && issueStatus === -2 && (
<div className="flex-shrink-0">
<Button
variant="neutral-primary"
size="sm"
prependIcon={<FileStack size={14} strokeWidth={2} />}
onClick={() => setSelectDuplicateIssue(true)}
>
Mark as duplicate
</Button>
</div>
)}
{isAllowed && (issueStatus === 0 || issueStatus === -2) && (
<div className="flex-shrink-0">
<Button
variant="neutral-primary"
size="sm"
prependIcon={<CheckCircle2 className="text-green-500" size={14} strokeWidth={2} />}
onClick={() => setAcceptIssueModal(true)}
>
Accept
</Button>
</div>
)}
{isAllowed && issueStatus === -2 && (
<div className="flex-shrink-0">
<Button
variant="neutral-primary"
size="sm"
prependIcon={<XCircle className="text-red-500" size={14} strokeWidth={2} />}
onClick={() => setDeclineIssueModal(true)}
>
Decline
</Button>
</div>
)}
{(isAllowed || currentUser?.id === issue?.created_by) && (
<div className="flex-shrink-0">
<Button
variant="neutral-primary"
size="sm"
prependIcon={<Trash2 className="text-red-500" size={14} strokeWidth={2} />}
onClick={() => setDeleteIssueModal(true)}
>
Delete
</Button>
</div>
)}
</div>
</div>
)}
</div>
</>
);
});

View File

@ -0,0 +1,329 @@
import { FC, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import DatePicker from "react-datepicker";
import { Popover } from "@headlessui/react";
// hooks
import { useApplication, useUser, useInboxIssues, useIssueDetail, useWorkspace } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import {
AcceptIssueModal,
DeclineIssueModal,
DeleteInboxIssueModal,
SelectDuplicateInboxIssueModal,
} from "components/inbox";
// ui
import { Button } from "@plane/ui";
// icons
import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Inbox, Trash2, XCircle } from "lucide-react";
// types
import type { TInboxStatus, TInboxDetailedStatus } from "@plane/types";
import { EUserProjectRoles } from "constants/project";
type TInboxIssueActionsHeader = {
workspaceSlug: string;
projectId: string;
inboxId: string;
inboxIssueId: string | undefined;
};
type TInboxIssueOperations = {
updateInboxIssueStatus: (data: TInboxStatus) => Promise<void>;
removeInboxIssue: () => Promise<void>;
};
export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((props) => {
const { workspaceSlug, projectId, inboxId, inboxIssueId } = props;
// router
const router = useRouter();
// hooks
const {
eventTracker: { postHogEventTracker },
} = useApplication();
const { currentWorkspace } = useWorkspace();
const {
issues: { getInboxIssuesByInboxId, getInboxIssueByIssueId, updateInboxIssueStatus, removeInboxIssue },
} = useInboxIssues();
const {
issue: { getIssueById },
} = useIssueDetail();
const {
currentUser,
membership: { currentProjectRole },
} = useUser();
const { setToastAlert } = useToast();
// states
const [date, setDate] = useState(new Date());
const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false);
const [acceptIssueModal, setAcceptIssueModal] = useState(false);
const [declineIssueModal, setDeclineIssueModal] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
// derived values
const inboxIssues = getInboxIssuesByInboxId(inboxId);
const issueStatus = (inboxIssueId && inboxId && getInboxIssueByIssueId(inboxId, inboxIssueId)) || undefined;
const issue = (inboxIssueId && getIssueById(inboxIssueId)) || undefined;
const currentIssueIndex = inboxIssues?.findIndex((issue) => issue === inboxIssueId) ?? 0;
const inboxIssueOperations: TInboxIssueOperations = useMemo(
() => ({
updateInboxIssueStatus: async (data: TInboxDetailedStatus) => {
try {
if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId) throw new Error("Missing required parameters");
await updateInboxIssueStatus(workspaceSlug, projectId, inboxId, inboxIssueId, data);
} catch (error) {
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong while updating inbox status. Please try again.",
});
}
},
removeInboxIssue: async () => {
try {
if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId || !currentWorkspace)
throw new Error("Missing required parameters");
await removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId);
postHogEventTracker(
"ISSUE_DELETED",
{
state: "SUCCESS",
},
{
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
}
);
router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
});
} catch (error) {
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong while deleting inbox issue. Please try again.",
});
postHogEventTracker(
"ISSUE_DELETED",
{
state: "FAILED",
},
{
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
}
);
}
},
}),
[
currentWorkspace,
workspaceSlug,
projectId,
inboxId,
inboxIssueId,
updateInboxIssueStatus,
removeInboxIssue,
setToastAlert,
postHogEventTracker,
router,
]
);
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
useEffect(() => {
if (!issueStatus || !issueStatus.snoozed_till) return;
setDate(new Date(issueStatus.snoozed_till));
}, [issueStatus]);
if (!issueStatus || !issue || !inboxIssues) return <></>;
return (
<>
{issue && (
<>
<SelectDuplicateInboxIssueModal
isOpen={selectDuplicateIssue}
onClose={() => setSelectDuplicateIssue(false)}
value={issueStatus.duplicate_to}
onSubmit={(dupIssueId) => {
inboxIssueOperations
.updateInboxIssueStatus({
status: 2,
duplicate_to: dupIssueId,
})
.finally(() => setSelectDuplicateIssue(false));
}}
/>
<AcceptIssueModal
data={issue}
isOpen={acceptIssueModal}
onClose={() => setAcceptIssueModal(false)}
onSubmit={async () => {
await inboxIssueOperations
.updateInboxIssueStatus({
status: 1,
})
.finally(() => setAcceptIssueModal(false));
}}
/>
<DeclineIssueModal
data={issue}
isOpen={declineIssueModal}
onClose={() => setDeclineIssueModal(false)}
onSubmit={async () => {
await inboxIssueOperations
.updateInboxIssueStatus({
status: -1,
})
.finally(() => setDeclineIssueModal(false));
}}
/>
<DeleteInboxIssueModal
data={issue}
isOpen={deleteIssueModal}
onClose={() => setDeleteIssueModal(false)}
onSubmit={async () => {
await inboxIssueOperations.removeInboxIssue().finally(() => setDeclineIssueModal(false));
}}
/>
</>
)}
{inboxIssueId && (
<div className="px-4 w-full h-full relative flex items-center gap-2 justify-between">
<div className="flex items-center gap-x-2">
<button
type="button"
className="rounded border border-custom-border-200 bg-custom-background-90 p-1.5 hover:bg-custom-background-80"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "ArrowUp" });
document.dispatchEvent(e);
}}
>
<ChevronUp size={14} strokeWidth={2} />
</button>
<button
type="button"
className="rounded border border-custom-border-200 bg-custom-background-90 p-1.5 hover:bg-custom-background-80"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "ArrowDown" });
document.dispatchEvent(e);
}}
>
<ChevronDown size={14} strokeWidth={2} />
</button>
<div className="text-sm">
{currentIssueIndex + 1}/{inboxIssues?.length ?? 0}
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
{isAllowed && (issueStatus.status === 0 || issueStatus.status === -2) && (
<div className="flex-shrink-0">
<Popover className="relative">
<Popover.Button as="button" type="button">
<Button variant="neutral-primary" prependIcon={<Clock size={14} strokeWidth={2} />} size="sm">
Snooze
</Button>
</Popover.Button>
<Popover.Panel className="absolute right-0 z-10 mt-2 w-80 rounded-md bg-custom-background-100 p-2 shadow-lg">
{({ close }) => (
<div className="flex h-full w-full flex-col gap-y-1">
<DatePicker
selected={date ? new Date(date) : null}
onChange={(val) => {
if (!val) return;
setDate(val);
}}
dateFormat="dd-MM-yyyy"
minDate={tomorrow}
inline
/>
<Button
variant="primary"
onClick={() => {
close();
inboxIssueOperations.updateInboxIssueStatus({
status: 0,
snoozed_till: new Date(date),
});
}}
>
Snooze
</Button>
</div>
)}
</Popover.Panel>
</Popover>
</div>
)}
{isAllowed && issueStatus.status === -2 && (
<div className="flex-shrink-0">
<Button
variant="neutral-primary"
size="sm"
prependIcon={<FileStack size={14} strokeWidth={2} />}
onClick={() => setSelectDuplicateIssue(true)}
>
Mark as duplicate
</Button>
</div>
)}
{isAllowed && (issueStatus.status === 0 || issueStatus.status === -2) && (
<div className="flex-shrink-0">
<Button
variant="neutral-primary"
size="sm"
prependIcon={<CheckCircle2 className="text-green-500" size={14} strokeWidth={2} />}
onClick={() => setAcceptIssueModal(true)}
>
Accept
</Button>
</div>
)}
{isAllowed && issueStatus.status === -2 && (
<div className="flex-shrink-0">
<Button
variant="neutral-primary"
size="sm"
prependIcon={<XCircle className="text-red-500" size={14} strokeWidth={2} />}
onClick={() => setDeclineIssueModal(true)}
>
Decline
</Button>
</div>
)}
{(isAllowed || currentUser?.id === issue?.created_by) && (
<div className="flex-shrink-0">
<Button
variant="neutral-primary"
size="sm"
prependIcon={<Trash2 className="text-red-500" size={14} strokeWidth={2} />}
onClick={() => setDeleteIssueModal(true)}
>
Delete
</Button>
</div>
)}
</div>
</div>
)}
</>
);
});

View File

@ -0,0 +1,55 @@
import React from "react";
// hooks
import { useInboxIssues } from "hooks/store";
// constants
import { INBOX_STATUS } from "constants/inbox";
type Props = {
workspaceSlug: string;
projectId: string;
inboxId: string;
issueId: string;
iconSize?: number;
showDescription?: boolean;
};
export const InboxIssueStatus: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, inboxId, issueId, iconSize = 18, showDescription = false } = props;
// hooks
const {
issues: { getInboxIssueByIssueId },
} = useInboxIssues();
const inboxIssueDetail = getInboxIssueByIssueId(inboxId, issueId);
if (!inboxIssueDetail) return <></>;
const inboxIssueStatusDetail = INBOX_STATUS.find((s) => s.status === inboxIssueDetail.status);
if (!inboxIssueStatusDetail) return <></>;
const isSnoozedDatePassed =
inboxIssueDetail.status === 0 && new Date(inboxIssueDetail.snoozed_till ?? "") < new Date();
return (
<div
className={`flex items-center ${inboxIssueStatusDetail.textColor(isSnoozedDatePassed)} ${
showDescription
? `p-3 gap-2 text-sm rounded-md border ${inboxIssueStatusDetail.bgColor(
isSnoozedDatePassed
)} ${inboxIssueStatusDetail.borderColor(isSnoozedDatePassed)} `
: "w-full justify-end gap-1 text-xs"
}`}
>
<inboxIssueStatusDetail.icon size={iconSize} strokeWidth={2} />
{showDescription ? (
inboxIssueStatusDetail.description(
workspaceSlug,
projectId,
inboxIssueDetail.duplicate_to ?? "",
new Date(inboxIssueDetail.snoozed_till ?? "")
)
) : (
<span>{inboxIssueStatusDetail.title}</span>
)}
</div>
);
};

View File

@ -1,8 +1,12 @@
export * from "./modals";
export * from "./actions-header";
export * from "./filters-dropdown";
export * from "./filters-list";
export * from "./issue-activity";
export * from "./issue-card";
export * from "./issues-list-sidebar";
export * from "./main-content";
export * from "./inbox-issue-actions";
export * from "./inbox-issue-status";
export * from "./sidebar/root";
export * from "./sidebar/filter/filter-selection";
export * from "./sidebar/filter/applied-filters";
export * from "./sidebar/inbox-list";
export * from "./sidebar/inbox-list-item";

View File

@ -1,130 +0,0 @@
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
import { observer } from "mobx-react-lite";
// hooks
import { useApplication, useUser, useWorkspace } from "hooks/store";
// components
import { AddComment, IssueActivitySection } from "components/issues";
// services
import { IssueService, IssueCommentService } from "services/issue";
// hooks
import useToast from "hooks/use-toast";
// types
import { TIssue, IIssueActivity } from "@plane/types";
// fetch-keys
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
type Props = { issueDetails: TIssue };
// services
const issueService = new IssueService();
const issueCommentService = new IssueCommentService();
export const InboxIssueActivity: React.FC<Props> = observer(({ issueDetails }) => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store hooks
const {
eventTracker: { postHogEventTracker },
} = useApplication();
const { currentUser } = useUser();
const { currentWorkspace } = useWorkspace();
const { setToastAlert } = useToast();
const { data: issueActivity, mutate: mutateIssueActivity } = useSWR(
workspaceSlug && projectId && issueDetails ? PROJECT_ISSUES_ACTIVITY(issueDetails.id) : null,
workspaceSlug && projectId && issueDetails
? () => issueService.getIssueActivities(workspaceSlug.toString(), projectId.toString(), issueDetails.id)
: null
);
const handleCommentUpdate = async (commentId: string, data: Partial<any>) => {
if (!workspaceSlug || !projectId || !issueDetails.id || !currentUser) return;
await issueCommentService
.patchIssueComment(workspaceSlug.toString(), projectId.toString(), issueDetails.id, commentId, data)
.then((res) => {
mutateIssueActivity();
postHogEventTracker(
"COMMENT_UPDATED",
{
...res,
state: "SUCCESS",
},
{
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
}
);
});
};
const handleCommentDelete = async (commentId: string) => {
if (!workspaceSlug || !projectId || !issueDetails.id || !currentUser) return;
mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false);
await issueCommentService
.deleteIssueComment(workspaceSlug.toString(), projectId.toString(), issueDetails.id, commentId)
.then(() => {
mutateIssueActivity();
postHogEventTracker(
"COMMENT_DELETED",
{
state: "SUCCESS",
},
{
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
}
);
});
};
const handleAddComment = async (formData: IIssueActivity) => {
if (!workspaceSlug || !issueDetails || !currentUser) return;
/* FIXME: Replace this with the new issue activity component --issue-detail-- */
// await issueCommentService
// .createIssueComment(workspaceSlug.toString(), issueDetails.project_id, issueDetails.id, formData)
// .then((res) => {
// mutate(PROJECT_ISSUES_ACTIVITY(issueDetails.id));
// postHogEventTracker(
// "COMMENT_ADDED",
// {
// ...res,
// state: "SUCCESS",
// },
// {
// isGrouping: true,
// groupType: "Workspace_metrics",
// groupId: currentWorkspace?.id!,
// }
// );
// })
// .catch(() =>
// setToastAlert({
// type: "error",
// title: "Error!",
// message: "Comment could not be posted. Please try again.",
// })
// );
};
return (
<div className="space-y-5">
{/* FIXME: Replace this with the new issue activity component --issue-detail-- */}
{/* <h3 className="text-lg text-custom-text-100">Comments/Activity</h3>
<IssueActivitySection
activity={issueActivity}
handleCommentUpdate={handleCommentUpdate}
handleCommentDelete={handleCommentDelete}
/>
<AddComment onSubmit={handleAddComment} /> */}
</div>
);
});

View File

@ -1,96 +0,0 @@
import { useRouter } from "next/router";
import Link from "next/link";
import { AlertTriangle, CalendarDays, CheckCircle2, Clock, Copy, XCircle } from "lucide-react";
// ui
import { Tooltip, PriorityIcon } from "@plane/ui";
// hooks
import { useInboxIssues, useProject } from "hooks/store";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
// constants
import { INBOX_STATUS } from "constants/inbox";
type Props = {
active: boolean;
issueId: string;
};
export const InboxIssueCard: React.FC<Props> = (props) => {
const { active } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId, inboxId } = router.query;
// store hooks
const { getIssueById } = useInboxIssues();
const { getProjectById } = useProject();
// derived values
const issue = getIssueById(inboxId as string, props.issueId);
const issueStatus = issue?.issue_inbox[0].status;
if (!issue) return null;
return (
<Link href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${issue.issue_inbox[0].id}`}>
<div
id={issue.id}
className={`relative min-h-[5rem] cursor-pointer select-none space-y-3 border-b border-custom-border-200 px-4 py-2 hover:bg-custom-primary/5 ${
active ? "bg-custom-primary/5" : " "
} ${issue.issue_inbox[0].status !== -2 ? "opacity-60" : ""}`}
>
<div className="flex items-center gap-x-2">
<p className="flex-shrink-0 text-xs text-custom-text-200">
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
</p>
<h5 className="truncate text-sm">{issue.name}</h5>
</div>
<div className="flex flex-wrap items-center gap-2">
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`}>
<PriorityIcon priority={issue.priority ?? null} className="h-3.5 w-3.5" />
</Tooltip>
<Tooltip tooltipHeading="Created on" tooltipContent={`${renderFormattedDate(issue.created_at ?? "")}`}>
<div className="flex items-center gap-1 rounded border border-custom-border-200 px-2 py-[0.19rem] text-xs text-custom-text-200 shadow-sm">
<CalendarDays size={12} strokeWidth={1.5} />
<span>{renderFormattedDate(issue.created_at ?? "")}</span>
</div>
</Tooltip>
</div>
<div
className={`flex w-full items-center justify-end gap-1 text-xs ${
issueStatus === 0 && new Date(issue.issue_inbox[0].snoozed_till ?? "") < new Date()
? "text-red-500"
: INBOX_STATUS.find((s) => s.value === issueStatus)?.textColor
}`}
>
{issueStatus === -2 ? (
<>
<AlertTriangle size={14} strokeWidth={2} />
<span>Pending</span>
</>
) : issueStatus === -1 ? (
<>
<XCircle size={14} strokeWidth={2} />
<span>Declined</span>
</>
) : issueStatus === 0 ? (
<>
<Clock size={14} strokeWidth={2} />
<span>
{new Date(issue.issue_inbox[0].snoozed_till ?? "") < new Date() ? "Snoozed date passed" : "Snoozed"}
</span>
</>
) : issueStatus === 1 ? (
<>
<CheckCircle2 size={14} strokeWidth={2} />
<span>Accepted</span>
</>
) : (
<>
<Copy size={14} strokeWidth={2} />
<span>Duplicate</span>
</>
)}
</div>
</div>
</Link>
);
};

View File

@ -1,45 +0,0 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useInboxIssues } from "hooks/store";
// components
import { InboxIssueCard, InboxFiltersList } from "components/inbox";
// ui
import { Loader } from "@plane/ui";
export const InboxIssuesListSidebar = observer(() => {
const router = useRouter();
const { inboxIssueId } = router.query;
const { currentInboxIssueIds: currentInboxIssues } = useInboxIssues();
const issuesList = currentInboxIssues;
return (
<div className="flex h-full flex-col overflow-hidden">
<InboxFiltersList />
{issuesList ? (
issuesList.length > 0 ? (
<div className="h-full divide-y divide-custom-border-200 overflow-auto">
{issuesList.map((id) => (
<InboxIssueCard key={id} active={id === inboxIssueId} issueId={id} />
))}
</div>
) : (
<div className="grid h-full place-items-center p-4 text-center text-sm text-custom-text-200">
{/* TODO: add filtersLength logic here */}
{/* {filtersLength > 0 && "No issues found for the selected filters. Try changing the filters."} */}
</div>
)
) : (
<Loader className="space-y-4 p-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</div>
);
});

View File

@ -1,292 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
import { useForm } from "react-hook-form";
import { AlertTriangle, CheckCircle2, Clock, Copy, ExternalLink, Inbox, XCircle } from "lucide-react";
// hooks
import { useProjectState, useUser, useInboxIssues } from "hooks/store";
// components
import {
IssueDescriptionForm,
// FIXME: have to replace this once the issue details page is ready --issue-detail--
// IssueDetailsSidebar,
// IssueReaction,
IssueUpdateStatus,
} from "components/issues";
import { InboxIssueActivity } from "components/inbox";
// ui
import { Loader, StateGroupIcon } from "@plane/ui";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
// types
import { IInboxIssue, TIssue } from "@plane/types";
import { EUserProjectRoles } from "constants/project";
const defaultValues: Partial<IInboxIssue> = {
name: "",
description_html: "",
assignee_ids: [],
priority: "low",
target_date: new Date().toString(),
label_ids: [],
};
export const InboxMainContent: React.FC = observer(() => {
// states
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
// router
const router = useRouter();
const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query;
// store hooks
const { currentInboxIssueIds: currentInboxIssues, fetchIssueDetails, getIssueById, updateIssue } = useInboxIssues();
const {
currentUser,
membership: { currentProjectRole },
} = useUser();
const { projectStates } = useProjectState();
// form info
const { reset, control, watch } = useForm<TIssue>({
defaultValues,
});
useSWR(
workspaceSlug && projectId && inboxId && inboxIssueId ? `INBOX_ISSUE_DETAILS_${inboxIssueId.toString()}` : null,
workspaceSlug && projectId && inboxId && inboxIssueId
? () =>
fetchIssueDetails(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), inboxIssueId.toString())
: null
);
const issuesList = currentInboxIssues;
const issueDetails = inboxIssueId ? getIssueById(inboxId as string, inboxIssueId.toString()) : undefined;
const currentIssueState = projectStates?.find((s) => s.id === issueDetails?.state_id);
const submitChanges = useCallback(
async (formData: Partial<IInboxIssue>) => {
if (!workspaceSlug || !projectId || !inboxIssueId || !inboxId || !issueDetails) return;
await updateIssue(
workspaceSlug.toString(),
projectId.toString(),
inboxId.toString(),
issueDetails.issue_inbox[0].id,
formData
);
},
[workspaceSlug, inboxIssueId, projectId, inboxId, issueDetails, updateIssue]
);
// const onKeyDown = useCallback(
// (e: KeyboardEvent) => {
// if (!issuesList || !inboxIssueId) return;
// const currentIssueIndex = issuesList.findIndex((issue) => issue.issue_inbox[0].id === inboxIssueId);
// switch (e.key) {
// case "ArrowUp":
// Router.push({
// pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
// query: {
// inboxIssueId:
// currentIssueIndex === 0
// ? issuesList[issuesList.length - 1].issue_inbox[0].id
// : issuesList[currentIssueIndex - 1].issue_inbox[0].id,
// },
// });
// break;
// case "ArrowDown":
// Router.push({
// pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
// query: {
// inboxIssueId:
// currentIssueIndex === issuesList.length - 1
// ? issuesList[0].issue_inbox[0].id
// : issuesList[currentIssueIndex + 1].issue_inbox[0].id,
// },
// });
// break;
// default:
// break;
// }
// },
// [workspaceSlug, projectId, inboxIssueId, inboxId, issuesList]
// );
// useEffect(() => {
// document.addEventListener("keydown", onKeyDown);
// return () => {
// document.removeEventListener("keydown", onKeyDown);
// };
// }, [onKeyDown]);
useEffect(() => {
if (!issueDetails || !inboxIssueId) return;
reset({
...issueDetails,
assignee_ids: issueDetails.assignee_ids ?? issueDetails.assignee_ids,
label_ids: issueDetails.label_ids ?? issueDetails.label_ids,
});
}, [issueDetails, reset, inboxIssueId]);
const issueStatus = issueDetails?.issue_inbox[0].status;
if (!inboxIssueId)
return (
<div className="grid h-full place-items-center p-4 text-custom-text-200">
<div className="grid h-full place-items-center">
<div className="my-5 flex flex-col items-center gap-4">
<Inbox size={60} strokeWidth={1.5} />
{issuesList && issuesList.length > 0 ? (
<span className="text-custom-text-200">
{issuesList?.length} issues found. Select an issue from the sidebar to view its details.
</span>
) : (
<span className="text-custom-text-200">No issues found</span>
)}
</div>
</div>
</div>
);
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
return (
<>
{issueDetails ? (
<div className="flex h-full divide-x overflow-auto">
<div className="h-full basis-2/3 space-y-3 overflow-auto p-5">
<div
className={`flex items-center gap-2 rounded-md border p-3 text-sm ${
issueStatus === -2
? "border-yellow-500 bg-yellow-500/10 text-yellow-500"
: issueStatus === -1
? "border-red-500 bg-red-500/10 text-red-500"
: issueStatus === 0
? new Date(issueDetails.issue_inbox[0].snoozed_till ?? "") < new Date()
? "border-red-500 bg-red-500/10 text-red-500"
: "border-gray-500 bg-gray-500/10 text-custom-text-200"
: issueStatus === 1
? "border-green-500 bg-green-500/10 text-green-500"
: issueStatus === 2
? "border-gray-500 bg-gray-500/10 text-custom-text-200"
: ""
}`}
>
{issueStatus === -2 ? (
<>
<AlertTriangle size={18} strokeWidth={2} />
<p>This issue is still pending.</p>
</>
) : issueStatus === -1 ? (
<>
<XCircle size={18} strokeWidth={2} />
<p>This issue has been declined.</p>
</>
) : issueStatus === 0 ? (
<>
<Clock size={18} strokeWidth={2} />
{new Date(issueDetails.issue_inbox[0].snoozed_till ?? "") < new Date() ? (
<p>
This issue was snoozed till {renderFormattedDate(issueDetails.issue_inbox[0].snoozed_till ?? "")}.
</p>
) : (
<p>
This issue has been snoozed till{" "}
{renderFormattedDate(issueDetails.issue_inbox[0].snoozed_till ?? "")}.
</p>
)}
</>
) : issueStatus === 1 ? (
<>
<CheckCircle2 size={18} strokeWidth={2} />
<p>This issue has been accepted.</p>
</>
) : issueStatus === 2 ? (
<>
<Copy size={18} strokeWidth={2} />
<p className="flex items-center gap-1">
This issue has been marked as a duplicate of
<a
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueDetails.issue_inbox[0].duplicate_to}`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 underline"
>
this issue <ExternalLink size={12} strokeWidth={2} />
</a>
.
</p>
</>
) : null}
</div>
<div className="mb-2.5 flex items-center">
{currentIssueState && (
<StateGroupIcon
className="mr-3 h-4 w-4"
stateGroup={currentIssueState.group}
color={currentIssueState.color}
/>
)}
<IssueUpdateStatus isSubmitting={isSubmitting} issueDetail={issueDetails} />
</div>
{/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */}
{/* <div>
<IssueDescriptionForm
setIsSubmitting={(value) => setIsSubmitting(value)}
isSubmitting={isSubmitting}
workspaceSlug={workspaceSlug as string}
issue={{
name: issueDetails.name,
description_html: issueDetails.description_html,
id: issueDetails.id,
}}
handleFormSubmit={submitChanges}
isAllowed={isAllowed || currentUser?.id === issueDetails.created_by}
/>
</div> */}
{/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */}
{/* {workspaceSlug && projectId && (
<IssueReaction
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={issueDetails.id}
/>
)} */}
<InboxIssueActivity issueDetails={issueDetails} />
</div>
<div className="basis-1/3 space-y-5 border-custom-border-200 py-5">
{/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */}
{/* <IssueDetailsSidebar
control={control}
issueDetail={issueDetails}
submitChanges={submitChanges}
watch={watch}
fieldsToShow={["assignee", "priority", "estimate", "dueDate", "label", "state"]}
/> */}
</div>
</div>
) : (
<Loader className="flex h-full gap-5 p-5">
<div className="basis-2/3 space-y-2">
<Loader.Item height="30px" width="40%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="40%" />
</div>
<div className="basis-1/3 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
)}
</>
);
});

View File

@ -1,16 +1,15 @@
import React, { useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// icons
import { CheckCircle } from "lucide-react";
// ui
import { Button } from "@plane/ui";
// types
import type { IInboxIssue } from "@plane/types";
import type { TIssue } from "@plane/types";
import { useProject } from "hooks/store";
type Props = {
data: IInboxIssue;
data: TIssue;
isOpen: boolean;
onClose: () => void;
onSubmit: () => Promise<void>;
@ -28,7 +27,6 @@ export const AcceptIssueModal: React.FC<Props> = ({ isOpen, onClose, data, onSub
const handleAccept = () => {
setIsAccepting(true);
onSubmit().finally(() => setIsAccepting(false));
};

View File

@ -58,7 +58,9 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string;
// store hooks
const { createIssue } = useInboxIssues();
const {
issues: { createInboxIssue },
} = useInboxIssues();
const {
config: { envConfig },
eventTracker: { postHogEventTracker },
@ -85,10 +87,10 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
const handleFormSubmit = async (formData: Partial<TIssue>) => {
if (!workspaceSlug || !projectId || !inboxId) return;
await createIssue(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), formData)
await createInboxIssue(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), formData)
.then((res) => {
if (!createMore) {
router.push(`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${res.issue_inbox[0].id}`);
router.push(`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${res.id}`);
handleClose();
} else reset(defaultValues);
postHogEventTracker(

View File

@ -1,16 +1,15 @@
import React, { useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// icons
import { AlertTriangle } from "lucide-react";
// ui
import { Button } from "@plane/ui";
// types
import type { IInboxIssue } from "@plane/types";
import type { TIssue } from "@plane/types";
import { useProject } from "hooks/store";
type Props = {
data: IInboxIssue;
data: TIssue;
isOpen: boolean;
onClose: () => void;
onSubmit: () => Promise<void>;
@ -28,7 +27,6 @@ export const DeclineIssueModal: React.FC<Props> = ({ isOpen, onClose, data, onSu
const handleDecline = () => {
setIsDeclining(true);
onSubmit().finally(() => setIsDeclining(false));
};

View File

@ -1,39 +1,27 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Dialog, Transition } from "@headlessui/react";
// hooks
import { useApplication, useProject, useWorkspace } from "hooks/store";
import useToast from "hooks/use-toast";
import { useProject } from "hooks/store";
// icons
import { AlertTriangle } from "lucide-react";
// ui
import { Button } from "@plane/ui";
// types
import type { IInboxIssue } from "@plane/types";
import { useInboxIssues } from "hooks/store/use-inbox-issues";
import type { TIssue } from "@plane/types";
type Props = {
data: IInboxIssue;
data: TIssue;
isOpen: boolean;
onClose: () => void;
onSubmit: () => Promise<void>;
};
export const DeleteInboxIssueModal: React.FC<Props> = observer(({ isOpen, onClose, data }) => {
export const DeleteInboxIssueModal: React.FC<Props> = observer(({ isOpen, onClose, onSubmit, data }) => {
// states
const [isDeleting, setIsDeleting] = useState(false);
// router
const router = useRouter();
const { workspaceSlug, projectId, inboxId } = router.query;
// store hooks
const { deleteIssue } = useInboxIssues();
const {
eventTracker: { postHogEventTracker },
} = useApplication();
const { currentWorkspace } = useWorkspace();
const { getProjectById } = useProject();
const { setToastAlert } = useToast();
const { getProjectById } = useProject();
const handleClose = () => {
setIsDeleting(false);
@ -41,59 +29,13 @@ export const DeleteInboxIssueModal: React.FC<Props> = observer(({ isOpen, onClos
};
const handleDelete = () => {
if (!workspaceSlug || !projectId || !inboxId) return;
setIsDeleting(true);
deleteIssue(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), data.issue_inbox[0].id)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Issue deleted successfully.",
});
postHogEventTracker(
"ISSUE_DELETED",
{
state: "SUCCESS",
},
{
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
}
);
// remove inboxIssueId from the url
router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
});
handleClose();
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Issue could not be deleted. Please try again.",
});
postHogEventTracker(
"ISSUE_DELETED",
{
state: "FAILED",
},
{
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
}
);
})
.finally(() => setIsDeleting(false));
onSubmit().finally(() => setIsDeleting(false));
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@ -137,7 +79,7 @@ export const DeleteInboxIssueModal: React.FC<Props> = observer(({ isOpen, onClos
</p>
</span>
<div className="flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button variant="danger" size="sm" tabIndex={1} onClick={handleDelete} loading={isDeleting}>

View File

@ -1,66 +1,73 @@
import { useRouter } from "next/router";
import { FC } from "react";
import { observer } from "mobx-react-lite";
// mobx store
import { useInboxFilters } from "hooks/store";
import { useInboxIssues } from "hooks/store";
// icons
import { X } from "lucide-react";
import { PriorityIcon } from "@plane/ui";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { IInboxFilterOptions, TIssuePriorities } from "@plane/types";
import { TInboxIssueFilterOptions, TIssuePriorities } from "@plane/types";
// constants
import { INBOX_STATUS } from "constants/inbox";
export const InboxFiltersList = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId, inboxId } = router.query;
type TInboxIssueAppliedFilter = { workspaceSlug: string; projectId: string; inboxId: string };
const { inboxFilters, updateInboxFilters } = useInboxFilters();
export const IssueStatusLabel = ({ status }: { status: number }) => {
const issueStatusDetail = INBOX_STATUS.find((s) => s.status === status);
const filters = inboxId ? inboxFilters[inboxId.toString()]?.filters : undefined;
if (!issueStatusDetail) return <></>;
const handleUpdateFilter = (filter: Partial<IInboxFilterOptions>) => {
return (
<div className="relative flex items-center gap-1">
<div className={issueStatusDetail.textColor(false)}>
<issueStatusDetail.icon size={12} />
</div>
<div>{issueStatusDetail.title}</div>
</div>
);
};
export const InboxIssueAppliedFilter: FC<TInboxIssueAppliedFilter> = observer((props) => {
const { workspaceSlug, projectId, inboxId } = props;
// hooks
const {
filters: { inboxFilters, updateInboxFilters },
} = useInboxIssues();
const filters = inboxFilters?.filters;
const handleUpdateFilter = (filter: Partial<TInboxIssueFilterOptions>) => {
if (!workspaceSlug || !projectId || !inboxId) return;
updateInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), filter);
};
const handleClearAllFilters = () => {
if (!workspaceSlug || !projectId || !inboxId) return;
const newFilters: IInboxFilterOptions = {};
Object.keys(filters ?? {}).forEach((key) => {
newFilters[key as keyof IInboxFilterOptions] = null;
});
const newFilters: TInboxIssueFilterOptions = { priority: [], inbox_status: [] };
updateInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), newFilters);
};
let filtersLength = 0;
Object.keys(filters ?? {}).forEach((key) => {
const filterKey = key as keyof IInboxFilterOptions;
const filterKey = key as keyof TInboxIssueFilterOptions;
if (filters?.[filterKey] && Array.isArray(filters[filterKey])) filtersLength += (filters[filterKey] ?? []).length;
});
if (!filters || filtersLength <= 0) return null;
if (!filters || filtersLength <= 0) return <></>;
return (
<div className="flex flex-wrap items-center gap-2 p-3 text-[0.65rem]">
<div className="relative flex flex-wrap items-center gap-2 p-3 text-[0.65rem] border-b border-custom-border-100">
{Object.keys(filters).map((key) => {
const filterKey = key as keyof IInboxFilterOptions;
const filterKey = key as keyof TInboxIssueFilterOptions;
if (filters[filterKey])
if (filters[filterKey].length > 0)
return (
<div
key={key}
className="flex items-center gap-x-2 rounded-full border border-custom-border-200 bg-custom-background-80 px-2 py-1"
>
<span className="capitalize text-custom-text-200">{replaceUnderscoreIfSnakeCase(key)}:</span>
{filters[filterKey] === null || (filters[filterKey]?.length ?? 0) <= 0 ? (
{filters[filterKey]?.length < 0 ? (
<span className="inline-flex items-center px-2 py-0.5 font-medium">None</span>
) : (
<div className="space-x-2">
@ -81,9 +88,12 @@ export const InboxFiltersList = observer(() => {
: "bg-custom-background-90 text-custom-text-200"
}`}
>
<span>
<PriorityIcon priority={priority as TIssuePriorities} />
</span>
<div className="relative flex items-center gap-1">
<div>
<PriorityIcon priority={priority as TIssuePriorities} size={14} />
</div>
<div>{priority}</div>
</div>
<button
type="button"
className="cursor-pointer"
@ -101,7 +111,7 @@ export const InboxFiltersList = observer(() => {
type="button"
onClick={() =>
handleUpdateFilter({
priority: null,
priority: [],
})
}
>
@ -115,7 +125,7 @@ export const InboxFiltersList = observer(() => {
key={status}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-2 py-0.5 capitalize text-custom-text-200"
>
<span>{INBOX_STATUS.find((s) => s.value === status)?.label}</span>
<IssueStatusLabel status={status} />
<button
type="button"
className="cursor-pointer"
@ -133,7 +143,7 @@ export const InboxFiltersList = observer(() => {
type="button"
onClick={() =>
handleUpdateFilter({
inbox_status: null,
inbox_status: [],
})
}
>

View File

@ -1,30 +1,31 @@
import { useRouter } from "next/router";
import { FC } from "react";
import { observer } from "mobx-react-lite";
// mobx store
import { useInboxFilters } from "hooks/store";
import { useInboxIssues } from "hooks/store";
// ui
import { MultiLevelDropdown } from "components/ui";
// icons
import { PriorityIcon } from "@plane/ui";
// types
import { IInboxFilterOptions } from "@plane/types";
import { TInboxIssueFilterOptions } from "@plane/types";
// constants
import { INBOX_STATUS } from "constants/inbox";
import { ISSUE_PRIORITIES } from "constants/issue";
export const FiltersDropdown: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId, inboxId } = router.query;
type TInboxIssueFilterSelection = { workspaceSlug: string; projectId: string; inboxId: string };
const { inboxFilters, updateInboxFilters } = useInboxFilters();
export const InboxIssueFilterSelection: FC<TInboxIssueFilterSelection> = observer((props) => {
const { workspaceSlug, projectId, inboxId } = props;
// hooks
const {
filters: { inboxFilters, updateInboxFilters },
} = useInboxIssues();
const filters = inboxId ? inboxFilters[inboxId.toString()]?.filters : undefined;
const filters = inboxFilters?.filters;
let filtersLength = 0;
Object.keys(filters ?? {}).forEach((key) => {
const filterKey = key as keyof IInboxFilterOptions;
const filterKey = key as keyof TInboxIssueFilterOptions;
if (filters?.[filterKey] && Array.isArray(filters[filterKey])) filtersLength += (filters[filterKey] ?? []).length;
});
@ -35,7 +36,7 @@ export const FiltersDropdown: React.FC = observer(() => {
onSelect={(option) => {
if (!workspaceSlug || !projectId || !inboxId) return;
const key = option.key as keyof IInboxFilterOptions;
const key = option.key as keyof TInboxIssueFilterOptions;
const currentValue: any[] = filters?.[key] ?? [];
const valueExists = currentValue.includes(option.value);
@ -74,20 +75,28 @@ export const FiltersDropdown: React.FC = observer(() => {
{
id: "inbox_status",
label: "Status",
value: INBOX_STATUS.map((status) => status.value),
value: INBOX_STATUS.map((status) => status.status),
hasChildren: true,
children: INBOX_STATUS.map((status) => ({
id: status.key,
label: status.label,
id: status.status.toString(),
label: (
<div className="relative inline-flex gap-2 items-center">
<div className={status.textColor(false)}>
<status.icon size={12} />
</div>
<div>{status.title}</div>
</div>
),
value: {
key: "inbox_status",
value: status.value,
value: status.status,
},
selected: filters?.inbox_status?.includes(status.value),
selected: filters?.inbox_status?.includes(status.status),
})),
},
]}
/>
{filtersLength > 0 && (
<div className="absolute -right-2 -top-2 z-10 grid h-4 w-4 place-items-center rounded-full border border-custom-border-200 bg-custom-background-80 text-[0.65rem] text-custom-text-100">
<span>{filtersLength}</span>

View File

@ -0,0 +1,100 @@
import { FC, useEffect } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useRouter } from "next/router";
// icons
import { CalendarDays } from "lucide-react";
// hooks
import { useInboxIssues, useIssueDetail, useProject } from "hooks/store";
// ui
import { Tooltip, PriorityIcon } from "@plane/ui";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
// components
import { InboxIssueStatus } from "components/inbox/inbox-issue-status";
type TInboxIssueListItem = {
workspaceSlug: string;
projectId: string;
inboxId: string;
issueId: string;
};
export const InboxIssueListItem: FC<TInboxIssueListItem> = observer((props) => {
const { workspaceSlug, projectId, inboxId, issueId } = props;
// router
const router = useRouter();
const { inboxIssueId } = router.query;
// hooks
const { getProjectById } = useProject();
const {
issues: { getInboxIssueByIssueId },
} = useInboxIssues();
const {
issue: { getIssueById },
} = useIssueDetail();
const inboxIssueDetail = getInboxIssueByIssueId(inboxId, issueId);
const issue = getIssueById(issueId);
if (!issue || !inboxIssueDetail) return <></>;
useEffect(() => {
if (issueId === inboxIssueId) {
setTimeout(() => {
const issueItemCard = document.getElementById(`inbox-issue-list-item-${issueId}`);
if (issueItemCard)
issueItemCard.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, 200);
}
}, [issueId, inboxIssueId]);
return (
<>
<Link
id={`inbox-issue-list-item-${issue.id}`}
key={`${inboxId}_${issueId}`}
href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${issueId}`}
>
<div
className={`relative min-h-[5rem]select-none space-y-3 border-b border-custom-border-200 px-4 py-2 hover:bg-custom-primary/5 cursor-pointer ${
inboxIssueId === issueId ? "bg-custom-primary/5" : " "
} ${inboxIssueDetail.status !== -2 ? "opacity-60" : ""}`}
>
<div className="flex items-center justify-between gap-x-2">
<div className="flex items-center gap-x-2">
<p className="flex-shrink-0 text-xs text-custom-text-200">
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
</p>
<h5 className="truncate text-sm">{issue.name}</h5>
</div>
<div>
<InboxIssueStatus
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxId={inboxId}
issueId={issueId}
iconSize={14}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`}>
<PriorityIcon priority={issue.priority ?? null} className="h-3.5 w-3.5" />
</Tooltip>
<Tooltip tooltipHeading="Created on" tooltipContent={`${renderFormattedDate(issue.created_at ?? "")}`}>
<div className="flex items-center gap-1 rounded border border-custom-border-200 px-2 py-[0.19rem] text-xs text-custom-text-200 shadow-sm">
<CalendarDays size={12} strokeWidth={1.5} />
<span>{renderFormattedDate(issue.created_at ?? "")}</span>
</div>
</Tooltip>
</div>
</div>
</Link>
</>
);
});

View File

@ -0,0 +1,27 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useInboxIssues } from "hooks/store";
// components
import { InboxIssueListItem } from "../";
type TInboxIssueList = { workspaceSlug: string; projectId: string; inboxId: string };
export const InboxIssueList: FC<TInboxIssueList> = observer((props) => {
const { workspaceSlug, projectId, inboxId } = props;
// hooks
const {
issues: { getInboxIssuesByInboxId },
} = useInboxIssues();
const inboxIssueIds = getInboxIssuesByInboxId(inboxId);
if (!inboxIssueIds) return <></>;
return (
<div className="overflow-y-auto w-full h-full">
{inboxIssueIds.map((issueId) => (
<InboxIssueListItem workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} issueId={issueId} />
))}
</div>
);
});

View File

@ -0,0 +1,38 @@
import { FC } from "react";
import { Inbox } from "lucide-react";
// components
import { InboxIssueList, InboxIssueFilterSelection, InboxIssueAppliedFilter } from "../";
type TInboxSidebarRoot = {
workspaceSlug: string;
projectId: string;
inboxId: string;
};
export const InboxSidebarRoot: FC<TInboxSidebarRoot> = (props) => {
const { workspaceSlug, projectId, inboxId } = props;
return (
<div className="relative flex flex-col w-full h-full">
<div className="flex-shrink-0 w-full h-[50px] relative flex justify-between items-center gap-2 p-2 px-3 border-b border-custom-border-100">
<div className="relative flex items-center gap-1">
<div className="relative w-6 h-6 flex justify-center items-center rounded bg-custom-background-80">
<Inbox className="w-4 h-4" />
</div>
<div className="font-medium">Inbox</div>
</div>
<div className="z-[999999]">
<InboxIssueFilterSelection workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} />
</div>
</div>
<div className="w-full h-auto">
<InboxIssueAppliedFilter workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} />
</div>
<div className="w-full h-full overflow-hidden">
<InboxIssueList workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} />
</div>
</div>
);
};

View File

@ -35,8 +35,8 @@ export const IssueCycleSelect: React.FC<TIssueCycleSelect> = observer((props) =>
const handleIssueCycleChange = async (cycleId: string | null) => {
if (!issue || issue.cycle_id === cycleId) return;
setIsUpdating(true);
if (cycleId) await issueOperations.addIssueToCycle(workspaceSlug, projectId, cycleId, [issueId]);
else await issueOperations.removeIssueFromCycle(workspaceSlug, projectId, issue.cycle_id ?? "", issueId);
if (cycleId) await issueOperations.addIssueToCycle?.(workspaceSlug, projectId, cycleId, [issueId]);
else await issueOperations.removeIssueFromCycle?.(workspaceSlug, projectId, issue.cycle_id ?? "", issueId);
setIsUpdating(false);
};

View File

@ -0,0 +1,3 @@
export * from "./root"
export * from "./main-content"
export * from "./sidebar"

View File

@ -0,0 +1,84 @@
import { useState } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { useIssueDetail, useProjectState, useUser } from "hooks/store";
// components
import { IssueDescriptionForm, IssueUpdateStatus, TIssueOperations } from "components/issues";
import { IssueReaction } from "../reactions";
import { IssueActivity } from "../issue-activity";
import { InboxIssueStatus } from "../../../inbox/inbox-issue-status";
// ui
import { StateGroupIcon } from "@plane/ui";
type Props = {
workspaceSlug: string;
projectId: string;
inboxId: string;
issueId: string;
issueOperations: TIssueOperations;
is_editable: boolean;
};
export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, inboxId, issueId, issueOperations, is_editable } = props;
// states
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
// hooks
const { currentUser } = useUser();
const { projectStates } = useProjectState();
const {
issue: { getIssueById },
} = useIssueDetail();
const issue = getIssueById(issueId);
if (!issue) return <></>;
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);
return (
<>
<div className="rounded-lg space-y-4">
<InboxIssueStatus
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxId={inboxId}
issueId={issueId}
showDescription={true}
/>
<div className="mb-2.5 flex items-center">
{currentIssueState && (
<StateGroupIcon
className="mr-3 h-4 w-4"
stateGroup={currentIssueState.group}
color={currentIssueState.color}
/>
)}
<IssueUpdateStatus isSubmitting={isSubmitting} issueDetail={issue} />
</div>
<IssueDescriptionForm
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
setIsSubmitting={(value) => setIsSubmitting(value)}
isSubmitting={isSubmitting}
issue={issue}
issueOperations={issueOperations}
disabled={!is_editable}
/>
{currentUser && (
<IssueReaction
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
currentUser={currentUser}
/>
)}
</div>
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={!is_editable} />
</>
);
});

View File

@ -0,0 +1,150 @@
import { FC, useMemo } from "react";
import useSWR from "swr";
// components
import { InboxIssueMainContent } from "./main-content";
import { InboxIssueDetailsSidebar } from "./sidebar";
// hooks
import { useInboxIssues, useIssueDetail, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// types
import { TIssue } from "@plane/types";
import { TIssueOperations } from "../root";
// ui
import { Loader } from "@plane/ui";
// constants
import { EUserProjectRoles } from "constants/project";
export type TInboxIssueDetailRoot = {
workspaceSlug: string;
projectId: string;
inboxId: string;
issueId: string;
};
export const InboxIssueDetailRoot: FC<TInboxIssueDetailRoot> = (props) => {
const { workspaceSlug, projectId, inboxId, issueId } = props;
// hooks
const {
issues: { fetchInboxIssueById, updateInboxIssue, removeInboxIssue },
} = useInboxIssues();
const {
issue: { getIssueById },
} = useIssueDetail();
const { setToastAlert } = useToast();
const {
membership: { currentProjectRole },
} = useUser();
const issueOperations: TIssueOperations = useMemo(
() => ({
fetch: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await fetchInboxIssueById(workspaceSlug, projectId, inboxId, issueId);
} catch (error) {
console.error("Error fetching the parent issue");
}
},
update: async (
workspaceSlug: string,
projectId: string,
issueId: string,
data: Partial<TIssue>,
showToast: boolean = true
) => {
try {
await updateInboxIssue(workspaceSlug, projectId, inboxId, issueId, data);
if (showToast) {
setToastAlert({
title: "Issue updated successfully",
type: "success",
message: "Issue updated successfully",
});
}
} catch (error) {
setToastAlert({
title: "Issue update failed",
type: "error",
message: "Issue update failed",
});
}
},
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await removeInboxIssue(workspaceSlug, projectId, inboxId, issueId);
setToastAlert({
title: "Issue deleted successfully",
type: "success",
message: "Issue deleted successfully",
});
} catch (error) {
setToastAlert({
title: "Issue delete failed",
type: "error",
message: "Issue delete failed",
});
}
},
}),
[inboxId, fetchInboxIssueById, updateInboxIssue, removeInboxIssue, setToastAlert]
);
useSWR(
workspaceSlug && projectId && inboxId && issueId
? `INBOX_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxId}_${issueId}`
: null,
async () => {
if (workspaceSlug && projectId && inboxId && issueId) {
await issueOperations.fetch(workspaceSlug, projectId, issueId);
}
}
);
// checking if issue is editable, based on user role
const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
// issue details
const issue = getIssueById(issueId);
return (
<>
{issue ? (
<div className="flex h-full overflow-hidden">
<div className="h-full w-2/3 space-y-5 divide-y-2 divide-custom-border-300 overflow-y-auto p-5">
<InboxIssueMainContent
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxId={inboxId}
issueId={issueId}
issueOperations={issueOperations}
is_editable={is_editable}
/>
</div>
<div className="h-full w-1/3 space-y-5 overflow-hidden border-l border-custom-border-300 py-5">
<InboxIssueDetailsSidebar
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
issueOperations={issueOperations}
is_editable={is_editable}
/>
</div>
</div>
) : (
<Loader className="flex h-full gap-5 p-5">
<div className="basis-2/3 space-y-2">
<Loader.Item height="30px" width="40%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="40%" />
</div>
<div className="basis-1/3 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
)}
</>
);
};

View File

@ -0,0 +1,149 @@
import React from "react";
// import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { CalendarDays, Signal, Tag } from "lucide-react";
// hooks
import { useIssueDetail, useProject } from "hooks/store";
// components
import { IssueLabel, TIssueOperations } from "components/issues";
import { PriorityDropdown, ProjectMemberDropdown, StateDropdown } from "components/dropdowns";
// ui
import { CustomDatePicker } from "components/ui";
// icons
import { DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui";
// types
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
issueOperations: TIssueOperations;
is_editable: boolean;
};
export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, issueOperations, is_editable } = props;
// router
// FIXME: Check if we need this. Previously it was used to render Project Identifier conditionally.
// const router = useRouter();
// const { inboxIssueId } = router.query;
// store hooks
const { getProjectById } = useProject();
const {
issue: { getIssueById },
} = useIssueDetail();
const issue = getIssueById(issueId);
if (!issue) return <></>;
const projectDetails = issue ? getProjectById(issue.project_id) : null;
const minDate = issue.start_date ? new Date(issue.start_date) : null;
minDate?.setDate(minDate.getDate());
return (
<div className="flex h-full w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
<div className="flex items-center justify-between px-5 pb-3">
<div className="flex items-center gap-x-2">
<StateGroupIcon className="h-4 w-4" stateGroup="backlog" color="#ff7700" />
<h4 className="text-lg font-medium text-custom-text-300">
{projectDetails?.identifier}-{issue?.sequence_id}
</h4>
</div>
</div>
<div className="h-full w-full overflow-y-auto px-5">
<div className={`divide-y-2 divide-custom-border-200 ${!is_editable ? "opacity-60" : ""}`}>
<div className="py-1">
{/* State */}
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
<p>State</p>
</div>
<div className="h-5 sm:w-1/2">
<StateDropdown
value={issue?.state_id ?? undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })}
projectId={projectId?.toString() ?? ""}
disabled={!is_editable}
buttonVariant="background-with-text"
/>
</div>
</div>
{/* Assignee */}
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
<p>Assignees</p>
</div>
<div className="h-5 sm:w-1/2">
<ProjectMemberDropdown
value={issue?.assignee_ids ?? undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
disabled={!is_editable}
projectId={projectId?.toString() ?? ""}
placeholder="Assignees"
multiple
buttonVariant={issue?.assignee_ids?.length > 0 ? "transparent-without-text" : "background-with-text"}
buttonClassName={issue?.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""}
/>
</div>
</div>
{/* Priority */}
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
<Signal className="h-4 w-4 flex-shrink-0" />
<p>Priority</p>
</div>
<div className="h-5 sm:w-1/2">
<PriorityDropdown
value={issue?.priority || undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })}
disabled={!is_editable}
buttonVariant="background-with-text"
/>
</div>
</div>
</div>
</div>
<div className={`divide-y-2 divide-custom-border-200 ${!is_editable ? "opacity-60" : ""}`}>
<div className="py-1">
{/* Due Date */}
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
<CalendarDays className="h-4 w-4 flex-shrink-0" />
<p>Due date</p>
</div>
<div className="sm:basis-1/2">
<CustomDatePicker
placeholder="Due date"
value={issue.target_date || undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { target_date: val })}
className="border-none bg-custom-background-80"
minDate={minDate ?? undefined}
disabled={!is_editable}
/>
</div>
</div>
{/* Labels */}
<div className={`flex flex-wrap items-start py-2 ${!is_editable ? "opacity-60" : ""}`}>
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
<Tag className="h-4 w-4 flex-shrink-0" />
<p>Label</p>
</div>
<div className="space-y-1 sm:w-1/2">
<IssueLabel
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={!is_editable}
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
});

View File

@ -35,8 +35,8 @@ export const IssueModuleSelect: React.FC<TIssueModuleSelect> = observer((props)
const handleIssueModuleChange = async (moduleId: string | null) => {
if (!issue || issue.module_id === moduleId) return;
setIsUpdating(true);
if (moduleId) await issueOperations.addIssueToModule(workspaceSlug, projectId, moduleId, [issueId]);
else await issueOperations.removeIssueFromModule(workspaceSlug, projectId, issue.module_id ?? "", issueId);
if (moduleId) await issueOperations.addIssueToModule?.(workspaceSlug, projectId, moduleId, [issueId]);
else await issueOperations.removeIssueFromModule?.(workspaceSlug, projectId, issue.module_id ?? "", issueId);
setIsUpdating(false);
};

View File

@ -27,10 +27,15 @@ export type TIssueOperations = {
showToast?: boolean
) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise<void>;
removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise<void>;
addIssueToCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
removeIssueFromCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
addIssueToModule?: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise<void>;
removeIssueFromModule?: (
workspaceSlug: string,
projectId: string,
moduleId: string,
issueId: string
) => Promise<void>;
};
export type TIssueDetailRoot = {

View File

@ -1,44 +0,0 @@
export const INBOX_STATUS = [
{
key: "pending",
label: "Pending",
value: -2,
textColor: "text-yellow-500",
bgColor: "bg-yellow-500/10",
borderColor: "border-yellow-500",
},
{
key: "declined",
label: "Declined",
value: -1,
textColor: "text-red-500",
bgColor: "bg-red-500/10",
borderColor: "border-red-500",
},
{
key: "snoozed",
label: "Snoozed",
value: 0,
textColor: "text-custom-text-200",
bgColor: "bg-gray-500/10",
borderColor: "border-gray-500",
},
{
key: "accepted",
label: "Accepted",
value: 1,
textColor: "text-green-500",
bgColor: "bg-green-500/10",
borderColor: "border-green-500",
},
{
key: "duplicate",
label: "Duplicate",
value: 2,
textColor: "text-custom-text-200",
bgColor: "bg-gray-500/10",
borderColor: "border-gray-500",
},
];
export const INBOX_ISSUE_SOURCE = "in-app";

86
web/constants/inbox.tsx Normal file
View File

@ -0,0 +1,86 @@
// icons
import { AlertTriangle, CheckCircle2, Clock, Copy, ExternalLink, LucideIcon, XCircle } from "lucide-react";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
export const INBOX_STATUS: {
key: string;
status: number;
icon: LucideIcon;
title: string;
description: (workspaceSlug: string, projectId: string, issueId: string, snoozedTillDate: Date) => JSX.Element;
textColor: (snoozeDatePassed: boolean) => string;
bgColor: (snoozeDatePassed: boolean) => string;
borderColor: (snoozeDatePassed: boolean) => string;
}[] = [
{
key: "pending",
status: -2,
icon: AlertTriangle,
title: "Pending",
description: () => <p>This issue is still pending.</p>,
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-yellow-500"),
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-yellow-500/10"),
borderColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "border-yellow-500"),
},
{
key: "declined",
status: -1,
icon: XCircle,
title: "Declined",
description: () => <p>This issue has been declined.</p>,
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-red-500"),
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-red-500/10"),
borderColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "border-red-500"),
},
{
key: "snoozed",
status: 0,
icon: Clock,
title: "Snoozed",
description: (workspaceSlug: string, projectId: string, issueId: string, snoozedTillDate: Date = new Date()) =>
snoozedTillDate < new Date() ? (
<p>This issue was snoozed till {renderFormattedDate(snoozedTillDate)}.</p>
) : (
<p>This issue has been snoozed till {renderFormattedDate(snoozedTillDate)}.</p>
),
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "text-red-500" : "text-custom-text-200"),
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "bg-red-500/10" : "bg-gray-500/10"),
borderColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "border-red-500" : "border-gray-500"),
},
{
key: "accepted",
status: 1,
icon: CheckCircle2,
title: "Accepted",
description: () => <p>This issue has been accepted.</p>,
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-green-500"),
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-green-500/10"),
borderColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "border-green-500"),
},
{
key: "duplicate",
status: 2,
icon: Copy,
title: "Duplicate",
description: (workspaceSlug: string, projectId: string, issueId: string) => (
<p className="flex items-center gap-1">
This issue has been marked as a duplicate of
<a
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueId}`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 underline"
>
this issue <ExternalLink size={12} strokeWidth={2} />
</a>
.
</p>
),
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-custom-text-200"),
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-gray-500/10"),
borderColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "border-gray-500"),
},
];
export const INBOX_ISSUE_SOURCE = "in-app";

View File

@ -4,9 +4,6 @@ export * from "./use-cycle";
export * from "./use-dashboard";
export * from "./use-estimate";
export * from "./use-global-view";
export * from "./use-inbox";
export * from "./use-inbox-filters";
export * from "./use-inbox-issues";
export * from "./use-label";
export * from "./use-member";
export * from "./use-mention";
@ -22,3 +19,5 @@ export * from "./use-workspace";
export * from "./use-issues";
export * from "./use-kanban-view";
export * from "./use-issue-detail";
export * from "./use-inbox";
export * from "./use-inbox-issues";

View File

@ -1,11 +0,0 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "contexts/store-context";
// types
import { IInboxFiltersStore } from "store/inbox/inbox_filter.store";
export const useInboxFilters = (): IInboxFiltersStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useInboxFilters must be used within StoreProvider");
return context.inboxRoot.inboxFilters;
};

View File

@ -2,10 +2,14 @@ import { useContext } from "react";
// mobx store
import { StoreContext } from "contexts/store-context";
// types
import { IInboxIssuesStore } from "store/inbox/inbox_issue.store";
import { IInboxIssue } from "store/inbox/inbox_issue.store";
import { IInboxFilter } from "store/inbox/inbox_filter.store";
export const useInboxIssues = (): IInboxIssuesStore => {
export const useInboxIssues = (): {
issues: IInboxIssue;
filters: IInboxFilter;
} => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useInboxIssues must be used within StoreProvider");
return context.inboxRoot.inboxIssues;
return { issues: context.inbox.inboxIssue, filters: context.inbox.inboxFilter };
};

View File

@ -2,10 +2,10 @@ import { useContext } from "react";
// mobx store
import { StoreContext } from "contexts/store-context";
// types
import { IInboxStore } from "store/inbox/inbox.store";
import { IInbox } from "store/inbox/inbox.store";
export const useInbox = (): IInboxStore => {
export const useInbox = (): IInbox => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useInbox must be used within StoreProvider");
return context.inboxRoot.inbox;
return context.inbox.inbox;
};

View File

@ -30,14 +30,14 @@ interface IProjectAuthWrapper {
export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
const { children } = props;
// store
const { fetchInboxesList, isInboxEnabled } = useInbox();
const { fetchInboxes } = useInbox();
const {
commandPalette: { toggleCreateProjectModal },
} = useApplication();
const {
membership: { fetchUserProjectInfo, projectMemberInfo, hasPermissionToProject },
} = useUser();
const { getProjectById, fetchProjectDetails } = useProject();
const { getProjectById, fetchProjectDetails, currentProjectDetails } = useProject();
const { fetchAllCycles } = useCycle();
const { fetchModules } = useModule();
const { fetchViews } = useProjectView();
@ -96,11 +96,13 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
workspaceSlug && projectId ? `PROJECT_VIEWS_${workspaceSlug}_${projectId}` : null,
workspaceSlug && projectId ? () => fetchViews(workspaceSlug.toString(), projectId.toString()) : null
);
// fetching project inboxes if inbox is enabled
// fetching project inboxes if inbox is enabled in project settings
useSWR(
workspaceSlug && projectId && isInboxEnabled ? `PROJECT_INBOXES_${workspaceSlug}_${projectId}` : null,
workspaceSlug && projectId && isInboxEnabled
? () => fetchInboxesList(workspaceSlug.toString(), projectId.toString())
workspaceSlug && projectId && currentProjectDetails && currentProjectDetails.inbox_view
? `PROJECT_INBOXES_${workspaceSlug}_${projectId}`
: null,
workspaceSlug && projectId && currentProjectDetails && currentProjectDetails.inbox_view
? () => fetchInboxes(workspaceSlug.toString(), projectId.toString())
: null,
{
revalidateOnFocus: false,

View File

@ -1,41 +1,109 @@
import { ReactElement } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { observer } from "mobx-react";
import { Inbox } from "lucide-react";
// hooks
import { useInboxFilters } from "hooks/store/";
import { useProject, useInboxIssues } from "hooks/store";
// layouts
import { AppLayout } from "layouts/app-layout";
// ui
import { Spinner } from "@plane/ui";
// components
import { InboxActionsHeader, InboxMainContent, InboxIssuesListSidebar } from "components/inbox";
import { ProjectInboxHeader } from "components/headers";
import { InboxSidebarRoot, InboxIssueActionsHeader } from "components/inbox";
import { InboxIssueDetailRoot } from "components/issues/issue-detail/inbox";
// types
import { NextPageWithLayout } from "lib/types";
const ProjectInboxPage: NextPageWithLayout = () => {
const ProjectInboxPage: NextPageWithLayout = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId, inboxId } = router.query;
const { fetchInboxFilters } = useInboxFilters();
const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query;
// store hooks
const {
issues: { getInboxIssuesByInboxId },
} = useInboxIssues();
const { currentProjectDetails } = useProject();
const {
filters: { fetchInboxFilters },
issues: { loader, fetchInboxIssues },
} = useInboxIssues();
useSWR(
workspaceSlug && projectId && inboxId ? `INBOX_FILTERS_${inboxId.toString()}` : null,
workspaceSlug && projectId && inboxId
? () => fetchInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString())
: null
workspaceSlug && projectId && currentProjectDetails && currentProjectDetails?.inbox_view
? `INBOX_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}`
: null,
async () => {
if (workspaceSlug && projectId && inboxId && currentProjectDetails && currentProjectDetails?.inbox_view) {
await fetchInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString());
await fetchInboxIssues(workspaceSlug.toString(), projectId.toString(), inboxId.toString());
}
}
);
// inbox issues list
const inboxIssuesList = inboxId ? getInboxIssuesByInboxId(inboxId?.toString()) : undefined;
if (!workspaceSlug || !projectId || !inboxId) return <></>;
return (
<div className="flex h-full flex-col">
<InboxActionsHeader />
<div className="grid flex-1 grid-cols-4 divide-x divide-custom-border-200 overflow-hidden">
<InboxIssuesListSidebar />
<div className="col-span-3 h-full overflow-auto">
<InboxMainContent />
<>
{loader === "fetch" ? (
<div className="relative flex w-full h-full items-center justify-center">
<Spinner />
</div>
</div>
</div>
) : (
<div className="relative flex h-full overflow-hidden">
<div className="flex-shrink-0 w-[340px] h-full border-r border-custom-border-100">
{workspaceSlug && projectId && inboxId && (
<InboxSidebarRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
inboxId={inboxId.toString()}
/>
)}
</div>
<div className="w-full">
{workspaceSlug && projectId && inboxId && inboxIssueId ? (
<div className="w-full h-full overflow-hidden relative flex flex-col">
<div className="flex-shrink-0 min-h-[50px] border-b border-custom-border-100">
<InboxIssueActionsHeader
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
inboxId={inboxId.toString()}
inboxIssueId={inboxIssueId?.toString() || undefined}
/>
</div>
<div className="w-full h-full">
<InboxIssueDetailRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
inboxId={inboxId.toString()}
issueId={inboxIssueId.toString()}
/>
</div>
</div>
) : (
<div className="grid h-full place-items-center p-4 text-custom-text-200">
<div className="grid h-full place-items-center">
<div className="my-5 flex flex-col items-center gap-4">
<Inbox size={60} strokeWidth={1.5} />
{inboxIssuesList && inboxIssuesList.length > 0 ? (
<span className="text-custom-text-200">
{inboxIssuesList?.length} issues found. Select an issue from the sidebar to view its details.
</span>
) : (
<span className="text-custom-text-200">No issues found</span>
)}
</div>
</div>
</div>
)}
</div>
</div>
)}
</>
);
};
});
ProjectInboxPage.getLayout = function getLayout(page: ReactElement) {
return (

View File

@ -0,0 +1,49 @@
import { ReactElement } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { observer } from "mobx-react";
// hooks
import { useInbox, useProject } from "hooks/store";
// layouts
import { AppLayout } from "layouts/app-layout";
// components
import { ProjectInboxHeader } from "components/headers";
// types
import { NextPageWithLayout } from "lib/types";
const ProjectInboxPage: NextPageWithLayout = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { currentProjectDetails } = useProject();
const { fetchInboxes } = useInbox();
useSWR(
workspaceSlug && projectId && currentProjectDetails && currentProjectDetails?.inbox_view
? `INBOX_${workspaceSlug.toString()}_${projectId.toString()}`
: null,
async () => {
if (workspaceSlug && projectId && currentProjectDetails && currentProjectDetails?.inbox_view) {
const inboxes = await fetchInboxes(workspaceSlug.toString(), projectId.toString());
if (inboxes && inboxes.length > 0)
router.push(`/${workspaceSlug}/projects/${projectId}/inbox/${inboxes[0].id}`);
}
}
);
return (
<div className="flex h-full flex-col">
{currentProjectDetails?.inbox_view ? <div>Loading...</div> : <div>You don{"'"}t have access to inbox</div>}
</div>
);
});
ProjectInboxPage.getLayout = function getLayout(page: ReactElement) {
return (
<AppLayout header={<ProjectInboxHeader />} withProjectWrapper>
{page}
</AppLayout>
);
};
export default ProjectInboxPage;

View File

@ -0,0 +1,112 @@
import { APIService } from "services/api.service";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
// types
import type { TInboxIssueFilterOptions, TInboxIssueExtendedDetail, TIssue, TInboxDetailedStatus } from "@plane/types";
export class InboxIssueService extends APIService {
constructor() {
super(API_BASE_URL);
}
async fetchInboxIssues(
workspaceSlug: string,
projectId: string,
inboxId: string,
params?: TInboxIssueFilterOptions | {}
): Promise<TInboxIssueExtendedDetail[]> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/?expand=issue_inbox`,
{
params,
}
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async fetchInboxIssueById(
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string
): Promise<TInboxIssueExtendedDetail> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/?expand=issue_inbox`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async createInboxIssue(
workspaceSlug: string,
projectId: string,
inboxId: string,
data: {
source: string;
issue: Partial<TIssue>;
}
): Promise<TInboxIssueExtendedDetail> {
return this.post(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/?expand=issue_inbox`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async updateInboxIssue(
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string,
data: { issue: Partial<TIssue> }
): Promise<TInboxIssueExtendedDetail> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/?expand=issue_inbox`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async removeInboxIssue(
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string
): Promise<void> {
return this.delete(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async updateInboxIssueStatus(
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string,
data: TInboxDetailedStatus
): Promise<TInboxIssueExtendedDetail> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/?expand=issue_inbox`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@ -0,0 +1,35 @@
import { APIService } from "services/api.service";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
// types
import type { TInbox } from "@plane/types";
export class InboxService extends APIService {
constructor() {
super(API_BASE_URL);
}
async fetchInboxes(workspaceSlug: string, projectId: string): Promise<TInbox[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async fetchInboxById(workspaceSlug: string, projectId: string, inboxId: string): Promise<TInbox> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async updateInbox(workspaceSlug: string, projectId: string, inboxId: string, data: Partial<TInbox>): Promise<TInbox> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@ -0,0 +1,2 @@
export * from "./inbox.service";
export * from "./inbox-issue.service";

View File

@ -1,37 +1,32 @@
import { observable, action, makeObservable, runInAction, computed } from "mobx";
import { observable, action, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import { set } from "lodash";
import set from "lodash/set";
import update from "lodash/update";
import concat from "lodash/concat";
import uniq from "lodash/uniq";
// services
import { InboxService } from "services/inbox.service";
import { InboxService } from "services/inbox/inbox.service";
// types
import { RootStore } from "store/root.store";
import { IInbox } from "@plane/types";
import { TInboxDetailMap, TInboxDetailIdMap, TInbox } from "@plane/types";
export interface IInboxStore {
export interface IInbox {
// observables
inboxesList: {
[projectId: string]: IInbox[];
};
inboxDetails: {
[inboxId: string]: IInbox;
};
// computed
isInboxEnabled: boolean;
// computed actions
getInboxId: (projectId: string) => string | null;
inboxes: TInboxDetailIdMap;
inboxMap: TInboxDetailMap;
// helper methods
getInboxesByProjectId: (projectId: string) => string[] | undefined;
getInboxById: (inboxId: string) => TInbox | undefined;
// fetch actions
fetchInboxesList: (workspaceSlug: string, projectId: string) => Promise<IInbox[]>;
fetchInboxDetails: (workspaceSlug: string, projectId: string, inboxId: string) => Promise<IInbox>;
fetchInboxes: (workspaceSlug: string, projectId: string) => Promise<TInbox[]>;
fetchInboxById: (workspaceSlug: string, projectId: string, inboxId: string) => Promise<TInbox>;
updateInbox: (workspaceSlug: string, projectId: string, inboxId: string, data: Partial<TInbox>) => Promise<TInbox>;
}
export class InboxStore implements IInboxStore {
export class Inbox implements IInbox {
// observables
inboxesList: {
[projectId: string]: IInbox[];
} = {};
inboxDetails: {
[inboxId: string]: IInbox;
} = {};
inboxes: TInboxDetailIdMap = {};
inboxMap: TInboxDetailMap = {};
// root store
rootStore;
// services
@ -40,68 +35,80 @@ export class InboxStore implements IInboxStore {
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
inboxesList: observable,
inboxDetails: observable,
// computed
isInboxEnabled: computed,
inboxMap: observable,
inboxes: observable,
// actions
fetchInboxesList: action,
fetchInboxes: action,
fetchInboxById: action,
updateInbox: action,
});
// root store
this.rootStore = _rootStore;
// services
this.inboxService = new InboxService();
}
/**
* Returns true if inbox is enabled for current project
*/
get isInboxEnabled() {
const projectId = this.rootStore.app.router.projectId;
if (!projectId) return false;
const projectDetails = this.rootStore.projectRoot.project.currentProjectDetails;
if (!projectDetails) return false;
return projectDetails.inbox_view;
}
/**
* Returns the inbox Id belongs to a specific project
*/
getInboxId = computedFn((projectId: string) => {
const projectDetails = this.rootStore.projectRoot.project.getProjectById(projectId);
if (!projectDetails || !projectDetails.inbox_view) return null;
return this.inboxesList[projectId]?.[0]?.id ?? null;
// helper methods
getInboxesByProjectId = computedFn((projectId: string) => {
if (!projectId) return undefined;
return this.inboxes?.[projectId] ?? undefined;
});
/**
* Fetches the inboxes list belongs to a specific project
* @param workspaceSlug
* @param projectId
* @returns Promise<IInbox[]>
*/
fetchInboxesList = async (workspaceSlug: string, projectId: string) => {
return await this.inboxService.getInboxes(workspaceSlug, projectId).then((inboxes) => {
getInboxById = computedFn((inboxId: string) => {
if (!inboxId) return undefined;
return this.inboxMap[inboxId] ?? undefined;
});
// actions
fetchInboxes = async (workspaceSlug: string, projectId: string) => {
try {
const response = await this.inboxService.fetchInboxes(workspaceSlug, projectId);
const _inboxIds = response.map((inbox) => inbox.id);
runInAction(() => {
set(this.inboxesList, projectId, inboxes);
response.forEach((inbox) => {
set(this.inboxMap, inbox.id, inbox);
});
set(this.inboxes, projectId, _inboxIds);
});
return inboxes;
});
return response;
} catch (error) {
throw error;
}
};
/**
* Fetches the inbox details belongs to a specific inbox
* @param workspaceSlug
* @param projectId
* @param inboxId
* @returns Promise<IInbox>
*/
fetchInboxDetails = async (workspaceSlug: string, projectId: string, inboxId: string) => {
return await this.inboxService.getInboxById(workspaceSlug, projectId, inboxId).then((inboxDetailsResponse) => {
fetchInboxById = async (workspaceSlug: string, projectId: string, inboxId: string) => {
try {
const response = await this.inboxService.fetchInboxById(workspaceSlug, projectId, inboxId);
runInAction(() => {
set(this.inboxDetails, inboxId, inboxDetailsResponse);
set(this.inboxMap, inboxId, response);
update(this.inboxes, projectId, (inboxIds: string[] = []) => {
if (inboxIds.includes(inboxId)) return inboxIds;
return uniq(concat(inboxIds, inboxId));
});
});
return inboxDetailsResponse;
});
return response;
} catch (error) {
throw error;
}
};
updateInbox = async (workspaceSlug: string, projectId: string, inboxId: string, data: Partial<TInbox>) => {
try {
const response = await this.inboxService.updateInbox(workspaceSlug, projectId, inboxId, data);
runInAction(() => {
Object.keys(response).forEach((key) => {
set(this.inboxMap, [inboxId, key], response[key as keyof TInbox]);
});
});
return response;
} catch (error) {
throw error;
}
};
}

View File

@ -1,34 +1,31 @@
import { observable, action, makeObservable, runInAction, computed } from "mobx";
import { set } from "lodash";
import set from "lodash/set";
import isEmpty from "lodash/isEmpty";
// services
import { InboxService } from "services/inbox.service";
// types
import { RootStore } from "store/root.store";
import { IInbox, IInboxFilterOptions, IInboxQueryParams } from "@plane/types";
import { EUserWorkspaceRoles } from "constants/workspace";
import { EUserProjectRoles } from "constants/project";
import { TInboxIssueFilterOptions, TInboxIssueFilters, TInboxIssueQueryParams, TInbox } from "@plane/types";
export interface IInboxFiltersStore {
export interface IInboxFilter {
// observables
inboxFilters: Record<string, { filters: IInboxFilterOptions }>;
filters: Record<string, TInboxIssueFilters>; // inbox_id -> TInboxIssueFilters
// computed
appliedFilters: IInboxQueryParams | null;
// fetch action
fetchInboxFilters: (workspaceSlug: string, projectId: string, inboxId: string) => Promise<IInbox>;
// update action
inboxFilters: TInboxIssueFilters | undefined;
inboxAppliedFilters: Partial<Record<TInboxIssueQueryParams, string>> | undefined;
// actions
fetchInboxFilters: (workspaceSlug: string, projectId: string, inboxId: string) => Promise<TInbox>;
updateInboxFilters: (
workspaceSlug: string,
projectId: string,
inboxId: string,
filters: Partial<IInboxFilterOptions>
) => Promise<void>;
filters: Partial<TInboxIssueFilterOptions>
) => Promise<TInbox>;
}
export class InboxFiltersStore implements IInboxFiltersStore {
export class InboxFilter implements IInboxFilter {
// observables
inboxFilters: {
[inboxId: string]: { filters: IInboxFilterOptions };
} = {};
filters: Record<string, TInboxIssueFilters> = {};
// root store
rootStore;
// services
@ -37,12 +34,12 @@ export class InboxFiltersStore implements IInboxFiltersStore {
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
inboxFilters: observable,
filters: observable,
// computed
appliedFilters: computed,
// fetch action
inboxFilters: computed,
inboxAppliedFilters: computed,
// actions
fetchInboxFilters: action,
// update action
updateInboxFilters: action,
});
// root store
@ -51,69 +48,81 @@ export class InboxFiltersStore implements IInboxFiltersStore {
this.inboxService = new InboxService();
}
/**
* Returns applied filters to specific inbox
*/
get appliedFilters(): IInboxQueryParams | null {
get inboxFilters() {
const inboxId = this.rootStore.app.router.inboxId;
if (!inboxId) return null;
const filtersList = this.inboxFilters[inboxId]?.filters;
if (!filtersList) return null;
const filteredRouteParams: IInboxQueryParams = {
priority: filtersList.priority ? filtersList.priority.join(",") : null,
inbox_status: filtersList.inbox_status ? filtersList.inbox_status.join(",") : null,
if (!inboxId) return undefined;
const displayFilters = this.filters[inboxId] || undefined;
if (isEmpty(displayFilters)) return undefined;
const _filters: TInboxIssueFilters = {
filters: {
priority: isEmpty(displayFilters?.filters?.priority) ? [] : displayFilters?.filters?.priority,
inbox_status: isEmpty(displayFilters?.filters?.inbox_status) ? [] : displayFilters?.filters?.inbox_status,
},
};
return filteredRouteParams;
return _filters;
}
get inboxAppliedFilters() {
const userFilters = this.inboxFilters;
if (!userFilters) return undefined;
const filteredParams = {
priority: userFilters?.filters?.priority?.join(",") || undefined,
inbox_status: userFilters?.filters?.inbox_status?.join(",") || undefined,
};
return filteredParams;
}
/**
* Fetches filters of a specific inbox and adds it to the store
* @param workspaceSlug
* @param projectId
* @param inboxId
* @returns Promise<IInbox[]>
*
*/
fetchInboxFilters = async (workspaceSlug: string, projectId: string, inboxId: string) => {
return await this.inboxService.getInboxById(workspaceSlug, projectId, inboxId).then((issuesResponse) => {
try {
const response = await this.rootStore.inbox.inbox.fetchInboxById(workspaceSlug, projectId, inboxId);
const filters: TInboxIssueFilterOptions = {
priority: response?.view_props?.filters?.priority || [],
inbox_status: response?.view_props?.filters?.inbox_status || [],
};
runInAction(() => {
set(this.inboxFilters, [inboxId], issuesResponse.view_props);
set(this.filters, [inboxId], { filters: filters });
});
return issuesResponse;
});
return response;
} catch (error) {
throw error;
}
};
/**
* Updates filters of a specific inbox and updates it in the store
* @param workspaceSlug
* @param projectId
* @param inboxId
* @param filters
* @returns Promise<void>
*
*/
updateInboxFilters = async (
workspaceSlug: string,
projectId: string,
inboxId: string,
filters: Partial<IInboxFilterOptions>
filters: Partial<TInboxIssueFilterOptions>
) => {
const newViewProps = {
...this.inboxFilters[inboxId],
filters: {
...this.inboxFilters[inboxId]?.filters,
...filters,
},
};
const userRole = this.rootStore.user.membership?.currentProjectRole || EUserProjectRoles.GUEST;
if (userRole > EUserWorkspaceRoles.VIEWER)
await this.inboxService
.patchInbox(workspaceSlug, projectId, inboxId, { view_props: newViewProps })
.then((response) => {
runInAction(() => {
set(this.inboxFilters, [inboxId], newViewProps);
});
return response;
try {
runInAction(() => {
Object.keys(filters).forEach((_key) => {
const _filterKey = _key as keyof TInboxIssueFilterOptions;
set(this.filters, [inboxId, "filters", _key], filters[_filterKey]);
});
});
const inboxFilters = this.inboxFilters;
let _filters: TInboxIssueFilterOptions = {
priority: inboxFilters?.filters?.priority || [],
inbox_status: inboxFilters?.filters?.inbox_status || [],
};
_filters = { ..._filters, ...filters };
const response = await this.rootStore.inbox.inbox.updateInbox(workspaceSlug, projectId, inboxId, {
view_props: { filters: _filters },
});
this.rootStore.inbox.inboxIssue.fetchInboxIssues(workspaceSlug, projectId, inboxId);
return response;
} catch (error) {
throw error;
}
};
}

View File

@ -1,244 +1,282 @@
import { observable, action, makeObservable, runInAction, autorun, computed } from "mobx";
import { observable, action, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import { set } from "lodash";
import set from "lodash/set";
import update from "lodash/update";
import concat from "lodash/concat";
import uniq from "lodash/uniq";
import pull from "lodash/pull";
// services
import { InboxService } from "services/inbox.service";
import { InboxIssueService } from "services/inbox/inbox-issue.service";
// types
import { RootStore } from "store/root.store";
import { IInboxIssue, TIssue, TInboxStatus } from "@plane/types";
import type {
TInboxIssueDetailIdMap,
TInboxIssueDetailMap,
TInboxIssueDetail,
TInboxIssueExtendedDetail,
TInboxDetailedStatus,
TIssue,
} from "@plane/types";
// constants
import { INBOX_ISSUE_SOURCE } from "constants/inbox";
export interface IInboxIssuesStore {
type TInboxIssueLoader = "fetch" | undefined;
export interface IInboxIssue {
// observables
issueMap: Record<string, Record<string, IInboxIssue>>; // {inboxId: {issueId: IInboxIssue}}
// computed
currentInboxIssueIds: string[] | null;
// computed actions
getIssueById: (inboxId: string, issueId: string) => IInboxIssue | null;
// fetch actions
fetchIssues: (workspaceSlug: string, projectId: string, inboxId: string) => Promise<IInboxIssue[]>;
fetchIssueDetails: (
loader: TInboxIssueLoader;
inboxIssues: TInboxIssueDetailIdMap;
inboxIssueMap: TInboxIssueDetailMap;
// helper methods
getInboxIssuesByInboxId: (inboxId: string) => string[] | undefined;
getInboxIssueByIssueId: (inboxId: string, issueId: string) => TInboxIssueDetail | undefined;
// actions
fetchInboxIssues: (workspaceSlug: string, projectId: string, inboxId: string) => Promise<TInboxIssueExtendedDetail[]>;
fetchInboxIssueById: (
workspaceSlug: string,
projectId: string,
inboxId: string,
issueId: string
) => Promise<IInboxIssue>;
// CRUD actions
createIssue: (
inboxIssueId: string
) => Promise<TInboxIssueExtendedDetail[]>;
createInboxIssue: (
workspaceSlug: string,
projectId: string,
inboxId: string,
data: Partial<TIssue>
) => Promise<IInboxIssue>;
updateIssue: (
data: Partial<TInboxIssueExtendedDetail>
) => Promise<TInboxIssueExtendedDetail>;
updateInboxIssue: (
workspaceSlug: string,
projectId: string,
inboxId: string,
issueId: string,
data: Partial<IInboxIssue>
) => Promise<void>;
updateIssueStatus: (
inboxIssueId: string,
data: Partial<TInboxIssueExtendedDetail>
) => Promise<TInboxIssueExtendedDetail>;
removeInboxIssue: (workspaceSlug: string, projectId: string, inboxId: string, issueId: string) => Promise<void>;
updateInboxIssueStatus: (
workspaceSlug: string,
projectId: string,
inboxId: string,
issueId: string,
data: TInboxStatus
) => Promise<void>;
deleteIssue: (workspaceSlug: string, projectId: string, inboxId: string, issueId: string) => Promise<void>;
inboxIssueId: string,
data: TInboxDetailedStatus
) => Promise<TInboxIssueExtendedDetail>;
}
export class InboxIssuesStore implements IInboxIssuesStore {
export class InboxIssue implements IInboxIssue {
// observables
issueMap: { [inboxId: string]: Record<string, IInboxIssue> } = {};
loader: TInboxIssueLoader = "fetch";
inboxIssues: TInboxIssueDetailIdMap = {};
inboxIssueMap: TInboxIssueDetailMap = {};
// root store
rootStore;
// services
inboxService;
inboxIssueService;
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
issueMap: observable,
// computed
currentInboxIssueIds: computed,
// fetch actions
fetchIssues: action,
fetchIssueDetails: action,
// CRUD actions
createIssue: action,
updateIssue: action,
updateIssueStatus: action,
deleteIssue: action,
loader: observable.ref,
inboxIssues: observable,
inboxIssueMap: observable,
// actions
fetchInboxIssues: action,
fetchInboxIssueById: action,
createInboxIssue: action,
updateInboxIssue: action,
removeInboxIssue: action,
updateInboxIssueStatus: action,
});
// root store
this.rootStore = _rootStore;
// services
this.inboxService = new InboxService();
autorun(() => {
const routerStore = this.rootStore.app.router;
const workspaceSlug = routerStore?.workspaceSlug;
const projectId = routerStore?.projectId;
const inboxId = routerStore?.inboxId;
if (workspaceSlug && projectId && inboxId && this.rootStore.inboxRoot.inboxFilters.inboxFilters[inboxId])
this.fetchIssues(workspaceSlug, projectId, inboxId);
});
this.inboxIssueService = new InboxIssueService();
}
/**
* Returns the issue IDs belong to a specific inbox issues list
*/
get currentInboxIssueIds() {
const inboxId = this.rootStore.app.router.inboxId;
if (!inboxId) return null;
return Object.keys(this.issueMap?.[inboxId] ?? {}) ?? null;
}
// helper methods
getInboxIssuesByInboxId = computedFn((inboxId: string) => {
if (!inboxId) return undefined;
return this.inboxIssues?.[inboxId] ?? undefined;
});
/**
* Returns the issue details belongs to a specific inbox issue
*/
getIssueById = computedFn(
(inboxId: string, issueId: string): IInboxIssue | null => this.issueMap?.[inboxId]?.[issueId] ?? null
);
getInboxIssueByIssueId = computedFn((inboxId: string, issueId: string) => {
if (!inboxId) return undefined;
return this.inboxIssueMap?.[inboxId]?.[issueId] ?? undefined;
});
/**
* Fetches issues of a specific inbox and adds it to the store
* @param workspaceSlug
* @param projectId
* @param inboxId
* @returns Promise<IInbox[]>
*/
fetchIssues = async (workspaceSlug: string, projectId: string, inboxId: string) => {
const queryParams = this.rootStore.inboxRoot.inboxFilters.appliedFilters ?? undefined;
return await this.inboxService
.getInboxIssues(workspaceSlug, projectId, inboxId, queryParams)
.then((issuesResponse) => {
runInAction(() => {
issuesResponse.forEach((issue) => {
set(this.issueMap, [inboxId, issue.issue_inbox?.[0].id], issue);
});
});
return issuesResponse;
});
};
// actions
fetchInboxIssues = async (workspaceSlug: string, projectId: string, inboxId: string) => {
try {
this.loader = "fetch";
const queryParams = this.rootStore.inbox.inboxFilter.inboxAppliedFilters ?? {};
/**
* Fetches issue details of a specific inbox issue and updates it to the store
* @param workspaceSlug
* @param projectId
* @param inboxId
* @param issueId
* returns Promise<IInboxIssue>
*/
fetchIssueDetails = async (workspaceSlug: string, projectId: string, inboxId: string, issueId: string) => {
return await this.inboxService
.getInboxIssueById(workspaceSlug, projectId, inboxId, issueId)
.then((issueResponse) => {
runInAction(() => {
set(this.issueMap, [inboxId, issueId], issueResponse);
});
return issueResponse;
});
};
const response = await this.inboxIssueService.fetchInboxIssues(workspaceSlug, projectId, inboxId, queryParams);
/**
* Creates a new issue for a specific inbox and add it to the store
* @param workspaceSlug
* @param projectId
* @param inboxId
* @param data
* @returns Promise<IInboxIssue>
*/
createIssue = async (workspaceSlug: string, projectId: string, inboxId: string, data: Partial<TIssue>) => {
const payload = {
issue: {
name: data.name,
// description: data.description,
description_html: data.description_html,
priority: data.priority,
},
source: INBOX_ISSUE_SOURCE,
};
return await this.inboxService.createInboxIssue(workspaceSlug, projectId, inboxId, payload).then((response) => {
runInAction(() => {
set(this.issueMap, [inboxId, response.issue_inbox?.[0].id], response);
response.forEach((_inboxIssue) => {
const { ["issue_inbox"]: issueInboxDetail, ...issue } = _inboxIssue;
this.rootStore.inbox.rootStore.issue.issues.addIssue([issue]);
const { ["id"]: omittedId, ...inboxIssue } = issueInboxDetail[0];
set(this.inboxIssueMap, [inboxId, _inboxIssue.id], inboxIssue);
});
});
const _inboxIssueIds = response.map((inboxIssue) => inboxIssue.id);
runInAction(() => {
set(this.inboxIssues, inboxId, _inboxIssueIds);
this.loader = undefined;
});
return response;
});
} catch (error) {
this.loader = undefined;
throw error;
}
};
/**
* Updates an issue for a specific inbox and update it in the store
* @param workspaceSlug
* @param projectId
* @param inboxId
* @param issueId
* @param data
* @returns Promise<IInboxIssue>
*/
updateIssue = async (
fetchInboxIssueById = async (workspaceSlug: string, projectId: string, inboxId: string, inboxIssueId: string) => {
try {
const response = await this.inboxIssueService.fetchInboxIssueById(
workspaceSlug,
projectId,
inboxId,
inboxIssueId
);
runInAction(() => {
const { ["issue_inbox"]: issueInboxDetail, ...issue } = response;
this.rootStore.inbox.rootStore.issue.issues.addIssue([issue]);
const { ["id"]: omittedId, ...inboxIssue } = issueInboxDetail[0];
set(this.inboxIssueMap, [inboxId, response.id], inboxIssue);
});
runInAction(() => {
update(this.inboxIssues, inboxId, (inboxIssueIds: string[] = []) => {
if (inboxIssueIds.includes(response.id)) return inboxIssueIds;
return uniq(concat(inboxIssueIds, response.id));
});
});
// fetching issue activity
await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, inboxIssueId);
// fetching issue reaction
await this.rootStore.issue.issueDetail.fetchReactions(workspaceSlug, projectId, inboxIssueId);
return response as any;
} catch (error) {
throw error;
}
};
createInboxIssue = async (workspaceSlug: string, projectId: string, inboxId: string, data: Partial<TIssue>) => {
try {
const response = await this.inboxIssueService.createInboxIssue(workspaceSlug, projectId, inboxId, {
source: "in-app",
issue: data,
});
runInAction(() => {
const { ["issue_inbox"]: issueInboxDetail, ...issue } = response;
this.rootStore.inbox.rootStore.issue.issues.addIssue([issue]);
const { ["id"]: omittedId, ...inboxIssue } = issueInboxDetail[0];
set(this.inboxIssueMap, [inboxId, response.id], inboxIssue);
});
runInAction(() => {
update(this.inboxIssues, inboxId, (inboxIssueIds: string[] = []) => {
if (inboxIssueIds.includes(response.id)) return inboxIssueIds;
return uniq(concat(inboxIssueIds, response.id));
});
});
await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, response.id);
return response;
} catch (error) {
throw error;
}
};
updateInboxIssue = async (
workspaceSlug: string,
projectId: string,
inboxId: string,
issueId: string,
data: Partial<IInboxIssue>
inboxIssueId: string,
data: Partial<TIssue>
) => {
const issueDetails = this.rootStore.inboxRoot.inboxIssues.getIssueById(inboxId, issueId);
return await this.inboxService
.patchInboxIssue(workspaceSlug, projectId, inboxId, issueId, { issue: data })
.then((issueResponse) => {
runInAction(() => {
set(this.issueMap, [inboxId, issueId], {
...issueDetails,
...issueResponse,
});
});
return issueResponse;
try {
const response = await this.inboxIssueService.updateInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId, {
issue: data,
});
runInAction(() => {
const { ["issue_inbox"]: issueInboxDetail, ...issue } = response;
this.rootStore.inbox.rootStore.issue.issues.addIssue([issue]);
const { ["id"]: omittedId, ...inboxIssue } = issueInboxDetail[0];
set(this.inboxIssueMap, [inboxId, response.id], inboxIssue);
});
runInAction(() => {
update(this.inboxIssues, inboxId, (inboxIssueIds: string[] = []) => {
if (inboxIssueIds.includes(response.id)) return inboxIssueIds;
return uniq(concat(inboxIssueIds, response.id));
});
});
await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, inboxIssueId);
return response as any;
} catch (error) {
throw error;
}
};
/**
* Updates an issue status for a specific inbox issue and update it in the store
* @param workspaceSlug
* @param projectId
* @param inboxId
* @param issueId
* @param data
* @returns Promise<IInboxIssue>
*/
updateIssueStatus = async (
removeInboxIssue = async (workspaceSlug: string, projectId: string, inboxId: string, inboxIssueId: string) => {
try {
const response = await this.inboxIssueService.removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId);
runInAction(() => {
pull(this.inboxIssues[inboxId], inboxIssueId);
delete this.inboxIssueMap[inboxId][inboxIssueId];
});
await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, inboxIssueId);
return response as any;
} catch (error) {
throw error;
}
};
updateInboxIssueStatus = async (
workspaceSlug: string,
projectId: string,
inboxId: string,
issueId: string,
data: TInboxStatus
inboxIssueId: string,
data: TInboxDetailedStatus
) => {
const issueDetails = this.rootStore.inboxRoot.inboxIssues.getIssueById(inboxId, issueId);
await this.inboxService.markInboxStatus(workspaceSlug, projectId, inboxId, issueId, data).then((response) => {
try {
const response = await this.inboxIssueService.updateInboxIssueStatus(
workspaceSlug,
projectId,
inboxId,
inboxIssueId,
data
);
runInAction(() => {
set(this.issueMap, [inboxId, issueId, "issue_inbox", 0], {
...issueDetails?.issue_inbox?.[0],
...response?.issue_inbox?.[0],
const { ["issue_inbox"]: issueInboxDetail, ...issue } = response;
this.rootStore.inbox.rootStore.issue.issues.addIssue([issue]);
const { ["id"]: omittedId, ...inboxIssue } = issueInboxDetail[0];
set(this.inboxIssueMap, [inboxId, response.id], inboxIssue);
});
runInAction(() => {
update(this.inboxIssues, inboxId, (inboxIssueIds: string[] = []) => {
if (inboxIssueIds.includes(response.id)) return inboxIssueIds;
return uniq(concat(inboxIssueIds, response.id));
});
});
return response;
});
};
/**
* Deletes an issue for a specific inbox and removes it from the store
* @param workspaceSlug
* @param projectId
* @param inboxId
* @param issueId
* @returns Promise<void>
*/
deleteIssue = async (workspaceSlug: string, projectId: string, inboxId: string, issueId: string) => {
await this.inboxService.deleteInboxIssue(workspaceSlug, projectId, inboxId, issueId).then((_) => {
runInAction(() => {
delete this.issueMap?.[inboxId]?.[issueId];
});
});
await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, inboxIssueId);
return response as any;
} catch (error) {
throw error;
}
};
}

View File

@ -1,25 +0,0 @@
import { makeAutoObservable } from "mobx";
// types
import { RootStore } from "store/root.store";
import { IInboxIssuesStore, InboxIssuesStore } from "./inbox_issue.store";
import { IInboxFiltersStore, InboxFiltersStore } from "./inbox_filter.store";
import { IInboxStore, InboxStore } from "./inbox.store";
export interface IInboxRootStore {
inbox: IInboxStore;
inboxFilters: IInboxFiltersStore;
inboxIssues: IInboxIssuesStore;
}
export class InboxRootStore implements IInboxRootStore {
inbox: IInboxStore;
inboxFilters: IInboxFiltersStore;
inboxIssues: IInboxIssuesStore;
constructor(_rootStore: RootStore) {
makeAutoObservable(this, {});
this.inbox = new InboxStore(_rootStore);
this.inboxFilters = new InboxFiltersStore(_rootStore);
this.inboxIssues = new InboxIssuesStore(_rootStore);
}
}

View File

@ -0,0 +1,26 @@
// types
import { RootStore } from "store/root.store";
import { IInbox, Inbox } from "./inbox.store";
import { IInboxIssue, InboxIssue } from "./inbox_issue.store";
import { IInboxFilter, InboxFilter } from "./inbox_filter.store";
export interface IInboxRootStore {
rootStore: RootStore;
inbox: IInbox;
inboxIssue: IInboxIssue;
inboxFilter: IInboxFilter;
}
export class InboxRootStore implements IInboxRootStore {
rootStore: RootStore;
inbox: IInbox;
inboxIssue: IInboxIssue;
inboxFilter: IInboxFilter;
constructor(_rootStore: RootStore) {
this.rootStore = _rootStore;
this.inbox = new Inbox(_rootStore);
this.inboxIssue = new InboxIssue(_rootStore);
this.inboxFilter = new InboxFilter(_rootStore);
}
}

View File

@ -8,9 +8,9 @@ import { IModuleStore, ModulesStore } from "./module.store";
import { IUserRootStore, UserRootStore } from "./user";
import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace";
import { IssueRootStore, IIssueRootStore } from "./issue/root.store";
import { IInboxRootStore, InboxRootStore } from "./inbox/root.store";
import { IStateStore, StateStore } from "./state.store";
import { IMemberRootStore, MemberRootStore } from "./member";
import { IInboxRootStore, InboxRootStore } from "./inbox";
import { IEstimateStore, EstimateStore } from "./estimate.store";
import { GlobalViewStore, IGlobalViewStore } from "./global-view.store";
import { IMentionStore, MentionStore } from "./mention.store";
@ -26,12 +26,12 @@ export class RootStore {
workspaceRoot: IWorkspaceRootStore;
projectRoot: IProjectRootStore;
memberRoot: IMemberRootStore;
inboxRoot: IInboxRootStore;
cycle: ICycleStore;
module: IModuleStore;
projectView: IProjectViewStore;
globalView: IGlobalViewStore;
issue: IIssueRootStore;
inbox: IInboxRootStore;
state: IStateStore;
label: ILabelStore;
estimate: IEstimateStore;
@ -45,13 +45,13 @@ export class RootStore {
this.workspaceRoot = new WorkspaceRootStore(this);
this.projectRoot = new ProjectRootStore(this);
this.memberRoot = new MemberRootStore(this);
this.inboxRoot = new InboxRootStore(this);
// independent stores
this.cycle = new CycleStore(this);
this.module = new ModulesStore(this);
this.projectView = new ProjectViewStore(this);
this.globalView = new GlobalViewStore(this);
this.issue = new IssueRootStore(this);
this.inbox = new InboxRootStore(this);
this.state = new StateStore(this);
this.label = new LabelStore(this);
this.estimate = new EstimateStore(this);