[WEB-406] chore: project inbox revamp (#4141)

* chore: removed inbox id

* fix: inbox changes

* chore: resolved merge conflicts

* chore: inbox issue response changes

* chore: inbox issue filters

* fix: inbox implementation revamp

* fix: type fixes

* fix: pagination implementation

* fix: inbox fixes

* fix: pagination fixes

* fix: inbox Issues pagination fixes

* chore: triage state change

* fix: inbox fixes

* chore: filtering using boolean

* chore: total results in the pagination

* fix: inbox main content changes

* fix: develop pull fixes

* chore: resolved build erros in inbox issues

* dev: fix migrations

* chore: module, labels and assignee in inbox

* chore: inbox issue order by

* chore: inbox filters

* chore: inbox ui revamp

* chore: inbox type updated

* chore: updated filters

* chore: updated filter menmbers and date types in inbox issue filter

* chore: inbox issue filter updated

* chore: updated date filter in the inbox issue filter

* chore: moved the current tab state from local state to store

* chore: updated the filter and fetch request in the inbox issues

* chore: updated tab change handler

* chore: handled isEmpty in the issue filters query params

* chore: inbox sidebar updated

* chore: enabled create inbox issue in mobx

* chore: replaced the key inbox_status to status

* chore: inbox sidebar pagination

* chore: updated inbox issue services

* chore: inbox sidebar total count indicator

* chore: create inbox issue updated

* chore: updated inbox issue sidebar layout

* chore: rendering issue detail in inbox issue

* chore: inbox issue content updated

* chore: create inbox issue modal description improvement

* fix: updated delete functionality in inbox store

* chore: updated multiple inbox issue creation

* chore: handled loading, empty states and inbox user access permissions

* chore: updated rendering issues in the sidebar

* chore: inbox sidebar label improvement

* chore: handled empty states

* chore: disabled inbox empty state added

* chore: module, labels and assignee in list endpoint

* chore: labels in list endpoint

* chore: inboc issue serializer

* chore: representation in serializer

* chore: super function

* chore: inbox empty state updated

* chore: implemented applied filters

* chore: inbox empty state updated

* chore: update date formats in applied filters

* chore: inbox skeleton updated

* chore: ui changes in the siebar list item

* chore: removed the module and cycle ids

* chore: inbox sidebar tab

* chore: inbox actions

* chore: updated inbox issue header actions

* chore: updated inbox issue code cleanup

* chore: loader improvement

* chore: inbox sidebar improvement

* chore: inbox sidebar empty state flicker

* fix: inbox issue delete operation

* chore: inbox issue title and description update indicator added

* fix: resolved issue property rendering in initial load

* chore: inbox sidebar and detail header improvement

* fix: handling selected filter in the issue filters and applied filters

* chore: inbox issue detail improvement

* chore: inbox issue label updated

* chore: inbox issue sidebar improvement

* fix: handling issue description update when we move between the issues in inbox

* chore: removed inbox issue helpers file

* chore: boolean checked

* chore: resolved file change requests

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
This commit is contained in:
guru_sainath 2024-04-08 19:11:47 +05:30 committed by GitHub
parent 9b0949148f
commit ddb07dbe5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
107 changed files with 3137 additions and 2673 deletions

View file

@ -1,16 +1,15 @@
import { FC, useState } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import { Plus } from "lucide-react";
// hooks
import { Plus, RefreshCcw } from "lucide-react";
// ui
import { Breadcrumbs, Button, LayersIcon } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { CreateInboxIssueModal } from "@/components/inbox";
// helper
import { ProjectLogo } from "@/components/project";
import { useProject } from "@/hooks/store";
// hooks
import { useProject, useProjectInbox } from "@/hooks/store";
export const ProjectInboxHeader: FC = observer(() => {
// states
@ -20,11 +19,12 @@ export const ProjectInboxHeader: FC = observer(() => {
const { workspaceSlug } = router.query;
// store hooks
const { currentProjectDetails } = useProject();
const { isLoading } = useProjectInbox();
return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div>
<div className="flex items-center gap-4">
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
@ -50,6 +50,13 @@ export const ProjectInboxHeader: FC = observer(() => {
}
/>
</Breadcrumbs>
{isLoading === "pagination-loading" && (
<div className="flex items-center gap-1.5 text-custom-text-300">
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
<p className="text-sm">Syncing...</p>
</div>
)}
</div>
</div>

View file

@ -0,0 +1,276 @@
import { FC, useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { ChevronDown, ChevronUp, Clock, ExternalLink, FileStack, Link, Trash2 } from "lucide-react";
import { Button, ControlLink, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
// components
import {
AcceptIssueModal,
DeclineIssueModal,
DeleteInboxIssueModal,
InboxIssueSnoozeModal,
InboxIssueStatus,
SelectDuplicateInboxIssueModal,
} from "@/components/inbox";
import { IssueUpdateStatus } from "@/components/issues";
// constants
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useUser, useProjectInbox, useProject } from "@/hooks/store";
// store types
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
type TInboxIssueActionsHeader = {
workspaceSlug: string;
projectId: string;
inboxIssue: IInboxIssueStore | undefined;
isSubmitting: "submitting" | "submitted" | "saved";
};
export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((props) => {
const { workspaceSlug, projectId, inboxIssue, isSubmitting } = props;
// states
const [isSnoozeDateModalOpen, setIsSnoozeDateModalOpen] = useState(false);
const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false);
const [acceptIssueModal, setAcceptIssueModal] = useState(false);
const [declineIssueModal, setDeclineIssueModal] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
// store
const { deleteInboxIssue, inboxIssuesArray } = useProjectInbox();
const {
currentUser,
membership: { currentProjectRole },
} = useUser();
const router = useRouter();
const { getProjectById } = useProject();
const issue = inboxIssue?.issue;
// derived values
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const canMarkAsDuplicate = isAllowed && inboxIssue?.status === -2;
const canMarkAsAccepted = isAllowed && (inboxIssue?.status === 0 || inboxIssue?.status === -2);
const canMarkAsDeclined = isAllowed && inboxIssue?.status === -2;
const canDelete = isAllowed || inboxIssue?.created_by === currentUser?.id;
const isCompleted = inboxIssue?.status === 1;
const currentInboxIssueId = inboxIssue?.issue?.id;
const issueLink = `${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`;
const handleInboxIssueAccept = async () => {
inboxIssue?.updateInboxIssueStatus(1);
setAcceptIssueModal(false);
};
const handleInboxIssueDecline = async () => {
inboxIssue?.updateInboxIssueStatus(-1);
setDeclineIssueModal(false);
};
const handleInboxIssueDuplicate = (issueId: string) => {
inboxIssue?.updateInboxIssueDuplicateTo(issueId);
};
const handleInboxSIssueSnooze = async (date: Date) => {
inboxIssue?.updateInboxIssueSnoozeTill(date);
setIsSnoozeDateModalOpen(false);
};
const handleInboxIssueDelete = async () => {
if (!inboxIssue || !currentInboxIssueId) return;
deleteInboxIssue(workspaceSlug, projectId, currentInboxIssueId).finally(() => {
router.push(`/${workspaceSlug}/projects/${projectId}/inbox`);
});
};
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link copied",
message: "Issue link copied to clipboard",
})
);
const currentIssueIndex = inboxIssuesArray.findIndex((issue) => issue.issue.id === currentInboxIssueId) ?? 0;
const handleInboxIssueNavigation = useCallback(
(direction: "next" | "prev") => {
if (!inboxIssuesArray || !currentInboxIssueId) return;
const activeElement = document.activeElement as HTMLElement;
if (activeElement && (activeElement.classList.contains("tiptap") || activeElement.id === "title-input")) return;
const nextIssueIndex =
direction === "next"
? (currentIssueIndex + 1) % inboxIssuesArray.length
: (currentIssueIndex - 1 + inboxIssuesArray.length) % inboxIssuesArray.length;
const nextIssueId = inboxIssuesArray[nextIssueIndex].issue.id;
if (!nextIssueId) return;
router.push(`/${workspaceSlug}/projects/${projectId}/inbox?inboxIssueId=${nextIssueId}`);
},
[currentInboxIssueId, currentIssueIndex, inboxIssuesArray, projectId, router, workspaceSlug]
);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "ArrowUp") {
handleInboxIssueNavigation("prev");
} else if (e.key === "ArrowDown") {
handleInboxIssueNavigation("next");
}
},
[handleInboxIssueNavigation]
);
useEffect(() => {
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [onKeyDown]);
if (!inboxIssue) return null;
return (
<>
<>
<SelectDuplicateInboxIssueModal
isOpen={selectDuplicateIssue}
onClose={() => setSelectDuplicateIssue(false)}
value={inboxIssue?.duplicate_to}
onSubmit={handleInboxIssueDuplicate}
/>
<AcceptIssueModal
data={inboxIssue?.issue}
isOpen={acceptIssueModal}
onClose={() => setAcceptIssueModal(false)}
onSubmit={handleInboxIssueAccept}
/>
<DeclineIssueModal
data={inboxIssue?.issue || {}}
isOpen={declineIssueModal}
onClose={() => setDeclineIssueModal(false)}
onSubmit={handleInboxIssueDecline}
/>
<DeleteInboxIssueModal
data={inboxIssue?.issue}
isOpen={deleteIssueModal}
onClose={() => setDeleteIssueModal(false)}
onSubmit={handleInboxIssueDelete}
/>
<InboxIssueSnoozeModal
isOpen={isSnoozeDateModalOpen}
handleClose={() => setIsSnoozeDateModalOpen(false)}
value={inboxIssue?.snoozed_till}
onConfirm={handleInboxSIssueSnooze}
/>
</>
<div className="relative flex h-full w-full items-center justify-between gap-2 px-4">
<div className="flex items-center gap-4">
{issue?.project_id && issue.sequence_id && (
<h3 className="text-base font-medium text-custom-text-300 flex-shrink-0">
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
</h3>
)}
<InboxIssueStatus inboxIssue={inboxIssue} />
<div className="flex items-center justify-end w-full">
<IssueUpdateStatus isSubmitting={isSubmitting} />
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-2">
<button
type="button"
className="rounded border border-custom-border-200 p-1.5"
onClick={() => handleInboxIssueNavigation("prev")}
>
<ChevronUp size={14} strokeWidth={2} />
</button>
<button
type="button"
className="rounded border border-custom-border-200 p-1.5"
onClick={() => handleInboxIssueNavigation("next")}
>
<ChevronDown size={14} strokeWidth={2} />
</button>
</div>
<div className="flex flex-wrap items-center gap-2">
{canMarkAsAccepted && (
<div className="flex-shrink-0">
<Button variant="neutral-primary" size="sm" onClick={() => setAcceptIssueModal(true)}>
Accept
</Button>
</div>
)}
{canMarkAsDeclined && (
<div className="flex-shrink-0">
<Button variant="neutral-primary" size="sm" onClick={() => setDeclineIssueModal(true)}>
Decline
</Button>
</div>
)}
{isCompleted ? (
<div className="flex items-center gap-2">
<Button
variant="neutral-primary"
prependIcon={<Link className="h-2.5 w-2.5" />}
size="sm"
onClick={handleCopyIssueLink}
>
Copy issue link
</Button>
<ControlLink
href={`/${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`}
onClick={() =>
router.push(`/${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`)
}
>
<Button variant="neutral-primary" prependIcon={<ExternalLink className="h-2.5 w-2.5" />} size="sm">
Open issue
</Button>
</ControlLink>
</div>
) : (
<CustomMenu verticalEllipsis placement="bottom-start">
{canMarkAsAccepted && (
<CustomMenu.MenuItem onClick={() => setIsSnoozeDateModalOpen(true)}>
<div className="flex items-center gap-2">
<Clock size={14} strokeWidth={2} />
Snooze
</div>
</CustomMenu.MenuItem>
)}
{canMarkAsDuplicate && (
<CustomMenu.MenuItem onClick={() => setSelectDuplicateIssue(true)}>
<div className="flex items-center gap-2">
<FileStack size={14} strokeWidth={2} />
Mark as duplicate
</div>
</CustomMenu.MenuItem>
)}
{canDelete && (
<CustomMenu.MenuItem onClick={() => setDeleteIssueModal(true)}>
<div className="flex items-center gap-2">
<Trash2 size={14} strokeWidth={2} />
Delete
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
</div>
</div>
</>
);
});

View file

@ -0,0 +1,4 @@
export * from "./root";
export * from "./inbox-issue-header";
export * from "./issue-properties";
export * from "./issue-root";

View file

@ -1,60 +1,32 @@
import React from "react";
import { observer } from "mobx-react-lite";
import { observer } from "mobx-react";
import { CalendarCheck2, Signal, Tag } from "lucide-react";
// hooks
import { TIssue } from "@plane/types";
import { DoubleCircleIcon, UserGroupIcon } from "@plane/ui";
// components
import { DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui";
import { DateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "@/components/dropdowns";
import { IssueLabel, TIssueOperations } from "@/components/issues";
// icons
// helper
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
issue: Partial<TIssue>;
issueOperations: TIssueOperations;
is_editable: boolean;
};
export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, issueOperations, is_editable } = props;
// store hooks
const { getProjectById } = useProject();
const { projectStates } = useProjectState();
const {
issue: { getIssueById },
} = useIssueDetail();
const issue = getIssueById(issueId);
if (!issue) return <></>;
const projectDetails = issue ? getProjectById(issue.project_id) : null;
export const InboxIssueProperties: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issue, issueOperations, is_editable } = props;
const minDate = issue.start_date ? getDate(issue.start_date) : null;
minDate?.setDate(minDate.getDate());
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);
if (!issue || !issue?.id) return <></>;
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">
{currentIssueState && (
<StateGroupIcon className="h-4 w-4" stateGroup={currentIssueState.group} color={currentIssueState.color} />
)}
<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="flex h-min w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
<div className="h-min w-full overflow-y-auto px-5">
<h5 className="text-sm font-medium my-4">Properties</h5>
<div className={`divide-y-2 divide-custom-border-200 ${!is_editable ? "opacity-60" : ""}`}>
<div className="flex flex-col gap-3">
@ -64,18 +36,22 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
<span>State</span>
</div>
<StateDropdown
value={issue?.state_id ?? undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })}
projectId={projectId?.toString() ?? ""}
disabled={!is_editable}
buttonVariant="transparent-with-text"
className="w-3/5 flex-grow group"
buttonContainerClassName="w-full text-left"
buttonClassName="text-sm"
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
{issue?.state_id && (
<StateDropdown
value={issue?.state_id}
onChange={(val) =>
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { state_id: val })
}
projectId={projectId?.toString() ?? ""}
disabled={!is_editable}
buttonVariant="transparent-with-text"
className="w-3/5 flex-grow group"
buttonContainerClassName="w-full text-left"
buttonClassName="text-sm"
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
)}
</div>
{/* Assignee */}
<div className="flex items-center gap-2 h-8">
@ -84,17 +60,21 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
<span>Assignees</span>
</div>
<MemberDropdown
value={issue?.assignee_ids ?? undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
value={issue?.assignee_ids ?? []}
onChange={(val) =>
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { assignee_ids: val })
}
disabled={!is_editable}
projectId={projectId?.toString() ?? ""}
placeholder="Add assignees"
multiple
buttonVariant={issue?.assignee_ids?.length > 0 ? "transparent-without-text" : "transparent-with-text"}
buttonVariant={
(issue?.assignee_ids || [])?.length > 0 ? "transparent-without-text" : "transparent-with-text"
}
className="w-3/5 flex-grow group"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-sm justify-between ${
issue?.assignee_ids.length > 0 ? "" : "text-custom-text-400"
(issue?.assignee_ids || [])?.length > 0 ? "" : "text-custom-text-400"
}`}
hideIcon={issue.assignee_ids?.length === 0}
dropdownArrow
@ -108,8 +88,10 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
<span>Priority</span>
</div>
<PriorityDropdown
value={issue?.priority || undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })}
value={issue?.priority || "none"}
onChange={(val) =>
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { priority: val })
}
disabled={!is_editable}
buttonVariant="border-with-text"
className="w-3/5 flex-grow rounded px-2 hover:bg-custom-background-80"
@ -129,9 +111,10 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
</div>
<DateDropdown
placeholder="Add due date"
value={issue.target_date}
value={issue.target_date || null}
onChange={(val) =>
issueOperations.update(workspaceSlug, projectId, issueId, {
issue?.id &&
issueOperations.update(workspaceSlug, projectId, issue?.id, {
target_date: val ? renderFormattedPayloadDate(val) : null,
})
}
@ -152,16 +135,18 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
<span>Labels</span>
</div>
<div className="w-3/5 flex-grow min-h-8 h-full pt-1">
<IssueLabel
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={!is_editable}
isInboxIssue
onLabelUpdate={(val: string[]) =>
issueOperations.update(workspaceSlug, projectId, issueId, { label_ids: val })
}
/>
{issue?.id && (
<IssueLabel
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issue?.id}
disabled={!is_editable}
isInboxIssue
onLabelUpdate={(val: string[]) =>
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { label_ids: val })
}
/>
)}
</div>
</div>
</div>

View file

@ -0,0 +1,174 @@
import { Dispatch, SetStateAction, useEffect, useMemo } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { TIssue } from "@plane/types";
import { Loader, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { InboxIssueProperties } from "@/components/inbox/content";
import {
IssueDescriptionInput,
IssueTitleInput,
IssueActivity,
IssueReaction,
TIssueOperations,
} from "@/components/issues";
// hooks
import { useEventTracker, useProjectInbox, useUser } from "@/hooks/store";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// store types
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
type Props = {
workspaceSlug: string;
projectId: string;
inboxIssue: IInboxIssueStore;
is_editable: boolean;
isSubmitting: "submitting" | "submitted" | "saved";
setIsSubmitting: Dispatch<SetStateAction<"submitting" | "submitted" | "saved">>;
};
export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
const router = useRouter();
const { workspaceSlug, projectId, inboxIssue, is_editable, isSubmitting, setIsSubmitting } = props;
// hooks
const { currentUser } = useUser();
const { isLoading } = useProjectInbox();
const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
const { captureIssueEvent } = useEventTracker();
useEffect(() => {
if (isSubmitting === "submitted") {
setShowAlert(false);
setTimeout(async () => {
setIsSubmitting("saved");
}, 3000);
} else if (isSubmitting === "submitting") {
setShowAlert(true);
}
}, [isSubmitting, setShowAlert, setIsSubmitting]);
const issue = inboxIssue.issue;
if (!issue) return <></>;
const issueDescription =
issue.description_html !== undefined || issue.description_html !== null
? issue.description_html != ""
? issue.description_html
: "<p></p>"
: undefined;
const issueOperations: TIssueOperations = useMemo(
() => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
fetch: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
return;
} catch (error) {
setToast({
title: "Issue fetch failed",
type: TOAST_TYPE.ERROR,
message: "Issue fetch failed",
});
}
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
return;
} catch (error) {
setToast({
title: "Issue remove failed",
type: TOAST_TYPE.ERROR,
message: "Issue remove failed",
});
}
},
update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
try {
await inboxIssue.updateIssue(data);
captureIssueEvent({
eventName: "Inbox issue updated",
payload: { ...data, state: "SUCCESS", element: "Inbox" },
updates: {
changed_property: Object.keys(data).join(","),
change_details: Object.values(data).join(","),
},
path: router.asPath,
});
} catch (error) {
setToast({
title: "Issue update failed",
type: TOAST_TYPE.ERROR,
message: "Issue update failed",
});
captureIssueEvent({
eventName: "Inbox issue updated",
payload: { state: "SUCCESS", element: "Inbox" },
updates: {
changed_property: Object.keys(data).join(","),
change_details: Object.values(data).join(","),
},
path: router.asPath,
});
}
},
}),
[inboxIssue]
);
if (!issue?.project_id || !issue?.id) return <></>;
return (
<>
<div className="rounded-lg space-y-4">
<IssueTitleInput
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={!is_editable}
value={issue.name}
/>
{isLoading ? (
<Loader className="h-[150px] space-y-2 overflow-hidden rounded-md border border-custom-border-200 p-2 py-2">
<Loader.Item width="100%" height="132px" />
</Loader>
) : (
<IssueDescriptionInput
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
value={issueDescription}
initialValue={issueDescription}
disabled={!is_editable}
issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)}
/>
)}
{currentUser && (
<IssueReaction
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issue.id}
currentUser={currentUser}
/>
)}
</div>
<InboxIssueProperties
workspaceSlug={workspaceSlug}
projectId={projectId}
issue={issue}
issueOperations={issueOperations}
is_editable={is_editable}
/>
<div className="pb-12">
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issue.id} />
</div>
</>
);
});

View file

@ -1,86 +1,62 @@
import { FC } from "react";
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { Inbox } from "lucide-react";
// hooks
import { Loader } from "@plane/ui";
import { InboxIssueActionsHeader } from "@/components/inbox";
import { InboxIssueDetailRoot } from "@/components/issues/issue-detail/inbox";
import { useInboxIssues } from "@/hooks/store";
// components
// ui
import useSWR from "swr";
import { InboxIssueActionsHeader, InboxIssueMainContent } from "@/components/inbox";
import { EUserProjectRoles } from "@/constants/project";
import { useProjectInbox, useUser } from "@/hooks/store";
type TInboxContentRoot = {
workspaceSlug: string;
projectId: string;
inboxId: string;
inboxIssueId: string | undefined;
inboxIssueId: string;
};
export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
const { workspaceSlug, projectId, inboxId, inboxIssueId } = props;
const { workspaceSlug, projectId, inboxIssueId } = props;
// states
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
// hooks
const { fetchInboxIssueById, getIssueInboxByIssueId } = useProjectInbox();
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
const {
issues: { loader, getInboxIssuesByInboxId },
} = useInboxIssues();
membership: { currentProjectRole },
} = useUser();
const inboxIssuesList = inboxId ? getInboxIssuesByInboxId(inboxId) : undefined;
useSWR(
workspaceSlug && projectId && inboxIssueId
? `PROJECT_INBOX_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}`
: null,
() => {
workspaceSlug && projectId && inboxIssueId && fetchInboxIssueById(workspaceSlug, projectId, inboxIssueId);
},
{ revalidateOnFocus: false }
);
const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
if (!inboxIssue) return <></>;
return (
<>
{loader === "init-loader" ? (
<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>
) : (
<>
{!inboxIssueId ? (
<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 className="w-full h-full overflow-hidden relative flex flex-col">
<div className="flex-shrink-0 min-h-[50px] border-b border-custom-border-300">
<InboxIssueActionsHeader
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxId={inboxId}
inboxIssueId={inboxIssueId}
/>
</div>
<div className="w-full h-full">
<InboxIssueDetailRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxId={inboxId}
issueId={inboxIssueId}
/>
</div>
</div>
)}
</>
)}
<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-300">
<InboxIssueActionsHeader
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxIssue={inboxIssue}
isSubmitting={isSubmitting}
/>
</div>
<div className="h-full w-full space-y-5 divide-y-2 divide-custom-border-300 overflow-y-auto p-5 vertical-scrollbar scrollbar-md">
<InboxIssueMainContent
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxIssue={inboxIssue}
is_editable={is_editable}
isSubmitting={isSubmitting}
setIsSubmitting={setIsSubmitting}
/>
</div>
</div>
</>
);
});

View file

@ -0,0 +1,66 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { X } from "lucide-react";
import { TInboxIssueFilterDateKeys } from "@plane/types";
// constants
import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useProjectInbox } from "@/hooks/store";
type InboxIssueAppliedFiltersDate = {
filterKey: TInboxIssueFilterDateKeys;
label: string;
};
export const InboxIssueAppliedFiltersDate: FC<InboxIssueAppliedFiltersDate> = observer((props) => {
const { filterKey, label } = props;
// hooks
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
// derived values
const filteredValues = inboxFilters?.[filterKey] || [];
const currentOptionDetail = (date: string) => {
const currentDate = DATE_BEFORE_FILTER_OPTIONS.find((d) => d.value === date);
if (currentDate) return currentDate;
const dateSplit = date.split(";");
return {
name: `${dateSplit[1].charAt(0).toUpperCase() + dateSplit[1].slice(1)} ${renderFormattedDate(dateSplit[0])}`,
value: date,
};
};
const handleFilterValue = (value: string): string[] =>
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
const clearFilter = () => handleInboxIssueFilters(filterKey, undefined);
if (filteredValues.length === 0) return <></>;
return (
<div className="relative flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1">
<div className="text-xs text-custom-text-200">{label}</div>
{filteredValues.map((value) => {
const optionDetail = currentOptionDetail(value);
if (!optionDetail) return <></>;
return (
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<div className="text-xs truncate">{optionDetail?.name}</div>
<div
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(optionDetail?.value))}
>
<X className={`w-3 h-3`} />
</div>
</div>
);
})}
<div
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
onClick={clearFilter}
>
<X className={`w-3 h-3`} />
</div>
</div>
);
});

View file

@ -0,0 +1,6 @@
export * from "./root";
export * from "./status";
export * from "./priority";
export * from "./member";
export * from "./label";
export * from "./date";

View file

@ -0,0 +1,55 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { X } from "lucide-react";
// hooks
import { useLabel, useProjectInbox } from "@/hooks/store";
const LabelIcons = ({ color }: { color: string }) => (
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
);
export const InboxIssueAppliedFiltersLabel: FC = observer(() => {
// hooks
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
const { getLabelById } = useLabel();
// derived values
const filteredValues = inboxFilters?.label || [];
const currentOptionDetail = (labelId: string) => getLabelById(labelId) || undefined;
const handleFilterValue = (value: string): string[] =>
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
const clearFilter = () => handleInboxIssueFilters("label", undefined);
if (filteredValues.length === 0) return <></>;
return (
<div className="relative flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1">
<div className="text-xs text-custom-text-200">Label</div>
{filteredValues.map((value) => {
const optionDetail = currentOptionDetail(value);
if (!optionDetail) return <></>;
return (
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<div className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden">
<LabelIcons color={optionDetail.color} />
</div>
<div className="text-xs truncate">{optionDetail?.name}</div>
<div
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
onClick={() => handleInboxIssueFilters("label", handleFilterValue(value))}
>
<X className={`w-3 h-3`} />
</div>
</div>
);
})}
<div
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
onClick={clearFilter}
>
<X className={`w-3 h-3`} />
</div>
</div>
);
});

View file

@ -0,0 +1,59 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { X } from "lucide-react";
import { TInboxIssueFilterMemberKeys } from "@plane/types";
import { Avatar } from "@plane/ui";
// hooks
import { useMember, useProjectInbox } from "@/hooks/store";
type InboxIssueAppliedFiltersMember = {
filterKey: TInboxIssueFilterMemberKeys;
label: string;
};
export const InboxIssueAppliedFiltersMember: FC<InboxIssueAppliedFiltersMember> = observer((props) => {
const { filterKey, label } = props;
// hooks
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
const { getUserDetails } = useMember();
// derived values
const filteredValues = inboxFilters?.[filterKey] || [];
const currentOptionDetail = (memberId: string) => getUserDetails(memberId) || undefined;
const handleFilterValue = (value: string): string[] =>
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
const clearFilter = () => handleInboxIssueFilters(filterKey, undefined);
if (filteredValues.length === 0) return <></>;
return (
<div className="relative flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1">
<div className="text-xs text-custom-text-200">{label}</div>
{filteredValues.map((value) => {
const optionDetail = currentOptionDetail(value);
if (!optionDetail) return <></>;
return (
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<div className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden">
<Avatar name={optionDetail.display_name} src={optionDetail.avatar} showTooltip={false} size="md" />
</div>
<div className="text-xs truncate">{optionDetail?.display_name}</div>
<div
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(value))}
>
<X className={`w-3 h-3`} />
</div>
</div>
);
})}
<div
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
onClick={clearFilter}
>
<X className={`w-3 h-3`} />
</div>
</div>
);
});

View file

@ -0,0 +1,55 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { X } from "lucide-react";
import { TIssuePriorities } from "@plane/types";
import { PriorityIcon } from "@plane/ui";
// constants
import { ISSUE_PRIORITIES } from "@/constants/issue";
// hooks
import { useProjectInbox } from "@/hooks/store";
export const InboxIssueAppliedFiltersPriority: FC = observer(() => {
// hooks
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
// derived values
const filteredValues = inboxFilters?.priority || [];
const currentOptionDetail = (priority: TIssuePriorities) =>
ISSUE_PRIORITIES.find((p) => p.key === priority) || undefined;
const handleFilterValue = (value: TIssuePriorities): TIssuePriorities[] =>
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
const clearFilter = () => handleInboxIssueFilters("priority", undefined);
if (filteredValues.length === 0) return <></>;
return (
<div className="relative flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1">
<div className="text-xs text-custom-text-200">Priority</div>
{filteredValues.map((value) => {
const optionDetail = currentOptionDetail(value);
if (!optionDetail) return <></>;
return (
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<div className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden">
<PriorityIcon priority={optionDetail.key} className="h-3 w-3" />
</div>
<div className="text-xs truncate">{optionDetail?.title}</div>
<div
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
onClick={() => handleInboxIssueFilters("priority", handleFilterValue(optionDetail?.key))}
>
<X className={`w-3 h-3`} />
</div>
</div>
);
})}
<div
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
onClick={clearFilter}
>
<X className={`w-3 h-3`} />
</div>
</div>
);
});

View file

@ -0,0 +1,36 @@
import { FC } from "react";
import { observer } from "mobx-react";
// components
import {
InboxIssueAppliedFiltersStatus,
InboxIssueAppliedFiltersPriority,
InboxIssueAppliedFiltersMember,
InboxIssueAppliedFiltersLabel,
InboxIssueAppliedFiltersDate,
} from "@/components/inbox";
// hooks
import { useProjectInbox } from "@/hooks/store";
export const InboxIssueAppliedFilters: FC = observer(() => {
const { getAppliedFiltersCount } = useProjectInbox();
if (getAppliedFiltersCount === 0) return <></>;
return (
<div className="p-3 py-2 relative flex flex-wrap items-center gap-1 border-b border-custom-border-300">
{/* status */}
<InboxIssueAppliedFiltersStatus />
{/* priority */}
<InboxIssueAppliedFiltersPriority />
{/* assignees */}
<InboxIssueAppliedFiltersMember filterKey="assignee" label="Assignee" />
{/* created_by */}
<InboxIssueAppliedFiltersMember filterKey="created_by" label="Created By" />
{/* label */}
<InboxIssueAppliedFiltersLabel />
{/* created_at */}
<InboxIssueAppliedFiltersDate filterKey="created_at" label="Created At" />
{/* updated_at */}
<InboxIssueAppliedFiltersDate filterKey="updated_at" label="Updated At" />
</div>
);
});

View file

@ -0,0 +1,57 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { X } from "lucide-react";
import { TInboxIssueStatus } from "@plane/types";
// constants
import { INBOX_STATUS } from "@/constants/inbox";
// hooks
import { useProjectInbox } from "@/hooks/store";
export const InboxIssueAppliedFiltersStatus: FC = observer(() => {
// hooks
const { currentTab, inboxFilters, handleInboxIssueFilters } = useProjectInbox();
// derived values
const filteredValues = inboxFilters?.status || [];
const currentOptionDetail = (status: TInboxIssueStatus) => INBOX_STATUS.find((s) => s.status === status) || undefined;
const handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] =>
filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value];
const clearFilter = () => handleInboxIssueFilters("status", undefined);
if (filteredValues.length === 0) return <></>;
return (
<div className="relative flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1">
<div className="text-xs text-custom-text-200">Status</div>
{filteredValues.map((value) => {
const optionDetail = currentOptionDetail(value);
if (!optionDetail) return <></>;
return (
<div key={value} className="relative flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<div className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden">
<optionDetail.icon className={`w-3 h-3 ${optionDetail?.textColor(false)}`} />
</div>
<div className="text-xs truncate">{optionDetail?.title}</div>
{currentTab === "closed" && handleFilterValue(optionDetail?.status).length >= 1 && (
<div
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
onClick={() => handleInboxIssueFilters("status", handleFilterValue(optionDetail?.status))}
>
<X className={`w-3 h-3`} />
</div>
)}
</div>
);
})}
{currentTab === "closed" && filteredValues.length > 1 && (
<div
className="w-3 h-3 flex-shrink-0 relative flex justify-center items-center overflow-hidden cursor-pointer text-custom-text-300 hover:text-custom-text-200 transition-all"
onClick={clearFilter}
>
<X className={`w-3 h-3`} />
</div>
)}
</div>
);
});

View file

@ -0,0 +1,97 @@
import { FC, useState } from "react";
import concat from "lodash/concat";
import pull from "lodash/pull";
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import { TInboxIssueFilterDateKeys } from "@plane/types";
// components
import { DateFilterModal } from "@/components/core";
import { FilterHeader, FilterOption } from "@/components/issues";
// constants
import { DATE_BEFORE_FILTER_OPTIONS } from "@/constants/filters";
// hooks
import { useProjectInbox } from "@/hooks/store";
type Props = {
filterKey: TInboxIssueFilterDateKeys;
label?: string;
searchQuery: string;
};
const isDate = (date: string) => {
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
return datePattern.test(date);
};
export const FilterDate: FC<Props> = observer((props) => {
const { filterKey, label, searchQuery } = props;
// hooks
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
// state
const [previewEnabled, setPreviewEnabled] = useState(true);
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
// derived values
const filterValue: string[] = inboxFilters?.[filterKey] || [];
const appliedFiltersCount = filterValue?.length ?? 0;
const filteredOptions = DATE_BEFORE_FILTER_OPTIONS.filter((d) =>
d.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleFilterValue = (value: string): string[] =>
filterValue?.includes(value) ? pull(filterValue, value) : uniq(concat(filterValue, value));
const handleCustomFilterValue = (value: string[]): string[] => {
const finalOptions: string[] = [...filterValue];
value.forEach((v) => (finalOptions?.includes(v) ? pull(finalOptions, v) : finalOptions.push(v)));
return uniq(finalOptions);
};
const isCustomDateSelected = () => {
const isValidDateSelected = filterValue?.filter((f) => isDate(f.split(";")[0])) || [];
return isValidDateSelected.length > 0 ? true : false;
};
const handleCustomDate = () => {
if (isCustomDateSelected()) {
const updateAppliedFilters = filterValue?.filter((f) => isDate(f.split(";")[0])) || [];
handleInboxIssueFilters(filterKey, handleCustomFilterValue(updateAppliedFilters));
} else setIsDateFilterModalOpen(true);
};
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleInboxIssueFilters(filterKey, handleCustomFilterValue(val))}
title="Created date"
/>
)}
<FilterHeader
title={`${label || "Created date"}${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.map((option) => (
<FilterOption
key={option.value}
isChecked={filterValue?.includes(option.value) ? true : false}
onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(option.value))}
title={option.name}
multiple
/>
))}
<FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View file

@ -0,0 +1,87 @@
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { Search, X } from "lucide-react";
// components
import {
FilterStatus,
FilterPriority,
FilterMember,
FilterDate,
FilterLabels,
} from "@/components/inbox/inbox-filter/filters";
// hooks
import { useMember, useLabel } from "@/hooks/store";
export const InboxIssueFilterSelection: FC = observer(() => {
// hooks
const {
project: { projectMemberIds },
} = useMember();
const { projectLabels } = useLabel();
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="bg-custom-background-100 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
<input
type="text"
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<X className="text-custom-text-300" size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
{/* status */}
<div className="py-2">
<FilterStatus searchQuery={filtersSearchQuery} />
</div>
{/* Priority */}
<div className="py-2">
<FilterPriority searchQuery={filtersSearchQuery} />
</div>
{/* assignees */}
<div className="py-2">
<FilterMember
filterKey="assignee"
label="Assignee"
searchQuery={filtersSearchQuery}
memberIds={projectMemberIds ?? []}
/>
</div>
{/* Created By */}
<div className="py-2">
<FilterMember
filterKey="created_by"
label="Created By"
searchQuery={filtersSearchQuery}
memberIds={projectMemberIds ?? []}
/>
</div>
{/* Labels */}
<div className="py-2">
<FilterLabels searchQuery={filtersSearchQuery} labels={projectLabels ?? []} />
</div>
{/* Created at */}
<div className="py-2">
<FilterDate filterKey="created_at" label="Created at" searchQuery={filtersSearchQuery} />
</div>
{/* Updated at */}
<div className="py-2">
<FilterDate filterKey="updated_at" label="Updated at" searchQuery={filtersSearchQuery} />
</div>
</div>
</div>
);
});

View file

@ -0,0 +1,6 @@
export * from "./filter-selection";
export * from "./status";
export * from "./priority";
export * from "./labels";
export * from "./members";
export * from "./date";

View file

@ -0,0 +1,88 @@
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { IIssueLabel } from "@plane/types";
import { Loader } from "@plane/ui";
// components
import { FilterHeader, FilterOption } from "@/components/issues";
// hooks
import { useProjectInbox } from "@/hooks/store";
const LabelIcons = ({ color }: { color: string }) => (
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
);
type Props = {
labels: IIssueLabel[] | undefined;
searchQuery: string;
};
export const FilterLabels: FC<Props> = observer((props) => {
const { labels, searchQuery } = props;
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
const filterValue = inboxFilters?.label || [];
const appliedFiltersCount = filterValue?.length ?? 0;
const filteredOptions = labels?.filter((label) => label.name.toLowerCase().includes(searchQuery.toLowerCase()));
const handleViewToggle = () => {
if (!filteredOptions) return;
if (itemsToRender === filteredOptions.length) setItemsToRender(5);
else setItemsToRender(filteredOptions.length);
};
const handleFilterValue = (value: string): string[] =>
filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value];
return (
<>
<FilterHeader
title={`Label${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions ? (
filteredOptions.length > 0 ? (
<>
{filteredOptions.slice(0, itemsToRender).map((label) => (
<FilterOption
key={label?.id}
isChecked={filterValue?.includes(label?.id) ? true : false}
onClick={() => handleInboxIssueFilters("label", handleFilterValue(label.id))}
icon={<LabelIcons color={label.color} />}
title={label.name}
/>
))}
{filteredOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === filteredOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View file

@ -0,0 +1,102 @@
import { FC, useMemo, useState } from "react";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import { TInboxIssueFilterMemberKeys } from "@plane/types";
import { Avatar, Loader } from "@plane/ui";
// components
import { FilterHeader, FilterOption } from "@/components/issues";
// hooks
import { useMember, useProjectInbox } from "@/hooks/store";
type Props = {
filterKey: TInboxIssueFilterMemberKeys;
label?: string;
memberIds: string[] | undefined;
searchQuery: string;
};
export const FilterMember: FC<Props> = observer((props: Props) => {
const { filterKey, label = "Members", memberIds, searchQuery } = props;
// hooks
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
const { getUserDetails } = useMember();
// states
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
// derived values
const filterValue = inboxFilters?.[filterKey] || [];
const appliedFiltersCount = filterValue?.length ?? 0;
const sortedOptions = useMemo(() => {
const filteredOptions = (memberIds || []).filter((memberId) =>
getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase())
);
return sortBy(filteredOptions, [
(memberId) => !filterValue.includes(memberId),
(memberId) => getUserDetails(memberId)?.display_name.toLowerCase(),
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
const handleViewToggle = () => {
if (!sortedOptions) return;
if (itemsToRender === sortedOptions.length) setItemsToRender(5);
else setItemsToRender(sortedOptions.length);
};
const handleFilterValue = (value: string): string[] =>
filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value];
return (
<>
<FilterHeader
title={`${label} ${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions ? (
sortedOptions.length > 0 ? (
<>
{sortedOptions.slice(0, itemsToRender).map((memberId) => {
const member = getUserDetails(memberId);
if (!member) return null;
return (
<FilterOption
key={`members-${member.id}`}
isChecked={filterValue?.includes(member.id) ? true : false}
onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(member.id))}
icon={<Avatar name={member.display_name} src={member.avatar} showTooltip={false} size="md" />}
title={member.display_name}
/>
);
})}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)
) : (
<Loader className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</Loader>
)}
</div>
)}
</>
);
});

View file

@ -0,0 +1,56 @@
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { TIssuePriorities } from "@plane/types";
import { PriorityIcon } from "@plane/ui";
// components
import { FilterHeader, FilterOption } from "@/components/issues";
// constants
import { ISSUE_PRIORITIES } from "@/constants/issue";
// hooks
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
type Props = {
searchQuery: string;
};
export const FilterPriority: FC<Props> = observer((props) => {
const { searchQuery } = props;
// hooks
const { inboxFilters, handleInboxIssueFilters } = useProjectInbox();
// states
const [previewEnabled, setPreviewEnabled] = useState(true);
// derived values
const filterValue = inboxFilters?.priority || [];
const appliedFiltersCount = filterValue?.length ?? 0;
const filteredOptions = ISSUE_PRIORITIES.filter((p) => p.key.includes(searchQuery.toLowerCase()));
const handleFilterValue = (value: TIssuePriorities): TIssuePriorities[] =>
filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value];
return (
<>
<FilterHeader
title={`Priority${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
filteredOptions.map((priority) => (
<FilterOption
key={priority.key}
isChecked={filterValue?.includes(priority.key) ? true : false}
onClick={() => handleInboxIssueFilters("priority", handleFilterValue(priority.key))}
icon={<PriorityIcon priority={priority.key} className="h-3.5 w-3.5" />}
title={priority.title}
/>
))
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View file

@ -0,0 +1,68 @@
import { FC, useState } from "react";
import { observer } from "mobx-react";
// types
import { TInboxIssueStatus } from "@plane/types";
// components
import { FilterHeader, FilterOption } from "@/components/issues";
// constants
import { INBOX_STATUS } from "@/constants/inbox";
// hooks
import { useProjectInbox } from "@/hooks/store/use-project-inbox";
type Props = {
searchQuery: string;
};
export const FilterStatus: FC<Props> = observer((props) => {
const { searchQuery } = props;
// hooks
const { currentTab, inboxFilters, handleInboxIssueFilters } = useProjectInbox();
// states
const [previewEnabled, setPreviewEnabled] = useState(true);
// derived values
const filterValue = inboxFilters?.status || [];
const appliedFiltersCount = filterValue?.length ?? 0;
const filteredOptions = INBOX_STATUS.filter(
(s) =>
((currentTab === "open" && [-2].includes(s.status)) ||
(currentTab === "closed" && [-1, 0, 1, 2].includes(s.status))) &&
s.key.includes(searchQuery.toLowerCase())
);
const handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] =>
filterValue?.includes(value) ? filterValue.filter((v) => v !== value) : [...filterValue, value];
const handleStatusFilterSelect = (status: TInboxIssueStatus) => {
if (currentTab === "closed") {
const selectedStatus = handleFilterValue(status);
if (selectedStatus.length >= 1) handleInboxIssueFilters("status", selectedStatus);
}
};
return (
<>
<FilterHeader
title={`Issue Status ${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
filteredOptions.map((status) => (
<FilterOption
key={status.key}
isChecked={filterValue?.includes(status.status) ? true : false}
onClick={() => handleStatusFilterSelect(status.status)}
icon={<status.icon className={`h-3.5 w-3.5 ${status?.textColor(false)}`} />}
title={status.title}
/>
))
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View file

@ -0,0 +1,4 @@
export * from "./root";
export * from "./filters";
export * from "./sorting";
export * from "./applied-filters";

View file

@ -0,0 +1,18 @@
import { FC } from "react";
import { ListFilter } from "lucide-react";
// components
import { InboxIssueFilterSelection, InboxIssueOrderByDropdown } from "@/components/inbox/inbox-filter";
import { FiltersDropdown } from "@/components/issues";
export const FiltersRoot: FC = () => (
<div className="relative flex items-center gap-2">
<div>
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
<InboxIssueFilterSelection />
</FiltersDropdown>
</div>
<div>
<InboxIssueOrderByDropdown />
</div>
</div>
);

View file

@ -0,0 +1 @@
export * from "./order-by";

View file

@ -0,0 +1,58 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { ArrowDownWideNarrow, ArrowUpWideNarrow, Check, ChevronDown } from "lucide-react";
import { CustomMenu, getButtonStyling } from "@plane/ui";
// constants
import { INBOX_ISSUE_ORDER_BY_OPTIONS, INBOX_ISSUE_SORT_BY_OPTIONS } from "@/constants/inbox";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useProjectInbox } from "@/hooks/store";
export const InboxIssueOrderByDropdown: FC = observer(() => {
// hooks
const { inboxSorting, handleInboxIssueSorting } = useProjectInbox();
const orderByDetails =
INBOX_ISSUE_ORDER_BY_OPTIONS.find((option) => inboxSorting?.order_by?.includes(option.key)) || undefined;
return (
<CustomMenu
customButton={
<div className={cn(getButtonStyling("neutral-primary", "sm"), "px-2 text-custom-text-300")}>
{inboxSorting?.sort_by === "asc" ? (
<ArrowUpWideNarrow className="h-3 w-3" />
) : (
<ArrowDownWideNarrow className="h-3 w-3" />
)}
{orderByDetails?.label || "Order By"}
<ChevronDown className="h-3 w-3" strokeWidth={2} />
</div>
}
placement="bottom-end"
maxHeight="lg"
closeOnSelect
>
{INBOX_ISSUE_ORDER_BY_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
className="flex items-center justify-between gap-2"
onClick={() => handleInboxIssueSorting("order_by", option.key)}
>
{option.label}
{inboxSorting?.order_by?.includes(option.key) && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
))}
<hr className="my-2" />
{INBOX_ISSUE_SORT_BY_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
className="flex items-center justify-between gap-2"
onClick={() => handleInboxIssueSorting("sort_by", option.key)}
>
{option.label}
{inboxSorting?.sort_by?.includes(option.key) && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
))}
</CustomMenu>
);
});

View file

@ -1,364 +0,0 @@
import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import { DayPicker } from "react-day-picker";
import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle } from "lucide-react";
import { Popover } from "@headlessui/react";
// icons
import type { TInboxDetailedStatus } from "@plane/types";
// ui
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// components
import {
AcceptIssueModal,
DeclineIssueModal,
DeleteInboxIssueModal,
SelectDuplicateInboxIssueModal,
} from "@/components/inbox";
import { ISSUE_DELETED } from "@/constants/event-tracker";
import { EUserProjectRoles } from "@/constants/project";
// hooks
import { getDate } from "@/helpers/date-time.helper";
import { useUser, useInboxIssues, useIssueDetail, useWorkspace, useEventTracker } from "@/hooks/store";
// types
//helpers
type TInboxIssueActionsHeader = {
workspaceSlug: string;
projectId: string;
inboxId: string;
inboxIssueId: string | undefined;
};
type TInboxIssueOperations = {
updateInboxIssueStatus: (data: TInboxDetailedStatus) => Promise<void>;
removeInboxIssue: () => Promise<void>;
};
export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((props) => {
const { workspaceSlug, projectId, inboxId, inboxIssueId } = props;
// router
const router = useRouter();
// hooks
const { captureIssueEvent } = useEventTracker();
const { currentWorkspace } = useWorkspace();
const {
issues: { getInboxIssuesByInboxId, getInboxIssueByIssueId, updateInboxIssueStatus, removeInboxIssue },
} = useInboxIssues();
const {
issue: { getIssueById },
} = useIssueDetail();
const {
currentUser,
membership: { currentProjectRole },
} = useUser();
// 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) {
setToast({
type: TOAST_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);
captureIssueEvent({
eventName: ISSUE_DELETED,
payload: {
id: inboxIssueId,
state: "SUCCESS",
element: "Inbox page",
},
});
router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
});
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong while deleting inbox issue. Please try again.",
});
captureIssueEvent({
eventName: ISSUE_DELETED,
payload: {
id: inboxIssueId,
state: "FAILED",
element: "Inbox page",
},
});
}
},
}),
[
currentWorkspace,
workspaceSlug,
projectId,
inboxId,
inboxIssueId,
updateInboxIssueStatus,
removeInboxIssue,
captureIssueEvent,
router,
]
);
const handleInboxIssueNavigation = useCallback(
(direction: "next" | "prev") => {
if (!inboxIssues || !inboxIssueId) return;
const activeElement = document.activeElement as HTMLElement;
if (activeElement && (activeElement.classList.contains("tiptap") || activeElement.id === "title-input")) return;
const nextIssueIndex =
direction === "next"
? (currentIssueIndex + 1) % inboxIssues.length
: (currentIssueIndex - 1 + inboxIssues.length) % inboxIssues.length;
const nextIssueId = inboxIssues[nextIssueIndex];
if (!nextIssueId) return;
router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
query: {
inboxIssueId: nextIssueId,
},
});
},
[workspaceSlug, projectId, inboxId, inboxIssues, inboxIssueId, currentIssueIndex, router]
);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "ArrowUp") {
handleInboxIssueNavigation("prev");
} else if (e.key === "ArrowDown") {
handleInboxIssueNavigation("next");
}
},
[handleInboxIssueNavigation]
);
useEffect(() => {
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [onKeyDown]);
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const today = new Date();
const tomorrow = getDate(today);
tomorrow?.setDate(today.getDate() + 1);
useEffect(() => {
if (!issueStatus || !issueStatus.snoozed_till) return;
setDate(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="relative flex h-full w-full items-center justify-between gap-2 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={() => handleInboxIssueNavigation("prev")}
>
<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={() => handleInboxIssueNavigation("next")}
>
<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">
<DayPicker
selected={getDate(date)}
defaultMonth={getDate(date)}
onSelect={(date) => {
if (!date) return;
setDate(date);
}}
mode="single"
className="border border-custom-border-200 rounded-md p-3"
disabled={
tomorrow
? [
{
before: tomorrow,
},
]
: undefined
}
/>
<Button
variant="primary"
onClick={() => {
close();
inboxIssueOperations.updateInboxIssueStatus({
status: 0,
snoozed_till: 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

@ -1,56 +1,45 @@
import React from "react";
import { observer } from "mobx-react";
// hooks
import { INBOX_STATUS } from "@/constants/inbox";
import { useInboxIssues } from "@/hooks/store";
// constants
import { INBOX_STATUS } from "@/constants/inbox";
// helpers
import { cn } from "@/helpers/common.helper";
// store
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
type Props = {
workspaceSlug: string;
projectId: string;
inboxId: string;
issueId: string;
inboxIssue: IInboxIssueStore;
iconSize?: number;
showDescription?: boolean;
};
export const InboxIssueStatus: React.FC<Props> = observer((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);
const { inboxIssue, iconSize = 16, showDescription = false } = props;
// derived values
const inboxIssueStatusDetail = INBOX_STATUS.find((s) => s.status === inboxIssue.status);
if (!inboxIssueStatusDetail) return <></>;
const isSnoozedDatePassed =
inboxIssueDetail.status === 0 && !!inboxIssueDetail.snoozed_till && inboxIssueDetail.snoozed_till < new Date();
const isSnoozedDatePassed = inboxIssue.status === 0 && new Date(inboxIssue.snoozed_till ?? "") < new Date();
const description = inboxIssueStatusDetail.description(new Date(inboxIssue.snoozed_till ?? ""));
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 ?? "",
inboxIssueDetail.snoozed_till
)
) : (
<span>{inboxIssueStatusDetail.title}</span>
className={cn(
`relative flex flex-col gap-1 p-1.5 py-0.5 rounded ${inboxIssueStatusDetail.textColor(
isSnoozedDatePassed
)} ${inboxIssueStatusDetail.bgColor(isSnoozedDatePassed)}`
)}
>
<div className={`flex items-center gap-1`}>
<inboxIssueStatusDetail.icon size={iconSize} />
<div className="font-medium text-xs">
{inboxIssue?.status === 0 && inboxIssue?.snoozed_till
? inboxIssueStatusDetail.description(inboxIssue?.snoozed_till)
: inboxIssueStatusDetail.title}
</div>
</div>
{showDescription && <div className="text-sm">{description}</div>}
</div>
);
});

View file

@ -1,14 +1,6 @@
export * from "./root";
export * from "./modals";
export * from "./inbox-issue-actions";
export * from "./sidebar";
export * from "./inbox-filter";
export * from "./content";
export * from "./inbox-issue-status";
export * from "./content/root";
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

@ -5,11 +5,11 @@ import type { TIssue } from "@plane/types";
// icons
// ui
import { Button } from "@plane/ui";
// types
// hooks
import { useProject } from "@/hooks/store";
type Props = {
data: TIssue;
data: Partial<TIssue>;
isOpen: boolean;
onClose: () => void;
onSubmit: () => Promise<void>;
@ -70,7 +70,8 @@ export const AcceptIssueModal: React.FC<Props> = ({ isOpen, onClose, data, onSub
<p className="text-sm text-custom-text-200">
Are you sure you want to accept issue{" "}
<span className="break-all font-medium text-custom-text-100">
{getProjectById(data?.project_id)?.identifier}-{data?.sequence_id}
{(data && data?.project_id && getProjectById(data?.project_id)?.identifier) || ""}-
{data?.sequence_id}
</span>
{""}? Once accepted, this issue will be added to the project issues list.
</p>

View file

@ -1,24 +1,24 @@
import { Fragment, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import { Sparkle } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
import { Transition, Dialog } from "@headlessui/react";
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
// types
import { TIssue } from "@plane/types";
// hooks
// ui
import { Button, Input, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { GptAssistantPopover } from "@/components/core";
import { PriorityDropdown } from "@/components/dropdowns";
// constants
import { ISSUE_CREATED } from "@/constants/event-tracker";
import { useApplication, useEventTracker, useWorkspace, useInboxIssues, useMention } from "@/hooks/store";
// hooks
import { useApplication, useEventTracker, useWorkspace, useMention, useProjectInbox } from "@/hooks/store";
// services
import { AIService } from "@/services/ai.service";
import { FileService } from "@/services/file.service";
// components
// ui
// types
// constants
type Props = {
isOpen: boolean;
@ -26,10 +26,8 @@ type Props = {
};
const defaultValues: Partial<TIssue> = {
project_id: "",
name: "",
description_html: "<p></p>",
parent_id: null,
priority: "none",
};
@ -39,33 +37,27 @@ const fileService = new FileService();
export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
if (!workspaceSlug || !projectId) return null;
// states
const [createMore, setCreateMore] = useState(false);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
// refs
const editorRef = useRef<any>(null);
// router
const router = useRouter();
const { workspaceSlug, projectId, inboxId } = router.query as {
workspaceSlug: string;
projectId: string;
inboxId: string;
};
// hooks
const { mentionHighlights, mentionSuggestions } = useMention();
const workspaceStore = useWorkspace();
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string;
// store hooks
const {
issues: { createInboxIssue },
} = useInboxIssues();
const { createInboxIssue } = useProjectInbox();
const {
config: { envConfig },
} = useApplication();
const { captureIssueEvent } = useEventTracker();
// form info
const {
control,
formState: { errors, isSubmitting },
@ -73,24 +65,26 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
reset,
watch,
getValues,
} = useForm({ defaultValues });
} = useForm<Partial<TIssue>>({ defaultValues });
const issueName = watch("name");
const handleClose = () => {
onClose();
reset(defaultValues);
editorRef?.current?.clearEditor();
};
const issueName = watch("name");
const handleFormSubmit = async (formData: Partial<TIssue>) => {
if (!workspaceSlug || !projectId || !inboxId) return;
await createInboxIssue(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), formData)
if (!workspaceSlug || !projectId) return;
await createInboxIssue(workspaceSlug.toString(), projectId.toString(), formData)
.then((res) => {
if (!createMore) {
router.push(`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${res.id}`);
router.push(`/${workspaceSlug}/projects/${projectId}/inbox/?inboxIssueId=${res?.issue?.id}`);
handleClose();
} else reset(defaultValues);
} else {
reset(defaultValues);
editorRef?.current?.clearEditor();
}
captureIssueEvent({
eventName: ISSUE_CREATED,
payload: {
@ -117,11 +111,11 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
const handleAiAssistance = async (response: string) => {
if (!workspaceSlug || !projectId) return;
editorRef.current?.setEditorValueAtCursorPosition(response);
};
const handleAutoGenerateDescription = async () => {
const issueName = getValues("name");
if (!workspaceSlug || !projectId || !issueName) return;
setIAmFeelingLucky(true);
@ -220,7 +214,7 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
</div>
<div className="relative">
<div className="border-0.5 absolute bottom-3.5 right-3.5 z-10 flex rounded bg-custom-background-80">
{issueName && issueName !== "" && (
{watch("name") && issueName !== "" && (
<button
type="button"
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${
@ -242,7 +236,7 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
{envConfig?.has_openai_configured && (
<GptAssistantPopover
isOpen={gptAssistantModal}
projectId={projectId}
projectId={projectId.toString()}
handleClose={() => {
setGptAssistantModal((prevData) => !prevData);
// this is done so that the title do not reset after gpt popover closed

View file

@ -9,7 +9,7 @@ import { Button } from "@plane/ui";
import { useProject } from "@/hooks/store";
type Props = {
data: TIssue;
data: Partial<TIssue>;
isOpen: boolean;
onClose: () => void;
onSubmit: () => Promise<void>;
@ -70,7 +70,8 @@ export const DeclineIssueModal: React.FC<Props> = ({ isOpen, onClose, data, onSu
<p className="text-sm text-custom-text-200">
Are you sure you want to decline issue{" "}
<span className="break-words font-medium text-custom-text-100">
{getProjectById(data?.project_id)?.identifier}-{data?.sequence_id}
{(data && data?.project_id && getProjectById(data?.project_id)?.identifier) || ""}-
{data?.sequence_id}
</span>
{""}? This action cannot be undone.
</p>

View file

@ -1,5 +1,5 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
import { observer } from "mobx-react";
import { AlertTriangle } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
// hooks
@ -11,7 +11,7 @@ import { useProject } from "@/hooks/store";
// types
type Props = {
data: TIssue;
data: Partial<TIssue>;
isOpen: boolean;
onClose: () => void;
onSubmit: () => Promise<void>;
@ -30,7 +30,7 @@ export const DeleteInboxIssueModal: React.FC<Props> = observer(({ isOpen, onClos
const handleDelete = () => {
setIsDeleting(true);
onSubmit().finally(() => setIsDeleting(false));
onSubmit().finally(() => handleClose());
};
return (
@ -73,7 +73,8 @@ export const DeleteInboxIssueModal: React.FC<Props> = observer(({ isOpen, onClos
<p className="text-sm text-custom-text-200">
Are you sure you want to delete issue{" "}
<span className="break-words font-medium text-custom-text-100">
{getProjectById(data?.project_id)?.identifier}-{data?.sequence_id}
{(data && data?.project_id && getProjectById(data?.project_id)?.identifier) || ""}-
{data?.sequence_id}
</span>
{""}? The issue will only be deleted from the inbox and this action cannot be undone.
</p>

View file

@ -3,3 +3,4 @@ export * from "./create-issue-modal";
export * from "./decline-issue-modal";
export * from "./delete-issue-modal";
export * from "./select-duplicate";
export * from "./snooze-issue-modal";

View file

@ -0,0 +1,78 @@
import { FC, Fragment, useState } from "react";
import { DayPicker } from "react-day-picker";
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "@plane/ui";
export type InboxIssueSnoozeModalProps = {
isOpen: boolean;
value: Date | undefined;
onConfirm: (value: Date) => void;
handleClose: () => void;
};
export const InboxIssueSnoozeModal: FC<InboxIssueSnoozeModalProps> = (props) => {
const { isOpen, handleClose, value, onConfirm } = props;
// states
const [date, setDate] = useState(value || new Date());
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 flex w-full justify-center overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative flex transform rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<div className="flex h-full w-full flex-col gap-y-1">
<DayPicker
selected={date ? new Date(date) : undefined}
defaultMonth={date ? new Date(date) : undefined}
onSelect={(date) => {
if (!date) return;
setDate(date);
}}
mode="single"
className="rounded-md border border-custom-border-200 p-3"
// disabled={[
// {
// before: tomorrow,
// },
// ]}
/>
<Button
variant="primary"
onClick={() => {
close();
onConfirm(date);
}}
>
Snooze
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View file

@ -0,0 +1,68 @@
import { FC } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { Inbox } from "lucide-react";
// components
import { EmptyState } from "@/components/empty-state";
import { InboxSidebar, InboxContentRoot } from "@/components/inbox";
import { InboxLayoutLoader } from "@/components/ui";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// hooks
import { useProjectInbox } from "@/hooks/store";
type TInboxIssueRoot = {
workspaceSlug: string;
projectId: string;
inboxIssueId: string | undefined;
inboxAccessible: boolean;
};
export const InboxIssueRoot: FC<TInboxIssueRoot> = observer((props) => {
const { workspaceSlug, projectId, inboxIssueId, inboxAccessible } = props;
// hooks
const { isLoading, error, fetchInboxIssues } = useProjectInbox();
useSWR(
inboxAccessible && workspaceSlug && projectId ? `PROJECT_INBOX_ISSUES_${workspaceSlug}_${projectId}` : null,
() => {
inboxAccessible && workspaceSlug && projectId && fetchInboxIssues(workspaceSlug.toString(), projectId.toString());
},
{ revalidateOnFocus: false }
);
// loader
if (isLoading === "init-loading")
return (
<div className="relative flex w-full h-full flex-col">
<InboxLayoutLoader />
</div>
);
// error
if (error && error?.status === "init-error")
return (
<div className="relative w-full h-full flex flex-col gap-3 justify-center items-center">
<Inbox size={60} strokeWidth={1.5} />
<div className="text-custom-text-200">{error?.message}</div>
</div>
);
return (
<div className="relative w-full h-full flex overflow-hidden">
<InboxSidebar workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
{inboxIssueId ? (
<InboxContentRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
inboxIssueId={inboxIssueId.toString()}
/>
) : (
<div className="w-full h-full relative flex justify-center items-center">
<EmptyState type={EmptyStateType.INBOX_DETAIL_EMPTY_STATE} layout="screen-simple" />
</div>
)}
</div>
);
});

View file

@ -1,171 +0,0 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// mobx store
// icons
import { X } from "lucide-react";
import { TInboxIssueFilterOptions, TIssuePriorities } from "@plane/types";
import { PriorityIcon } from "@plane/ui";
// helpers
import { INBOX_STATUS } from "@/constants/inbox";
import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper";
// types
import { useInboxIssues } from "@/hooks/store";
// constants
type TInboxIssueAppliedFilter = { workspaceSlug: string; projectId: string; inboxId: string };
export const IssueStatusLabel = ({ status }: { status: number }) => {
const issueStatusDetail = INBOX_STATUS.find((s) => s.status === status);
if (!issueStatusDetail) return <></>;
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 = () => {
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 TInboxIssueFilterOptions;
if (filters?.[filterKey] && Array.isArray(filters[filterKey])) filtersLength += (filters[filterKey] ?? []).length;
});
if (!filters || filtersLength <= 0) return <></>;
return (
<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 TInboxIssueFilterOptions;
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]?.length < 0 ? (
<span className="inline-flex items-center px-2 py-0.5 font-medium">None</span>
) : (
<div className="space-x-2">
{filterKey === "priority" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.priority?.map((priority) => (
<div
key={priority}
className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize ${
priority === "urgent"
? "bg-red-500/20 text-red-500"
: priority === "high"
? "bg-orange-500/20 text-orange-500"
: priority === "medium"
? "bg-yellow-500/20 text-yellow-500"
: priority === "low"
? "bg-green-500/20 text-green-500"
: "bg-custom-background-90 text-custom-text-200"
}`}
>
<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"
onClick={() =>
handleUpdateFilter({
priority: filters.priority?.filter((p) => p !== priority),
})
}
>
<X className="h-3 w-3" />
</button>
</div>
))}
<button
type="button"
onClick={() =>
handleUpdateFilter({
priority: [],
})
}
>
<X className="h-3 w-3" />
</button>
</div>
) : filterKey === "inbox_status" ? (
<div className="flex flex-wrap items-center gap-1">
{filters.inbox_status?.map((status) => (
<div
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"
>
<IssueStatusLabel status={status} />
<button
type="button"
className="cursor-pointer"
onClick={() =>
handleUpdateFilter({
inbox_status: filters.inbox_status?.filter((p) => p !== status),
})
}
>
<X className="h-3 w-3" />
</button>
</div>
))}
<button
type="button"
onClick={() =>
handleUpdateFilter({
inbox_status: [],
})
}
>
<X className="h-3 w-3" />
</button>
</div>
) : (
(filters[filterKey] as any)?.join(", ")
)}
</div>
)}
</div>
);
})}
<button
type="button"
onClick={handleClearAllFilters}
className="flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-80 px-3 py-1.5 text-custom-text-200 hover:text-custom-text-100"
>
<span>Clear all</span>
<X className="h-3 w-3" />
</button>
</div>
);
});

View file

@ -1,117 +0,0 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import { TInboxIssueFilterOptions } from "@plane/types";
// mobx store
// ui
// icons
import { PriorityIcon } from "@plane/ui";
import { MultiLevelDropdown } from "@/components/ui";
// types
// constants
import { INBOX_STATUS } from "@/constants/inbox";
import { ISSUE_PRIORITIES } from "@/constants/issue";
import { useInboxIssues } from "@/hooks/store";
type TInboxIssueFilterSelection = { workspaceSlug: string; projectId: string; inboxId: string };
export const InboxIssueFilterSelection: FC<TInboxIssueFilterSelection> = observer((props) => {
const { workspaceSlug, projectId, inboxId } = props;
// router
const router = useRouter();
const { inboxIssueId } = router.query;
// hooks
const {
filters: { inboxFilters, updateInboxFilters },
} = useInboxIssues();
const filters = inboxFilters?.filters;
let filtersLength = 0;
Object.keys(filters ?? {}).forEach((key) => {
const filterKey = key as keyof TInboxIssueFilterOptions;
if (filters?.[filterKey] && Array.isArray(filters[filterKey])) filtersLength += (filters[filterKey] ?? []).length;
});
return (
<div className="relative">
<MultiLevelDropdown
label="Filters"
onSelect={(option) => {
if (!workspaceSlug || !projectId || !inboxId) return;
const key = option.key as keyof TInboxIssueFilterOptions;
const currentValue: any[] = filters?.[key] ?? [];
const valueExists = currentValue.includes(option.value);
if (valueExists)
updateInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), {
[option.key]: currentValue.filter((val) => val !== option.value),
});
else
updateInboxFilters(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), {
[option.key]: [...currentValue, option.value],
});
if (inboxIssueId) {
router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
});
}
}}
direction="right"
height="rg"
options={[
{
id: "priority",
label: "Priority",
value: ISSUE_PRIORITIES.map((p) => p.key),
hasChildren: true,
children: ISSUE_PRIORITIES.map((priority) => ({
id: priority.key,
label: (
<div className="flex items-center gap-2 capitalize">
<PriorityIcon priority={priority.key} /> {priority.title ?? "None"}
</div>
),
value: {
key: "priority",
value: priority.key,
},
selected: filters?.priority?.includes(priority.key),
})),
},
{
id: "inbox_status",
label: "Status",
value: INBOX_STATUS.map((status) => status.status),
hasChildren: true,
children: INBOX_STATUS.map((status) => ({
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.status,
},
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>
</div>
)}
</div>
);
});

View file

@ -1,49 +1,40 @@
import { FC, useEffect } from "react";
import { FC, MouseEvent, 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
// ui
import { Tooltip, PriorityIcon } from "@plane/ui";
// helpers
import { InboxIssueStatus } from "@/components/inbox/inbox-issue-status";
import { renderFormattedDate } from "@/helpers/date-time.helper";
// components
import { useInboxIssues, useIssueDetail, useProject } from "@/hooks/store";
import { InboxIssueStatus } from "@/components/inbox";
// helpers
import { cn } from "@/helpers/common.helper";
import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useLabel } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// store
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
type TInboxIssueListItem = {
type InboxIssueListItemProps = {
workspaceSlug: string;
projectId: string;
inboxId: string;
issueId: string;
projectIdentifier?: string;
inboxIssue: IInboxIssueStore;
};
export const InboxIssueListItem: FC<TInboxIssueListItem> = observer((props) => {
const { workspaceSlug, projectId, inboxId, issueId } = props;
export const InboxIssueListItem: FC<InboxIssueListItemProps> = observer((props) => {
const { workspaceSlug, projectId, inboxIssue, projectIdentifier } = props;
// router
const router = useRouter();
const { inboxIssueId } = router.query;
// hooks
const { getProjectById } = useProject();
const {
issues: { getInboxIssueByIssueId },
} = useInboxIssues();
const {
issue: { getIssueById },
} = useIssueDetail();
// store
const { projectLabels } = useLabel();
const { isMobile } = usePlatformOS();
const inboxIssueDetail = getInboxIssueByIssueId(inboxId, issueId);
const issue = getIssueById(issueId);
if (!issue || !inboxIssueDetail) return <></>;
const issue = inboxIssue.issue;
useEffect(() => {
if (issueId === inboxIssueId) {
if (issue.id === inboxIssueId) {
setTimeout(() => {
const issueItemCard = document.getElementById(`inbox-issue-list-item-${issueId}`);
const issueItemCard = document.getElementById(`inbox-issue-list-item-${issue.id}`);
if (issueItemCard)
issueItemCard.scrollIntoView({
behavior: "smooth",
@ -51,52 +42,81 @@ export const InboxIssueListItem: FC<TInboxIssueListItem> = observer((props) => {
});
}, 200);
}
}, [issueId, inboxIssueId]);
}, [inboxIssueId, issue.id]);
const handleIssueRedirection = (event: MouseEvent, currentIssueId: string | undefined) => {
if (inboxIssueId === currentIssueId) event.preventDefault();
};
if (!issue) return <></>;
return (
<>
<Link
id={`inbox-issue-list-item-${issue.id}`}
key={`${inboxId}_${issueId}`}
href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${issueId}`}
key={`${projectId}_${issue.id}`}
href={`/${workspaceSlug}/projects/${projectId}/inbox?inboxIssueId=${issue.id}`}
onClick={(e) => handleIssueRedirection(e, issue.id)}
>
<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" : ""}`}
className={cn(
`flex flex-col gap-2 relative border border-t-transparent border-l-transparent border-r-transparent border-b-custom-border-200 p-4 hover:bg-custom-primary/5 cursor-pointer transition-all`,
{ "bg-custom-primary/5 border-custom-primary-100 border": inboxIssueId === issue.id }
)}
>
<div className="flex items-center justify-between gap-x-2">
<div className="relative flex items-center gap-x-2 overflow-hidden">
<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 className="space-y-1">
<div className="relative flex items-center justify-between gap-2">
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
{projectIdentifier}-{issue.sequence_id}
</div>
{inboxIssue.status !== -2 && <InboxIssueStatus inboxIssue={inboxIssue} iconSize={12} />}
</div>
<h3 className="truncate w-full text-sm">{issue.name}</h3>
</div>
<div className="flex flex-wrap items-center gap-2">
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`} isMobile={isMobile}>
<PriorityIcon priority={issue.priority ?? null} className="h-3.5 w-3.5" />
</Tooltip>
<Tooltip
tooltipHeading="Created on"
tooltipContent={`${renderFormattedDate(issue.created_at ?? "")}`}
isMobile={isMobile}
>
<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>
<div className="text-xs text-custom-text-200">{renderFormattedDate(issue.created_at ?? "")}</div>
</Tooltip>
<div className="border-2 rounded-full border-custom-border-400" />
{issue.priority && (
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`}>
<PriorityIcon priority={issue.priority} withContainer className="w-3 h-3" />
</Tooltip>
)}
{issue.label_ids && issue.label_ids.length > 3 ? (
<div className="relative !h-[17.5px] flex items-center gap-1 rounded border border-custom-border-300 px-1 text-xs">
<span className="h-2 w-2 rounded-full bg-orange-400" />
<span className="normal-case max-w-28 truncate">{`${issue.label_ids.length} labels`}</span>
</div>
) : (
<>
{(issue.label_ids ?? []).map((labelId) => {
const labelDetails = projectLabels?.find((l) => l.id === labelId);
if (!labelDetails) return null;
return (
<div
key={labelId}
className="relative !h-[17.5px] flex items-center gap-1 rounded border border-custom-border-300 px-1 text-xs"
>
<span
className="h-2 w-2 rounded-full"
style={{
backgroundColor: labelDetails.color,
}}
/>
<span className="normal-case max-w-28 truncate">{labelDetails.name}</span>
</div>
);
})}
</>
)}
</div>
</div>
</Link>

View file

@ -1,33 +1,33 @@
import { FC } from "react";
import { FC, Fragment } from "react";
import { observer } from "mobx-react";
// hooks
import { useInboxIssues } from "@/hooks/store";
// components
import { InboxIssueListItem } from "../";
import { InboxIssueListItem } from "@/components/inbox";
// store
import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
type TInboxIssueList = { workspaceSlug: string; projectId: string; inboxId: string };
export type InboxIssueListProps = {
workspaceSlug: string;
projectId: string;
projectIdentifier?: string;
inboxIssues: IInboxIssueStore[];
};
export const InboxIssueList: FC<TInboxIssueList> = observer((props) => {
const { workspaceSlug, projectId, inboxId } = props;
// hooks
const {
issues: { getInboxIssuesByInboxId },
} = useInboxIssues();
export const InboxIssueList: FC<InboxIssueListProps> = observer((props) => {
const { workspaceSlug, projectId, projectIdentifier, inboxIssues } = props;
const inboxIssueIds = getInboxIssuesByInboxId(inboxId);
if (!inboxIssueIds) return <></>;
return (
<div className="overflow-y-auto w-full h-full vertical-scrollbar scrollbar-md">
{inboxIssueIds.map((issueId) => (
<InboxIssueListItem
key={issueId}
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxId={inboxId}
issueId={issueId}
/>
<>
{inboxIssues.map((inboxIssue) => (
<Fragment key={inboxIssue.id}>
<InboxIssueListItem
key={inboxIssue.id}
workspaceSlug={workspaceSlug}
projectId={projectId}
projectIdentifier={projectIdentifier}
inboxIssue={inboxIssue}
/>
</Fragment>
))}
</div>
</>
);
});

View file

@ -0,0 +1,3 @@
export * from "./root";
export * from "./inbox-list";
export * from "./inbox-list-item";

View file

@ -1,49 +1,143 @@
import { FC } from "react";
import { FC, useCallback, useRef } from "react";
import { observer } from "mobx-react";
import { Inbox } from "lucide-react";
// hooks
import { InboxSidebarLoader } from "@/components/ui";
import { useInboxIssues } from "@/hooks/store";
// ui
import { useRouter } from "next/router";
import { TInboxIssueCurrentTab } from "@plane/types";
import { Loader } from "@plane/ui";
// components
import { InboxIssueList, InboxIssueFilterSelection, InboxIssueAppliedFilter } from "../";
import { EmptyState } from "@/components/empty-state";
import { FiltersRoot, InboxIssueAppliedFilters, InboxIssueList } from "@/components/inbox";
import { InboxSidebarLoader } from "@/components/ui";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useProject, useProjectInbox } from "@/hooks/store";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
type TInboxSidebarRoot = {
type IInboxSidebarProps = {
workspaceSlug: string;
projectId: string;
inboxId: string;
};
export const InboxSidebarRoot: FC<TInboxSidebarRoot> = observer((props) => {
const { workspaceSlug, projectId, inboxId } = props;
// store hooks
const {
issues: { loader },
} = useInboxIssues();
const tabNavigationOptions: { key: TInboxIssueCurrentTab; label: string }[] = [
{
key: "open",
label: "Open",
},
{
key: "closed",
label: "Closed",
},
];
if (loader === "init-loader") {
return <InboxSidebarLoader />;
}
export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
const { workspaceSlug, projectId } = props;
// ref
const containerRef = useRef<HTMLDivElement>(null);
const elementRef = useRef<HTMLDivElement>(null);
// store
const { currentProjectDetails } = useProject();
const {
currentTab,
handleCurrentTab,
isLoading,
inboxIssuesArray,
inboxIssuePaginationInfo,
fetchInboxPaginationIssues,
getAppliedFiltersCount,
} = useProjectInbox();
const router = useRouter();
const fetchNextPages = useCallback(() => {
if (!workspaceSlug || !projectId) return;
fetchInboxPaginationIssues(workspaceSlug.toString(), projectId.toString());
}, [workspaceSlug, projectId, fetchInboxPaginationIssues]);
// page observer
useIntersectionObserver({
containerRef,
elementRef,
callback: fetchNextPages,
rootMargin: "20%",
});
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-300">
<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 className="flex-shrink-0 w-2/6 h-full border-r border-custom-border-300">
<div className="relative w-full h-full flex flex-col overflow-hidden">
<div className="border-b border-custom-border-300 flex-shrink-0 w-full h-[50px] relative flex items-center gap-2 pr-3 whitespace-nowrap">
{tabNavigationOptions.map((option) => (
<div
key={option?.key}
className={cn(
`text-sm relative flex items-center gap-1 h-[50px] px-2 cursor-pointer transition-all font-medium`,
currentTab === option?.key ? `text-custom-primary-100` : `hover:text-custom-text-200`
)}
onClick={() => {
if (currentTab != option?.key) handleCurrentTab(option?.key);
router.push(`/${workspaceSlug}/projects/${projectId}/inbox`);
}}
>
<div>{option?.label}</div>
{option?.key === "open" && currentTab === option?.key && (
<div className="rounded-full p-1.5 py-0.5 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold">
{inboxIssuePaginationInfo?.total_results || 0}
</div>
)}
<div
className={cn(
`border absolute bottom-0 right-0 left-0 rounded-t-md`,
currentTab === option?.key ? `border-custom-primary-100` : `border-transparent`
)}
/>
</div>
))}
<div className="ml-auto">
<FiltersRoot />
</div>
</div>
<div className="z-20">
<InboxIssueFilterSelection workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} />
</div>
</div>
<div className="w-full h-auto">
<InboxIssueAppliedFilter workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} />
</div>
<InboxIssueAppliedFilters />
<div className="w-full h-full overflow-hidden">
<InboxIssueList workspaceSlug={workspaceSlug} projectId={projectId} inboxId={inboxId} />
{isLoading && !inboxIssuePaginationInfo?.next_page_results ? (
<InboxSidebarLoader />
) : (
<div
className="w-full h-full overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-md"
ref={containerRef}
>
{inboxIssuesArray.length > 0 ? (
<InboxIssueList
workspaceSlug={workspaceSlug}
projectId={projectId}
projectIdentifier={currentProjectDetails?.identifier}
inboxIssues={inboxIssuesArray}
/>
) : (
<div className="flex items-center justify-center h-full w-full">
<EmptyState
type={
getAppliedFiltersCount > 0
? EmptyStateType.INBOX_SIDEBAR_FILTER_EMPTY_STATE
: currentTab === "open"
? EmptyStateType.INBOX_SIDEBAR_OPEN_TAB
: EmptyStateType.INBOX_SIDEBAR_CLOSED_TAB
}
layout="screen-simple"
/>
</div>
)}
<div ref={elementRef}>
{inboxIssuePaginationInfo?.next_page_results && (
<Loader className="mx-auto w-full space-y-4 py-4 px-2">
<Loader.Item height="64px" width="w-100" />
<Loader.Item height="64px" width="w-100" />
</Loader>
)}
</div>
</div>
)}
</div>
</div>
);

View file

@ -3,7 +3,8 @@ export * from "./issue-modal";
export * from "./delete-issue-modal";
export * from "./description-form";
export * from "./issue-layouts";
export * from "./description-input";
export * from "./title-input";
export * from "./parent-issues-list-modal";
export * from "./label";
export * from "./confirm-issue-discard";

View file

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

View file

@ -1,119 +0,0 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
// hooks
import { StateGroupIcon } from "@plane/ui";
import { IssueUpdateStatus, TIssueOperations } from "@/components/issues";
import { useIssueDetail, useProjectState, useUser } from "@/hooks/store";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// components
import { InboxIssueStatus } from "../../../inbox/inbox-issue-status";
import { IssueDescriptionInput } from "../../description-input";
import { IssueTitleInput } from "../../title-input";
import { IssueActivity } from "../issue-activity";
import { IssueReaction } from "../reactions";
// 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 { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting");
useEffect(() => {
if (isSubmitting === "submitted") {
setShowAlert(false);
setTimeout(async () => {
setIsSubmitting("saved");
}, 3000);
} else if (isSubmitting === "submitting") {
setShowAlert(true);
}
}, [isSubmitting, setShowAlert, setIsSubmitting]);
const issue = issueId ? getIssueById(issueId) : undefined;
if (!issue) return <></>;
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);
const issueDescription =
issue.description_html !== undefined || issue.description_html !== null
? issue.description_html != ""
? issue.description_html
: "<p></p>"
: undefined;
return (
<>
<div className="rounded-lg space-y-4">
<InboxIssueStatus
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxId={inboxId}
issueId={issueId}
showDescription
/>
<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>
<IssueTitleInput
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
isSubmitting={isSubmitting}
setIsSubmitting={(value) => setIsSubmitting(value)}
issueOperations={issueOperations}
disabled={!is_editable}
value={issue.name}
/>
<IssueDescriptionInput
workspaceSlug={workspaceSlug}
projectId={issue.project_id}
issueId={issue.id}
value={issueDescription}
initialValue={issueDescription}
disabled={!is_editable}
issueOperations={issueOperations}
setIsSubmitting={(value) => setIsSubmitting(value)}
/>
{currentUser && (
<IssueReaction
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
currentUser={currentUser}
/>
)}
</div>
<div className="pb-12">
<IssueActivity workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
</div>
</>
);
});

View file

@ -1,152 +0,0 @@
import { FC, useMemo } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { TIssue } from "@plane/types";
// components
import { TOAST_TYPE, setToast } from "@plane/ui";
import { EUserProjectRoles } from "@/constants/project";
import { useEventTracker, useInboxIssues, useIssueDetail, useUser } from "@/hooks/store";
// ui
// types
import { TIssueOperations } from "../root";
import { InboxIssueMainContent } from "./main-content";
import { InboxIssueDetailsSidebar } from "./sidebar";
// constants
export type TInboxIssueDetailRoot = {
workspaceSlug: string;
projectId: string;
inboxId: string;
issueId: string;
};
export const InboxIssueDetailRoot: FC<TInboxIssueDetailRoot> = (props) => {
const { workspaceSlug, projectId, inboxId, issueId } = props;
// router
const router = useRouter();
// hooks
const {
issues: { fetchInboxIssueById, updateInboxIssue, removeInboxIssue },
} = useInboxIssues();
const {
issue: { getIssueById },
fetchActivities,
fetchComments,
} = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
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>) => {
try {
await updateInboxIssue(workspaceSlug, projectId, inboxId, issueId, data);
captureIssueEvent({
eventName: "Inbox issue updated",
payload: { ...data, state: "SUCCESS", element: "Inbox" },
updates: {
changed_property: Object.keys(data).join(","),
change_details: Object.values(data).join(","),
},
path: router.asPath,
});
} catch (error) {
setToast({
title: "Issue update failed",
type: TOAST_TYPE.ERROR,
message: "Issue update failed",
});
captureIssueEvent({
eventName: "Inbox issue updated",
payload: { state: "SUCCESS", element: "Inbox" },
updates: {
changed_property: Object.keys(data).join(","),
change_details: Object.values(data).join(","),
},
path: router.asPath,
});
}
},
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await removeInboxIssue(workspaceSlug, projectId, inboxId, issueId);
setToast({
title: "Issue deleted successfully",
type: TOAST_TYPE.SUCCESS,
message: "Issue deleted successfully",
});
captureIssueEvent({
eventName: "Inbox issue deleted",
payload: { id: issueId, state: "SUCCESS", element: "Inbox" },
path: router.asPath,
});
} catch (error) {
captureIssueEvent({
eventName: "Inbox issue deleted",
payload: { id: issueId, state: "FAILED", element: "Inbox" },
path: router.asPath,
});
setToast({
title: "Issue delete failed",
type: TOAST_TYPE.ERROR,
message: "Issue delete failed",
});
}
},
}),
[inboxId, fetchInboxIssueById, updateInboxIssue, removeInboxIssue]
);
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);
await fetchActivities(workspaceSlug, projectId, issueId);
await fetchComments(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);
if (!issue) return <></>;
return (
<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 vertical-scrollbar scrollbar-md">
<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>
);
};

View file

@ -1,14 +1,14 @@
export * from "./root";
export * from "./main-content";
export * from "./sidebar";
// select
export * from "./cycle-select";
export * from "./module-select";
export * from "./parent-select";
export * from "./relation-select";
export * from "./parent";
export * from "./label";
export * from "./subscription";
export * from "./links";
export * from "./issue-activity";
export * from "./reactions";
// select components
export * from "./cycle-select";
export * from "./module-select";
export * from "./parent-select";
export * from "./relation-select";

View file

@ -7,7 +7,6 @@ import { Popover } from "@headlessui/react";
import { IIssueLabel } from "@plane/types";
// hooks
import { Input, TOAST_TYPE, setToast } from "@plane/ui";
import { useIssueDetail } from "@/hooks/store";
// ui
// types
import { TLabelOperations } from "./root";
@ -16,6 +15,7 @@ type ILabelCreate = {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
labelOperations: TLabelOperations;
disabled?: boolean;
};
@ -26,11 +26,7 @@ const defaultValues: Partial<IIssueLabel> = {
};
export const LabelCreate: FC<ILabelCreate> = (props) => {
const { workspaceSlug, projectId, issueId, labelOperations, disabled = false } = props;
// hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const { workspaceSlug, projectId, issueId, values, labelOperations, disabled = false } = props;
// state
const [isCreateToggle, setIsCreateToggle] = useState(false);
const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle);
@ -70,9 +66,8 @@ export const LabelCreate: FC<ILabelCreate> = (props) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
try {
const issue = getIssueById(issueId);
const labelResponse = await labelOperations.createLabel(workspaceSlug, projectId, formData);
const currentLabels = [...(issue?.label_ids || []), labelResponse.id];
const currentLabels = [...(values || []), labelResponse.id];
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
reset(defaultValues);
} catch (error) {

View file

@ -1,7 +1,7 @@
import { FC } from "react";
import { X } from "lucide-react";
// types
import { useIssueDetail, useLabel } from "@/hooks/store";
import { useLabel } from "@/hooks/store";
import { TLabelOperations } from "./root";
type TLabelListItem = {
@ -9,24 +9,21 @@ type TLabelListItem = {
projectId: string;
issueId: string;
labelId: string;
values: string[];
labelOperations: TLabelOperations;
disabled: boolean;
};
export const LabelListItem: FC<TLabelListItem> = (props) => {
const { workspaceSlug, projectId, issueId, labelId, labelOperations, disabled } = props;
const { workspaceSlug, projectId, issueId, labelId, values, labelOperations, disabled } = props;
// hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const { getLabelById } = useLabel();
const issue = getIssueById(issueId);
const label = getLabelById(labelId);
const handleLabel = async () => {
if (issue && !disabled) {
const currentLabels = issue.label_ids.filter((_labelId) => _labelId !== labelId);
if (values && !disabled) {
const currentLabels = values.filter((_labelId) => _labelId !== labelId);
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
}
};

View file

@ -1,9 +1,7 @@
import { FC } from "react";
import { observer } from "mobx-react";
// components
import { useIssueDetail } from "@/hooks/store";
import { LabelListItem } from "./label-list-item";
// hooks
// types
import { TLabelOperations } from "./root";
@ -11,21 +9,16 @@ type TLabelList = {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
labelOperations: TLabelOperations;
disabled: boolean;
};
export const LabelList: FC<TLabelList> = observer((props) => {
const { workspaceSlug, projectId, issueId, labelOperations, disabled } = props;
// hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const { workspaceSlug, projectId, issueId, values, labelOperations, disabled } = props;
const issueLabels = values || undefined;
const issue = getIssueById(issueId);
const issueLabels = issue?.label_ids || undefined;
if (!issue || !issueLabels) return <></>;
if (!issueId || !issueLabels) return <></>;
return (
<>
{issueLabels.map((labelId) => (
@ -35,6 +28,7 @@ export const LabelList: FC<TLabelList> = observer((props) => {
projectId={projectId}
issueId={issueId}
labelId={labelId}
values={issueLabels}
labelOperations={labelOperations}
disabled={disabled}
/>

View file

@ -4,7 +4,7 @@ import { IIssueLabel, TIssue } from "@plane/types";
// components
import { TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { useIssueDetail, useLabel } from "@/hooks/store";
import { useIssueDetail, useLabel, useProjectInbox } from "@/hooks/store";
// ui
// types
import { LabelList, LabelCreate, IssueLabelSelectRoot } from "./";
@ -28,6 +28,12 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
// hooks
const { updateIssue } = useIssueDetail();
const { createLabel } = useLabel();
const {
issue: { getIssueById },
} = useIssueDetail();
const { getIssueInboxByIssueId } = useProjectInbox();
const issue = isInboxIssue ? getIssueInboxByIssueId(issueId)?.issue : getIssueById(issueId);
const labelOperations: TLabelOperations = useMemo(
() => ({
@ -72,6 +78,7 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
values={issue?.label_ids || []}
labelOperations={labelOperations}
disabled={disabled}
/>
@ -81,6 +88,7 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
values={issue?.label_ids || []}
labelOperations={labelOperations}
/>
)}
@ -90,6 +98,7 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
values={issue?.label_ids || []}
labelOperations={labelOperations}
/>
)}

View file

@ -4,22 +4,20 @@ import { usePopper } from "react-popper";
import { Check, Search, Tag } from "lucide-react";
import { Combobox } from "@headlessui/react";
// hooks
import { useIssueDetail, useLabel } from "@/hooks/store";
import { useLabel } from "@/hooks/store";
// components
export interface IIssueLabelSelect {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
onSelect: (_labelIds: string[]) => void;
}
export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) => {
const { workspaceSlug, projectId, issueId, onSelect } = props;
const { workspaceSlug, projectId, issueId, values, onSelect } = props;
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const { fetchProjectLabels, getProjectLabels } = useLabel();
// states
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
@ -27,7 +25,6 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
const [isLoading, setIsLoading] = useState<boolean>(false);
const [query, setQuery] = useState("");
const issue = getIssueById(issueId);
const projectLabels = getProjectLabels(projectId);
const fetchLabels = () => {
@ -67,7 +64,7 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
],
});
const issueLabels = issue?.label_ids ?? [];
const issueLabels = values ?? [];
const label = (
<div
@ -87,7 +84,7 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
}
};
if (!issue) return <></>;
if (!issueId || !values) return <></>;
return (
<>

View file

@ -8,17 +8,24 @@ type TIssueLabelSelectRoot = {
workspaceSlug: string;
projectId: string;
issueId: string;
values: string[];
labelOperations: TLabelOperations;
};
export const IssueLabelSelectRoot: FC<TIssueLabelSelectRoot> = (props) => {
const { workspaceSlug, projectId, issueId, labelOperations } = props;
const { workspaceSlug, projectId, issueId, values, labelOperations } = props;
const handleLabel = async (_labelIds: string[]) => {
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: _labelIds });
};
return (
<IssueLabelSelect workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} onSelect={handleLabel} />
<IssueLabelSelect
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
values={values}
onSelect={handleLabel}
/>
);
};

View file

@ -32,9 +32,8 @@ import {
import { LeaveProjectModal, ProjectLogo, PublishProjectModal } from "@/components/project";
import { EUserProjectRoles } from "@/constants/project";
import { cn } from "@/helpers/common.helper";
import { getNumberCount } from "@/helpers/string.helper";
// hooks
import { useApplication, useEventTracker, useInbox, useProject } from "@/hooks/store";
import { useApplication, useEventTracker, useProject } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os";
// helpers
@ -95,7 +94,6 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
const { theme: themeStore } = useApplication();
const { setTrackElement } = useEventTracker();
const { addProjectToFavorites, removeProjectFromFavorites, getProjectById } = useProject();
const { getInboxesByProjectId, getInboxById } = useInbox();
const { isMobile } = usePlatformOS();
// states
const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false);
@ -109,8 +107,6 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
// derived values
const project = getProjectById(projectId);
const isCollapsed = themeStore.sidebarCollapsed;
const inboxesMap = project?.inbox_view ? getInboxesByProjectId(projectId) : undefined;
const inboxDetails = inboxesMap && inboxesMap.length > 0 ? getInboxById(inboxesMap[0]) : undefined;
// auth
const isAdmin = project?.member_role === EUserProjectRoles.ADMIN;
const isViewerOrGuest =
@ -375,36 +371,8 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
: "text-custom-sidebar-text-300 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
} ${isCollapsed ? "justify-center" : ""}`}
>
{item.name === "Inbox" && inboxDetails ? (
<>
<div className="flex items-center justify-center relative">
{inboxDetails?.pending_issue_count > 0 && (
<span
className={cn(
"absolute -right-1.5 -top-1 px-0.5 h-3.5 w-3.5 flex items-center tracking-tight justify-center rounded-full text-[0.5rem] border-[0.5px] border-custom-sidebar-border-200 bg-custom-background-80 text-custom-text-100",
{
"text-[0.375rem] leading-5": inboxDetails?.pending_issue_count >= 100,
},
{
"border-none bg-custom-primary-300 text-white": router.asPath.includes(
item.href
),
}
)}
>
{getNumberCount(inboxDetails?.pending_issue_count)}
</span>
)}
<item.Icon className="h-4 w-4 stroke-[1.5]" />
</div>
{!isCollapsed && item.name}
</>
) : (
<>
<item.Icon className="h-4 w-4 stroke-[1.5]" />
{!isCollapsed && item.name}
</>
)}
<item.Icon className="h-4 w-4 stroke-[1.5]" />
{!isCollapsed && item.name}
</div>
</Tooltip>
</span>

View file

@ -4,22 +4,19 @@ import { Loader } from "@plane/ui";
import { InboxSidebarLoader } from "./inbox-sidebar-loader";
export const InboxLayoutLoader = () => (
<div className="relative flex h-full overflow-hidden">
<InboxSidebarLoader />
<div className="w-full">
<Loader className="flex h-full gap-5 p-5">
<div className="basis-2/3 space-y-2">
<div className="relative w-full h-full flex overflow-hidden">
<div className="flex-shrink-0 w-2/6 h-full border-r border-custom-border-300">
<InboxSidebarLoader />
</div>
<div className="w-4/6">
<Loader className="flex flex-col h-full gap-5 p-5">
<div className="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.Item height="150px" />
</Loader>
</div>
</div>

View file

@ -1,24 +1,20 @@
import React from "react";
export const InboxSidebarLoader = () => (
<div className="h-full w-[340px] border-r border-custom-border-300">
<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-300">
<span className="h-6 w-16 bg-custom-background-80 rounded" />
<span className="h-6 w-16 bg-custom-background-80 rounded" />
</div>
<div className="flex flex-col">
{[...Array(6)].map((i) => (
<div key={i} className="flex flex-col gap-3 h-[5rem]space-y-3 border-b border-custom-border-200 px-4 py-2">
<div className="flex items-center justify-between gap-3">
<span className="h-5 w-20 bg-custom-background-80 rounded" />
<span className="h-5 w-16 bg-custom-background-80 rounded" />
</div>
<div className="flex items-center gap-3">
<span className="h-5 w-5 bg-custom-background-80 rounded" />
<span className="h-5 w-16 bg-custom-background-80 rounded" />
</div>
<div className="flex flex-col">
{[...Array(6)].map((i, index) => (
<div key={index} className="flex flex-col gap-2.5 h-[105px] space-y-3 border-b border-custom-border-200 p-4">
<div className="flex flex-col gap-2">
<span className="h-5 w-16 bg-custom-background-80 rounded" />
<span className="h-5 w-36 bg-custom-background-80 rounded" />
</div>
))}
</div>
<div className="flex items-center gap-2">
<span className="h-4 w-20 bg-custom-background-80 rounded" />
<span className="h-2 w-2 bg-custom-background-80 rounded-full" />
<span className="h-4 w-16 bg-custom-background-80 rounded" />
<span className="h-4 w-16 bg-custom-background-80 rounded" />
</div>
</div>
))}
</div>
);

View file

@ -90,6 +90,13 @@ export enum EmptyStateType {
ACTIVE_CYCLE_PRIORITY_ISSUE_EMPTY_STATE = "active-cycle-priority-issue-empty-state",
ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE = "active-cycle-assignee-empty-state",
ACTIVE_CYCLE_LABEL_EMPTY_STATE = "active-cycle-label-empty-state",
DISABLED_PROJECT_INBOX = "disabled-project-inbox",
INBOX_SIDEBAR_OPEN_TAB = "inbox-sidebar-open-tab",
INBOX_SIDEBAR_CLOSED_TAB = "inbox-sidebar-closed-tab",
INBOX_SIDEBAR_FILTER_EMPTY_STATE = "inbox-sidebar-filter-empty-state",
INBOX_DETAIL_EMPTY_STATE = "inbox-detail-empty-state",
}
const emptyStateDetails = {
@ -615,6 +622,41 @@ const emptyStateDetails = {
title: "Add labels to issues to see the \n breakdown of work by labels.",
path: "/empty-state/active-cycle/label",
},
[EmptyStateType.DISABLED_PROJECT_INBOX]: {
key: EmptyStateType.DISABLED_PROJECT_INBOX,
title: "Inbox is not enabled for the project.",
description:
"Inbox helps you manage incoming requests to your project and add them as issues in your workflow. Enable inbox \n from project settings to manage requests.",
accessType: "project",
access: EUserProjectRoles.ADMIN,
path: "/empty-state/disabled-feature/inbox",
primaryButton: {
text: "Manage features",
},
},
[EmptyStateType.INBOX_SIDEBAR_OPEN_TAB]: {
key: EmptyStateType.INBOX_SIDEBAR_OPEN_TAB,
title: "No open issues",
description: "Find open issues here. Create new issue.",
path: "/empty-state/inbox/inbox-issue",
},
[EmptyStateType.INBOX_SIDEBAR_CLOSED_TAB]: {
key: EmptyStateType.INBOX_SIDEBAR_CLOSED_TAB,
title: "No closed issues",
description: "All the issues whether accepted or \n declined can be found here.",
path: "/empty-state/inbox/inbox-issue",
},
[EmptyStateType.INBOX_SIDEBAR_FILTER_EMPTY_STATE]: {
key: EmptyStateType.INBOX_SIDEBAR_FILTER_EMPTY_STATE,
title: "No matching issues",
description: "No issue matches filter applied in inbox. \n Create a new issue.",
path: "/empty-state/inbox/filter-issue",
},
[EmptyStateType.INBOX_DETAIL_EMPTY_STATE]: {
key: EmptyStateType.INBOX_DETAIL_EMPTY_STATE,
title: "Select an issue to view its details.",
path: "/empty-state/inbox/issue-detail",
},
} as const;
export const EMPTY_STATE_DETAILS: Record<EmptyStateType, EmptyStateDetails> = emptyStateDetails;

View file

@ -1,91 +1,90 @@
// icons
import { AlertTriangle, CheckCircle2, Clock, Copy, ExternalLink, LucideIcon, XCircle } from "lucide-react";
import { AlertTriangle, CheckCircle2, Clock, Copy, LucideIcon, XCircle } from "lucide-react";
// types
import { TInboxIssueSortingOrderByKeys, TInboxIssueSortingSortByKeys, TInboxIssueStatus } from "@plane/types";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { findHowManyDaysLeft } from "@/helpers/date-time.helper";
export const INBOX_STATUS: {
key: string;
status: number;
status: TInboxIssueStatus;
icon: LucideIcon;
title: string;
description: (
workspaceSlug: string,
projectId: string,
issueId: string,
snoozedTillDate: Date | undefined
) => JSX.Element;
description: (snoozedTillDate: Date) => string;
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"),
description: () => `Pending`,
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-[#AB6400]"),
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-[#FFF7C2]"),
},
{
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"),
description: () => `Declined`,
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-[#CE2C31]"),
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-[#FEEBEC]"),
},
{
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"),
description: (snoozedTillDate: Date = new Date()) => `${findHowManyDaysLeft(snoozedTillDate)} days to go`,
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "text-red-500" : "text-custom-text-400"),
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "bg-red-500/10" : "bg-[#E0E1E6]"),
},
{
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"),
description: () => `Accepted`,
textColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "text-[#3E9B4F]"),
bgColor: (snoozeDatePassed: boolean = false) => (snoozeDatePassed ? "" : "bg-[#E9F6E9]"),
},
{
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>
),
description: () => `Duplicate`,
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";
export const INBOX_ISSUE_ORDER_BY_OPTIONS: { key: TInboxIssueSortingOrderByKeys; label: string }[] = [
{
key: "issue__created_at",
label: "Date created",
},
{
key: "issue__updated_at",
label: "Date updated",
},
{
key: "issue__sequence_id",
label: "ID",
},
];
export const INBOX_ISSUE_SORT_BY_OPTIONS: { key: TInboxIssueSortingSortByKeys; label: string }[] = [
{
key: "asc",
label: "Ascending",
},
{
key: "desc",
label: "Descending",
},
];

View file

@ -23,5 +23,6 @@ export * from "./use-workspace";
export * from "./use-issues";
export * from "./use-kanban-view";
export * from "./use-issue-detail";
export * from "./use-inbox";
// project inbox
export * from "./use-project-inbox";
export * from "./use-inbox-issues";

View file

@ -1,15 +1,9 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/contexts/store-context";
// types
import { IInboxFilter } from "@/store/inbox/inbox_filter.store";
import { IInboxIssue } from "@/store/inbox/inbox_issue.store";
import { StoreContext } from "contexts/store-context";
export const useInboxIssues = (): {
issues: IInboxIssue;
filters: IInboxFilter;
} => {
export const useInboxIssues = (inboxIssueId: string) => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useInboxIssues must be used within StoreProvider");
return { issues: context.inbox.inboxIssue, filters: context.inbox.inboxFilter };
return context.projectInbox.getIssueInboxByIssueId(inboxIssueId) || {};
};

View file

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

View file

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

View file

@ -0,0 +1,42 @@
import { RefObject, useState, useEffect } from "react";
export type UseIntersectionObserverProps = {
containerRef: RefObject<HTMLDivElement>;
elementRef: RefObject<HTMLDivElement>;
callback: () => void;
rootMargin?: string;
};
export const useIntersectionObserver = (props: UseIntersectionObserverProps) => {
const { containerRef, elementRef, callback, rootMargin = "0px" } = props;
const [isVisible, setVisibility] = useState(false);
useEffect(() => {
if (elementRef.current) {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
callback();
}
setVisibility(entry.isIntersecting);
},
{
root: containerRef.current,
rootMargin,
}
);
observer.observe(elementRef.current);
return () => {
if (elementRef.current) {
// eslint-disable-next-line react-hooks/exhaustive-deps
observer.unobserve(elementRef.current);
}
};
}
// while removing the current from the refs, the observer is not not working as expected
// fix this eslint warning with caution
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rootMargin, callback, elementRef.current, containerRef.current]);
return isVisible;
};

View file

@ -19,7 +19,7 @@ import {
useProjectState,
useProjectView,
useUser,
useInbox,
// useInbox,
} from "@/hooks/store";
// images
import emptyProject from "public/empty-state/project.svg";
@ -31,7 +31,7 @@ interface IProjectAuthWrapper {
export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
const { children } = props;
// store
const { fetchInboxes } = useInbox();
// const { fetchInboxes } = useInbox();
const {
commandPalette: { toggleCreateProjectModal },
} = useApplication();
@ -39,7 +39,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
const {
membership: { fetchUserProjectInfo, projectMemberInfo, hasPermissionToProject },
} = useUser();
const { getProjectById, fetchProjectDetails, currentProjectDetails } = useProject();
const { getProjectById, fetchProjectDetails } = useProject();
const { fetchAllCycles } = useCycle();
const { fetchModules } = useModule();
const { fetchViews } = useProjectView();
@ -105,20 +105,6 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
workspaceSlug && projectId ? () => fetchViews(workspaceSlug.toString(), projectId.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetching project inboxes if inbox is enabled in project settings
useSWR(
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,
revalidateOnReconnect: false,
}
);
const projectExists = projectId ? getProjectById(projectId.toString()) : null;
// check if the project member apis is loading

1
web/next-env.d.ts vendored
View file

@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View file

@ -1,80 +0,0 @@
import { ReactElement } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import useSWR from "swr";
// hooks
import { PageHead } from "@/components/core";
import { ProjectInboxHeader } from "@/components/headers";
import { InboxSidebarRoot, InboxContentRoot } from "@/components/inbox";
import { InboxLayoutLoader } from "@/components/ui";
import { useProject, useInboxIssues } from "@/hooks/store";
// layouts
import { AppLayout } from "@/layouts/app-layout";
// components
// types
import { NextPageWithLayout } from "@/lib/types";
const ProjectInboxPage: NextPageWithLayout = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query;
// store hooks
const { currentProjectDetails } = useProject();
const {
filters: { fetchInboxFilters },
issues: { fetchInboxIssues },
} = useInboxIssues();
// fetching the Inbox filters and issues
const { isLoading } = useSWR(
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());
}
}
);
// derived values
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Inbox` : undefined;
if (!workspaceSlug || !projectId || !inboxId || !currentProjectDetails?.inbox_view || isLoading)
return (
<div className="flex h-full flex-col">
<InboxLayoutLoader />
</div>
);
return (
<>
<PageHead title={pageTitle} />
<div className="relative flex h-full overflow-hidden">
<div className="flex-shrink-0 w-[340px] h-full border-r border-custom-border-300">
<InboxSidebarRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
inboxId={inboxId.toString()}
/>
</div>
<div className="w-full">
<InboxContentRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
inboxId={inboxId.toString()}
inboxIssueId={inboxIssueId?.toString() || undefined}
/>
</div>
</div>
</>
);
});
ProjectInboxPage.getLayout = function getLayout(page: ReactElement) {
return (
<AppLayout header={<ProjectInboxHeader />} withProjectWrapper>
{page}
</AppLayout>
);
};
export default ProjectInboxPage;

View file

@ -1,41 +1,54 @@
import { ReactElement } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import useSWR from "swr";
// hooks
// components
import { PageHead } from "@/components/core";
import { EmptyState } from "@/components/empty-state";
import { ProjectInboxHeader } from "@/components/headers";
import { InboxLayoutLoader } from "@/components/ui";
import { useInbox, useProject } from "@/hooks/store";
import { InboxIssueRoot } from "@/components/inbox";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// hooks
import { useProject } from "@/hooks/store";
// layouts
import { AppLayout } from "@/layouts/app-layout";
// ui
// components
// types
import { NextPageWithLayout } from "@/lib/types";
const ProjectInboxPage: NextPageWithLayout = observer(() => {
/// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug, projectId, inboxIssueId } = router.query;
// hooks
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}`);
}
}
);
if (!workspaceSlug || !projectId) return <></>;
// No access to inbox
if (currentProjectDetails?.inbox_view === false)
return (
<div className="flex items-center justify-center h-full w-full">
<EmptyState
type={EmptyStateType.DISABLED_PROJECT_INBOX}
primaryButtonLink={`/${workspaceSlug}/projects/${projectId}/settings/features`}
/>
</div>
);
// derived values
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Inbox` : "Plane - Inbox";
return (
<div className="flex h-full flex-col">
{currentProjectDetails?.inbox_view ? <InboxLayoutLoader /> : <div>You don{"'"}t have access to inbox</div>}
<PageHead title={pageTitle} />
<div className="w-full h-full overflow-hidden">
<InboxIssueRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
inboxIssueId={inboxIssueId?.toString() || undefined}
inboxAccessible={currentProjectDetails?.inbox_view || false}
/>
</div>
</div>
);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -1,122 +0,0 @@
import { API_BASE_URL } from "@/helpers/common.helper";
import { APIService } from "@/services/api.service";
// helpers
// types
import type { IInboxIssue, IInbox, TInboxStatus, IInboxQueryParams } from "@plane/types";
export class InboxService extends APIService {
constructor() {
super(API_BASE_URL);
}
async getInboxes(workspaceSlug: string, projectId: string): Promise<IInbox[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getInboxById(workspaceSlug: string, projectId: string, inboxId: string): Promise<IInbox> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async patchInbox(workspaceSlug: string, projectId: string, inboxId: string, data: Partial<IInbox>): Promise<any> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getInboxIssues(
workspaceSlug: string,
projectId: string,
inboxId: string,
params?: IInboxQueryParams
): Promise<IInboxIssue[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getInboxIssueById(
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string
): Promise<IInboxIssue> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteInboxIssue(
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string
): Promise<any> {
return this.delete(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async markInboxStatus(
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string,
data: TInboxStatus
): Promise<IInboxIssue> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async patchInboxIssue(
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string,
data: { issue: Partial<IInboxIssue> }
): Promise<any> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async createInboxIssue(workspaceSlug: string, projectId: string, inboxId: string, data: any): Promise<IInboxIssue> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View file

@ -1,25 +1,27 @@
// types
import type { TInboxIssue, TIssue, TInboxIssueWithPagination } from "@plane/types";
import { API_BASE_URL } from "@/helpers/common.helper";
import { APIService } from "@/services/api.service";
// helpers
// 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[]> {
async list(workspaceSlug: string, projectId: string, params = {}): Promise<TInboxIssueWithPagination> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async retrieve(workspaceSlug: string, projectId: string, inboxIssueId: string): Promise<TInboxIssue> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/?expand=issue_inbox`,
{
params,
}
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/?expand=issue_inbox`
)
.then((response) => response?.data)
.catch((error) => {
@ -27,83 +29,47 @@ export class InboxIssueService extends APIService {
});
}
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`
)
async create(workspaceSlug: string, projectId: string, data: Partial<TIssue>): Promise<TInboxIssue> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/`, {
source: "IN_APP",
issue: data,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async createInboxIssue(
async update(
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
)
data: Partial<TInboxIssue>
): Promise<TInboxIssue> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async removeInboxIssue(
async updateIssue(
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
)
data: Partial<TIssue>
): Promise<TInboxIssue> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/`, {
issue: data,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async destroy(workspaceSlug: string, projectId: string, inboxIssueId: string): Promise<void> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inbox-issues/${inboxIssueId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;

View file

@ -1,35 +0,0 @@
import { API_BASE_URL } from "@/helpers/common.helper";
import { APIService } from "@/services/api.service";
// helpers
// 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

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

View file

@ -0,0 +1,139 @@
import set from "lodash/set";
import { makeObservable, observable, runInAction, action } from "mobx";
// services
// types
import { TIssue, TInboxIssue, TInboxIssueStatus } from "@plane/types";
import { InboxIssueService } from "@/services/inbox";
export interface IInboxIssueStore {
isLoading: boolean;
id: string;
status: TInboxIssueStatus;
issue: Partial<TIssue>;
snoozed_till: Date | undefined;
duplicate_to: string | undefined;
created_by: string | undefined;
// actions
updateInboxIssueStatus: (status: TInboxIssueStatus) => Promise<void>; // accept, decline
updateInboxIssueDuplicateTo: (issueId: string) => Promise<void>; // connecting the inbox issue to the project existing issue
updateInboxIssueSnoozeTill: (date: Date) => Promise<void>; // snooze the issue
updateIssue: (issue: Partial<TIssue>) => Promise<void>; // updating the issue
}
export class InboxIssueStore implements IInboxIssueStore {
// observables
isLoading: boolean = false;
id: string;
status: TInboxIssueStatus = -2;
issue: Partial<TIssue> = {};
snoozed_till: Date | undefined;
duplicate_to: string | undefined;
created_by: string | undefined;
workspaceSlug: string;
projectId: string;
// services
inboxIssueService;
constructor(workspaceSlug: string, projectId: string, data: TInboxIssue) {
this.id = data.id;
this.status = data.status;
this.issue = data?.issue;
this.snoozed_till = data?.snoozed_till ? new Date(data.snoozed_till) : undefined;
this.duplicate_to = data?.duplicate_to || undefined;
this.created_by = data?.created_by || undefined;
this.workspaceSlug = workspaceSlug;
this.projectId = projectId;
// services
this.inboxIssueService = new InboxIssueService();
// observable variables should be defined after the initialization of the values
makeObservable(this, {
id: observable,
status: observable,
issue: observable,
snoozed_till: observable,
duplicate_to: observable,
created_by: observable,
// actions
updateInboxIssueStatus: action,
updateInboxIssueDuplicateTo: action,
updateInboxIssueSnoozeTill: action,
updateIssue: action,
});
}
updateInboxIssueStatus = async (status: TInboxIssueStatus) => {
const previousData: Partial<TInboxIssue> = {
status: this.status,
};
try {
if (!this.issue.id) return;
set(this, "status", status);
await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, {
status: status,
});
} catch {
runInAction(() => set(this, "status", previousData.status));
}
};
updateInboxIssueDuplicateTo = async (issueId: string) => {
const inboxStatus = 2;
const previousData: Partial<TInboxIssue> = {
status: this.status,
duplicate_to: this.duplicate_to,
};
try {
if (!this.issue.id) return;
set(this, "status", inboxStatus);
set(this, "duplicate_to", issueId);
await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, {
status: inboxStatus,
duplicate_to: issueId,
});
} catch {
runInAction(() => {
set(this, "status", previousData.status);
set(this, "duplicate_to", previousData.duplicate_to);
});
}
};
updateInboxIssueSnoozeTill = async (date: Date) => {
const inboxStatus = 0;
const previousData: Partial<TInboxIssue> = {
status: this.status,
snoozed_till: this.snoozed_till,
};
try {
if (!this.issue.id) return;
set(this, "status", inboxStatus);
set(this, "snoozed_till", date);
await this.inboxIssueService.update(this.workspaceSlug, this.projectId, this.issue.id, {
status: inboxStatus,
snoozed_till: new Date(date),
});
} catch {
runInAction(() => {
set(this, "status", previousData.status);
set(this, "snoozed_till", previousData.snoozed_till);
});
}
};
updateIssue = async (issue: Partial<TIssue>) => {
const inboxIssue = this.issue;
try {
if (!this.issue.id) return;
Object.keys(issue).forEach((key) => {
const issueKey = key as keyof TIssue;
set(inboxIssue, issueKey, issue[issueKey]);
});
await this.inboxIssueService.updateIssue(this.workspaceSlug, this.projectId, this.issue.id, issue);
} catch {
Object.keys(issue).forEach((key) => {
const issueKey = key as keyof TIssue;
set(inboxIssue, issueKey, inboxIssue[issueKey]);
});
}
};
}

View file

@ -1,114 +0,0 @@
import concat from "lodash/concat";
import set from "lodash/set";
import uniq from "lodash/uniq";
import update from "lodash/update";
import { observable, action, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// services
import { InboxService } from "@/services/inbox/inbox.service";
// types
import { RootStore } from "@/store/root.store";
import { TInboxDetailMap, TInboxDetailIdMap, TInbox } from "@plane/types";
export interface IInbox {
// observables
inboxes: TInboxDetailIdMap;
inboxMap: TInboxDetailMap;
// helper methods
getInboxesByProjectId: (projectId: string) => string[] | undefined;
getInboxById: (inboxId: string) => TInbox | undefined;
// fetch actions
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 Inbox implements IInbox {
// observables
inboxes: TInboxDetailIdMap = {};
inboxMap: TInboxDetailMap = {};
// root store
rootStore;
// services
inboxService;
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
inboxMap: observable,
inboxes: observable,
// actions
fetchInboxes: action,
fetchInboxById: action,
updateInbox: action,
});
// root store
this.rootStore = _rootStore;
// services
this.inboxService = new InboxService();
}
// helper methods
getInboxesByProjectId = computedFn((projectId: string) => {
if (!projectId) return undefined;
return this.inboxes?.[projectId] ?? undefined;
});
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(() => {
response.forEach((inbox) => {
set(this.inboxMap, inbox.id, inbox);
});
set(this.inboxes, projectId, _inboxIds);
});
return response;
} catch (error) {
throw error;
}
};
fetchInboxById = async (workspaceSlug: string, projectId: string, inboxId: string) => {
try {
const response = await this.inboxService.fetchInboxById(workspaceSlug, projectId, inboxId);
runInAction(() => {
set(this.inboxMap, inboxId, response);
update(this.inboxes, projectId, (inboxIds: string[] = []) => {
if (inboxIds.includes(inboxId)) return inboxIds;
return uniq(concat(inboxIds, inboxId));
});
});
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,129 +0,0 @@
import isEmpty from "lodash/isEmpty";
import set from "lodash/set";
import { observable, action, makeObservable, runInAction, computed } from "mobx";
// services
import { InboxService } from "@/services/inbox.service";
// types
import { RootStore } from "@/store/root.store";
import { TInboxIssueFilterOptions, TInboxIssueFilters, TInboxIssueQueryParams, TInbox } from "@plane/types";
export interface IInboxFilter {
// observables
filters: Record<string, TInboxIssueFilters>; // inbox_id -> TInboxIssueFilters
// computed
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<TInboxIssueFilterOptions>
) => Promise<TInbox>;
}
export class InboxFilter implements IInboxFilter {
// observables
filters: Record<string, TInboxIssueFilters> = {};
// root store
rootStore;
// services
inboxService;
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
filters: observable,
// computed
inboxFilters: computed,
inboxAppliedFilters: computed,
// actions
fetchInboxFilters: action,
updateInboxFilters: action,
});
// root store
this.rootStore = _rootStore;
// services
this.inboxService = new InboxService();
}
get inboxFilters() {
const inboxId = this.rootStore.app.router.inboxId;
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 _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;
}
fetchInboxFilters = async (workspaceSlug: string, projectId: string, inboxId: string) => {
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.filters, [inboxId], { filters: filters });
});
return response;
} catch (error) {
throw error;
}
};
updateInboxFilters = async (
workspaceSlug: string,
projectId: string,
inboxId: string,
filters: Partial<TInboxIssueFilterOptions>
) => {
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 };
this.rootStore.inbox.inboxIssue.fetchInboxIssues(workspaceSlug, projectId, inboxId, "mutation");
const response = await this.rootStore.inbox.inbox.updateInbox(workspaceSlug, projectId, inboxId, {
view_props: { filters: _filters },
});
return response;
} catch (error) {
throw error;
}
};
}

View file

@ -1,267 +0,0 @@
import concat from "lodash/concat";
import pull from "lodash/pull";
import set from "lodash/set";
import uniq from "lodash/uniq";
import update from "lodash/update";
import { observable, action, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// services
import { InboxIssueService } from "@/services/inbox/inbox-issue.service";
// types
import { RootStore } from "@/store/root.store";
import type {
TInboxIssueDetailIdMap,
TInboxIssueDetailMap,
TInboxIssueDetail,
TInboxIssueExtendedDetail,
TInboxDetailedStatus,
TIssue,
} from "@plane/types";
type TLoader = "init-loader" | "mutation" | undefined;
export interface IInboxIssue {
// observables
loader: TLoader;
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,
loaderType?: TLoader
) => Promise<TInboxIssueExtendedDetail[]>;
fetchInboxIssueById: (
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string
) => Promise<TInboxIssueExtendedDetail[]>;
createInboxIssue: (
workspaceSlug: string,
projectId: string,
inboxId: string,
data: Partial<TInboxIssueExtendedDetail>
) => Promise<TInboxIssueExtendedDetail>;
updateInboxIssue: (
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string,
data: Partial<TInboxIssueExtendedDetail>
) => Promise<void>;
removeInboxIssue: (workspaceSlug: string, projectId: string, inboxId: string, issueId: string) => Promise<void>;
updateInboxIssueStatus: (
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string,
data: TInboxDetailedStatus
) => Promise<void>;
}
export class InboxIssue implements IInboxIssue {
// observables
loader: TLoader = "init-loader";
inboxIssues: TInboxIssueDetailIdMap = {};
inboxIssueMap: TInboxIssueDetailMap = {};
// root store
rootStore;
// services
inboxIssueService;
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
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.inboxIssueService = new InboxIssueService();
}
// helper methods
getInboxIssuesByInboxId = computedFn((inboxId: string) => {
if (!inboxId) return undefined;
return this.inboxIssues?.[inboxId] ?? undefined;
});
getInboxIssueByIssueId = computedFn((inboxId: string, issueId: string) => {
if (!inboxId) return undefined;
return this.inboxIssueMap?.[inboxId]?.[issueId] ?? undefined;
});
// actions
fetchInboxIssues = async (
workspaceSlug: string,
projectId: string,
inboxId: string,
loaderType: TLoader = "init-loader"
) => {
try {
this.loader = loaderType;
const queryParams = this.rootStore.inbox.inboxFilter.inboxAppliedFilters ?? {};
const response = await this.inboxIssueService.fetchInboxIssues(workspaceSlug, projectId, inboxId, queryParams);
runInAction(() => {
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;
}
};
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.updateIssue(issue.id, 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);
update(this.rootStore.inbox.inbox.inboxMap, [inboxId, "pending_issue_count"], (count: number = 0) => count + 1);
});
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,
inboxIssueId: string,
data: Partial<TIssue>
) => {
try {
const response = await this.inboxIssueService.updateInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId, {
issue: data,
});
this.rootStore.inbox.rootStore.issue.issues.updateIssue(inboxIssueId, data);
await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, inboxIssueId);
} catch (error) {
throw error;
}
};
removeInboxIssue = async (workspaceSlug: string, projectId: string, inboxId: string, inboxIssueId: string) => {
try {
await this.inboxIssueService.removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId);
runInAction(() => {
pull(this.inboxIssues[inboxId], inboxIssueId);
delete this.inboxIssueMap[inboxId][inboxIssueId];
this.rootStore.inbox.rootStore.issue.issues.removeIssue(inboxIssueId);
update(this.rootStore.inbox.inbox.inboxMap, [inboxId, "pending_issue_count"], (count: number = 0) => count - 1);
});
await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, inboxIssueId);
} catch (error) {
throw error;
}
};
updateInboxIssueStatus = async (
workspaceSlug: string,
projectId: string,
inboxId: string,
inboxIssueId: string,
data: TInboxDetailedStatus
) => {
try {
await this.inboxIssueService.updateInboxIssueStatus(workspaceSlug, projectId, inboxId, inboxIssueId, data);
const pendingStatus = -2;
runInAction(() => {
set(this.inboxIssueMap, [inboxId, inboxIssueId, "status"], data.status);
update(this.rootStore.inbox.inbox.inboxMap, [inboxId, "pending_issue_count"], (count: number = 0) =>
data.status === pendingStatus ? count + 1 : count - 1
);
});
await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, inboxIssueId);
} catch (error) {
throw error;
}
};
}

View file

@ -0,0 +1,353 @@
import isEmpty from "lodash/isEmpty";
import omit from "lodash/omit";
import set from "lodash/set";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// types
import {
TInboxIssue,
TInboxIssueCurrentTab,
TInboxIssueFilter,
TInboxIssueSorting,
TInboxIssuePaginationInfo,
TInboxIssueSortingOrderByQueryParam,
} from "@plane/types";
// services
import { InboxIssueService } from "@/services/inbox";
// root store
import { IInboxIssueStore, InboxIssueStore } from "@/store/inbox/inbox-issue.store";
import { RootStore } from "@/store/root.store";
type TLoader = "init-loading" | "filter-loading" | "pagination-loading" | "issue-loading" | undefined;
export interface IProjectInboxStore {
currentTab: TInboxIssueCurrentTab;
isLoading: TLoader;
error: { message: string; status: "init-error" | "pagination-error" } | undefined;
inboxFilters: Partial<TInboxIssueFilter>;
inboxSorting: Partial<TInboxIssueSorting>;
inboxIssuePaginationInfo: TInboxIssuePaginationInfo | undefined;
inboxIssues: Record<string, IInboxIssueStore>;
// computed
getAppliedFiltersCount: number;
inboxIssuesArray: IInboxIssueStore[];
// helper actions
getIssueInboxByIssueId: (issueId: string) => IInboxIssueStore | undefined;
inboxIssueQueryParams: (
inboxFilters: Partial<TInboxIssueFilter>,
inboxSorting: Partial<TInboxIssueSorting>,
pagePerCount: number,
paginationCursor: string
) => Partial<Record<keyof TInboxIssueFilter, string>>;
// actions
handleCurrentTab: (tab: TInboxIssueCurrentTab) => void;
handleInboxIssueFilters: <T extends keyof TInboxIssueFilter>(key: T, value: TInboxIssueFilter[T]) => void; // if user sends me undefined, I will remove the value from the filter key
handleInboxIssueSorting: <T extends keyof TInboxIssueSorting>(key: T, value: TInboxIssueSorting[T]) => void; // if user sends me undefined, I will remove the value from the filter key
fetchInboxIssues: (workspaceSlug: string, projectId: string, loadingType?: TLoader) => Promise<void>;
fetchInboxPaginationIssues: (workspaceSlug: string, projectId: string) => Promise<void>;
fetchInboxIssueById: (workspaceSlug: string, projectId: string, inboxIssueId: string) => Promise<void>;
createInboxIssue: (
workspaceSlug: string,
projectId: string,
data: Partial<TInboxIssue>
) => Promise<TInboxIssue | undefined>;
deleteInboxIssue: (workspaceSlug: string, projectId: string, inboxIssueId: string) => Promise<void>;
}
export class ProjectInboxStore implements IProjectInboxStore {
// constants
PER_PAGE_COUNT = 10;
// observables
currentTab: TInboxIssueCurrentTab = "open";
isLoading: TLoader = undefined;
error: { message: string; status: "init-error" | "pagination-error" } | undefined = undefined;
inboxFilters: Partial<TInboxIssueFilter> = {
status: [-2],
};
inboxSorting: Partial<TInboxIssueSorting> = {
order_by: "issue__created_at",
sort_by: "desc",
};
inboxIssuePaginationInfo: TInboxIssuePaginationInfo | undefined = undefined;
inboxIssues: Record<string, IInboxIssueStore> = {};
// services
inboxIssueService;
constructor(private store: RootStore) {
makeObservable(this, {
currentTab: observable.ref,
isLoading: observable.ref,
inboxFilters: observable,
inboxSorting: observable,
inboxIssuePaginationInfo: observable,
inboxIssues: observable,
// computed
getAppliedFiltersCount: computed,
inboxIssuesArray: computed,
// actions
handleInboxIssueFilters: action,
handleInboxIssueSorting: action,
fetchInboxIssues: action,
fetchInboxPaginationIssues: action,
fetchInboxIssueById: action,
createInboxIssue: action,
deleteInboxIssue: action,
});
this.inboxIssueService = new InboxIssueService();
}
// computed
get getAppliedFiltersCount() {
let count = 0;
this.inboxFilters != undefined &&
Object.keys(this.inboxFilters).forEach((key) => {
const filterKey = key as keyof TInboxIssueFilter;
if (this.inboxFilters[filterKey] && this.inboxFilters?.[filterKey])
count = count + (this.inboxFilters?.[filterKey]?.length ?? 0);
});
return count;
}
get inboxIssuesArray() {
return Object.values(this.inboxIssues || {}).filter((inbox) =>
(this.currentTab === "open" ? [-2] : [-1, 0, 1, 2]).includes(inbox.status)
);
}
getIssueInboxByIssueId = computedFn((issueId: string) => this.inboxIssues?.[issueId] || undefined);
inboxIssueQueryParams = (
inboxFilters: Partial<TInboxIssueFilter>,
inboxSorting: Partial<TInboxIssueSorting>,
pagePerCount: number,
paginationCursor: string
) => {
const filters: Partial<Record<keyof TInboxIssueFilter, string>> = {};
!isEmpty(inboxFilters) &&
Object.keys(inboxFilters).forEach((key) => {
const filterKey = key as keyof TInboxIssueFilter;
if (inboxFilters[filterKey] && inboxFilters[filterKey]?.length)
filters[filterKey] = inboxFilters[filterKey]?.join(",");
});
const sorting: TInboxIssueSortingOrderByQueryParam = {
order_by: "-issue__created_at",
};
if (inboxSorting?.order_by && inboxSorting?.sort_by) {
switch (inboxSorting.order_by) {
case "issue__created_at":
if (inboxSorting.sort_by === "desc") sorting.order_by = `-issue__created_at`;
else sorting.order_by = "issue__created_at";
break;
case "issue__updated_at":
if (inboxSorting.sort_by === "desc") sorting.order_by = `-issue__updated_at`;
else sorting.order_by = "issue__updated_at";
break;
case "issue__sequence_id":
if (inboxSorting.sort_by === "desc") sorting.order_by = `-issue__sequence_id`;
else sorting.order_by = "issue__sequence_id";
break;
default:
sorting.order_by = "-issue__created_at";
break;
}
}
return {
...filters,
...sorting,
per_page: pagePerCount,
cursor: paginationCursor,
};
};
// actions
handleCurrentTab = (tab: TInboxIssueCurrentTab) => {
set(this, "currentTab", tab);
set(this, "inboxFilters", undefined);
set(this, ["inboxSorting", "order_by"], "issue__created_at");
set(this, ["inboxSorting", "sort_by"], "desc");
if (tab === "closed") set(this, ["inboxFilters", "status"], [-1, 0, 1, 2]);
else set(this, ["inboxFilters", "status"], [-2]);
const { workspaceSlug, projectId } = this.store.app.router;
if (workspaceSlug && projectId) this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading");
};
handleInboxIssueFilters = <T extends keyof TInboxIssueFilter>(key: T, value: TInboxIssueFilter[T]) => {
set(this.inboxFilters, key, value);
const { workspaceSlug, projectId } = this.store.app.router;
if (workspaceSlug && projectId) this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading");
};
handleInboxIssueSorting = <T extends keyof TInboxIssueSorting>(key: T, value: TInboxIssueSorting[T]) => {
set(this.inboxSorting, key, value);
const { workspaceSlug, projectId } = this.store.app.router;
if (workspaceSlug && projectId) this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading");
};
/**
* @description fetch inbox issues with paginated data
* @param workspaceSlug
* @param projectId
*/
fetchInboxIssues = async (workspaceSlug: string, projectId: string, loadingType: TLoader = undefined) => {
try {
if (loadingType) this.isLoading = loadingType;
else this.isLoading = "init-loading";
this.inboxIssuePaginationInfo = undefined;
this.inboxIssues = {};
const queryParams = this.inboxIssueQueryParams(
this.inboxFilters,
this.inboxSorting,
this.PER_PAGE_COUNT,
`${this.PER_PAGE_COUNT}:0:0`
);
const { results, ...paginationInfo } = await this.inboxIssueService.list(workspaceSlug, projectId, queryParams);
runInAction(() => {
this.isLoading = undefined;
set(this, "inboxIssuePaginationInfo", paginationInfo);
if (results && results.length > 0)
results.forEach((value: TInboxIssue) => {
if (this.getIssueInboxByIssueId(value?.issue?.id) === undefined)
set(this.inboxIssues, value?.issue?.id, new InboxIssueStore(workspaceSlug, projectId, value));
});
});
} catch (error) {
console.error("Error fetching the inbox issues", error);
this.isLoading = undefined;
this.error = {
message: "Error fetching the inbox issues please try again later.",
status: "init-error",
};
throw error;
}
};
/**
* @description fetch inbox issues with paginated data
* @param workspaceSlug
* @param projectId
*/
fetchInboxPaginationIssues = async (workspaceSlug: string, projectId: string) => {
try {
if (
!this.inboxIssuePaginationInfo?.total_results ||
(this.inboxIssuePaginationInfo?.total_results &&
this.inboxIssuesArray.length < this.inboxIssuePaginationInfo?.total_results)
) {
this.isLoading = "pagination-loading";
const queryParams = this.inboxIssueQueryParams(
this.inboxFilters,
this.inboxSorting,
this.PER_PAGE_COUNT,
this.inboxIssuePaginationInfo?.next_cursor || `${this.PER_PAGE_COUNT}:0:0`
);
const { results, ...paginationInfo } = await this.inboxIssueService.list(workspaceSlug, projectId, queryParams);
runInAction(() => {
this.isLoading = undefined;
set(this, "inboxIssuePaginationInfo", paginationInfo);
if (results && results.length > 0)
results.forEach((value: TInboxIssue) => {
if (this.getIssueInboxByIssueId(value?.issue?.id) === undefined)
set(this.inboxIssues, value?.issue?.id, new InboxIssueStore(workspaceSlug, projectId, value));
});
});
} else set(this, ["inboxIssuePaginationInfo", "next_page_results"], false);
} catch (error) {
console.error("Error fetching the inbox issues", error);
this.isLoading = undefined;
this.error = {
message: "Error fetching the paginated inbox issues please try again later.",
status: "pagination-error",
};
throw error;
}
};
/**
* @description fetch inbox issue with issue id
* @param workspaceSlug
* @param projectId
* @param inboxIssueId
*/
fetchInboxIssueById = async (workspaceSlug: string, projectId: string, inboxIssueId: string) => {
try {
this.isLoading = "issue-loading";
const inboxIssue = await this.inboxIssueService.retrieve(workspaceSlug, projectId, inboxIssueId);
const issueId = inboxIssue?.issue?.id || undefined;
if (inboxIssue && issueId) {
// fetching reactions
await this.store.issue.issueDetail.fetchReactions(workspaceSlug, projectId, issueId);
// fetching activity
await this.store.issue.issueDetail.fetchReactions(workspaceSlug, projectId, issueId);
// fetching comments
await this.store.issue.issueDetail.fetchReactions(workspaceSlug, projectId, issueId);
runInAction(() => {
set(this.inboxIssues, issueId, new InboxIssueStore(workspaceSlug, projectId, inboxIssue));
});
this.isLoading = undefined;
}
} catch {
console.error("Error fetching the inbox issue with inbox issue id");
this.isLoading = undefined;
}
};
/**
* @description create inbox issue
* @param workspaceSlug
* @param projectId
* @param data
*/
createInboxIssue = async (workspaceSlug: string, projectId: string, data: Partial<TInboxIssue>) => {
try {
const inboxIssueResponse = await this.inboxIssueService.create(workspaceSlug, projectId, data);
if (inboxIssueResponse)
runInAction(() => {
set(
this.inboxIssues,
inboxIssueResponse?.issue?.id,
new InboxIssueStore(workspaceSlug, projectId, inboxIssueResponse)
);
set(
this,
["inboxIssuePaginationInfo", "total_results"],
(this.inboxIssuePaginationInfo?.total_results || 0) + 1
);
});
return inboxIssueResponse;
} catch {
console.error("Error creating the inbox issue");
}
};
/**
* @description delete inbox issue
* @param workspaceSlug
* @param projectId
* @param inboxIssueId
*/
deleteInboxIssue = async (workspaceSlug: string, projectId: string, inboxIssueId: string) => {
const currentIssue = this.inboxIssues?.[inboxIssueId];
try {
if (!currentIssue) return;
runInAction(() => {
set(
this,
["inboxIssuePaginationInfo", "total_results"],
(this.inboxIssuePaginationInfo?.total_results || 0) - 1
);
set(this, "inboxIssues", omit(this.inboxIssues, inboxIssueId));
});
await this.inboxIssueService.destroy(workspaceSlug, projectId, inboxIssueId);
} catch {
console.error("Error removing the inbox issue");
set(this.inboxIssues, [inboxIssueId], currentIssue);
}
};
}

View file

@ -1,26 +0,0 @@
// types
import { RootStore } from "@/store/root.store";
import { IInbox, Inbox } from "./inbox.store";
import { IInboxFilter, InboxFilter } from "./inbox_filter.store";
import { IInboxIssue, InboxIssue } from "./inbox_issue.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

@ -71,9 +71,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj
const displayFilters = this.filters[projectId] || undefined;
if (isEmpty(displayFilters)) return undefined;
const _filters: IIssueFilters = this.computedIssueFilters(displayFilters);
return _filters;
return this.computedIssueFilters(displayFilters);
}
get appliedFilters() {

View file

@ -1,25 +1,25 @@
import { enableStaticRendering } from "mobx-react-lite";
// root stores
import { ProjectInboxStore, IProjectInboxStore } from "@/store/inbox/project-inbox.store";
import { AppRootStore, IAppRootStore } from "./application";
import { CycleStore, ICycleStore } from "./cycle.store";
import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store";
import { DashboardStore, IDashboardStore } from "./dashboard.store";
import { IEstimateStore, EstimateStore } from "./estimate.store";
import { EventTrackerStore, IEventTrackerStore } from "./event-tracker.store";
import { GlobalViewStore, IGlobalViewStore } from "./global-view.store";
import { IInboxRootStore, InboxRootStore } from "./inbox/root.store";
import { IssueRootStore, IIssueRootStore } from "./issue/root.store";
import { ILabelStore, LabelStore } from "./label.store";
import { IMemberRootStore, MemberRootStore } from "./member";
import { IMentionStore, MentionStore } from "./mention.store";
import { IModuleStore, ModulesStore } from "./module.store";
import { IModuleFilterStore, ModuleFilterStore } from "./module_filter.store";
import { IProjectRootStore, ProjectRootStore } from "./project";
import { IProjectPageStore, ProjectPageStore } from "./project-page.store";
import { IProjectViewStore, ProjectViewStore } from "./project-view.store";
import { IStateStore, StateStore } from "./state.store";
import { IUserRootStore, UserRootStore } from "./user";
import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace";
import { IProjectPageStore, ProjectPageStore } from "./project-page.store";
import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store";
import { IModuleFilterStore, ModuleFilterStore } from "./module_filter.store";
enableStaticRendering(typeof window === "undefined");
@ -37,13 +37,13 @@ export class RootStore {
projectView: IProjectViewStore;
globalView: IGlobalViewStore;
issue: IIssueRootStore;
inbox: IInboxRootStore;
state: IStateStore;
label: ILabelStore;
estimate: IEstimateStore;
mention: IMentionStore;
dashboard: IDashboardStore;
projectPages: IProjectPageStore;
projectInbox: IProjectInboxStore;
constructor() {
this.app = new AppRootStore(this);
@ -60,12 +60,13 @@ export class RootStore {
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);
this.mention = new MentionStore(this);
this.dashboard = new DashboardStore(this);
// inbox
this.projectInbox = new ProjectInboxStore(this);
this.projectPages = new ProjectPageStore(this);
}
@ -81,12 +82,12 @@ export class RootStore {
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);
this.mention = new MentionStore(this);
this.dashboard = new DashboardStore(this);
this.projectInbox = new ProjectInboxStore(this);
this.projectPages = new ProjectPageStore(this);
}
}