chore: route to issue after creating it (#1359)

* chore: navigate to newly created inbox issue

* refactor: inbox

* fix: hide ai modal after issue creation

* chore: hide action buttons after acting upon them

* chore: add icon to inbox status

* chore: update inbox status colors
This commit is contained in:
Aaryan Khandelwal 2023-06-23 13:19:26 +05:30 committed by GitHub
parent 41b7544cfc
commit 71b2884b57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 517 additions and 564 deletions

View File

@ -1,63 +1,81 @@
// hooks
import useInboxView from "hooks/use-inbox-view";
// ui
import { MultiLevelDropdown } from "components/ui";
// icons
import { getPriorityIcon } from "components/icons";
// types
import { IInboxFilterOptions } from "types";
// constants
import { PRIORITIES } from "constants/project";
import { INBOX_STATUS } from "constants/inbox";
type Props = {
filters: Partial<IInboxFilterOptions>;
onSelect: (option: any) => void;
direction?: "left" | "right";
height?: "sm" | "md" | "rg" | "lg";
};
export const FiltersDropdown: React.FC = () => {
const { filters, setFilters, filtersLength } = useInboxView();
export const FiltersDropdown: React.FC<Props> = ({ filters, onSelect, direction, height }) => (
<MultiLevelDropdown
label="Filters"
onSelect={onSelect}
direction={direction}
height={height}
options={[
{
id: "priority",
label: "Priority",
value: PRIORITIES,
children: [
...PRIORITIES.map((priority) => ({
id: priority ?? "none",
label: (
<div className="flex items-center gap-2">
{getPriorityIcon(priority)} {priority ?? "None"}
</div>
),
value: {
key: "priority",
value: priority,
},
selected: filters?.priority?.includes(priority ?? "none"),
})),
],
},
{
id: "inbox_status",
label: "Status",
value: INBOX_STATUS.map((status) => status.value),
children: [
...INBOX_STATUS.map((status) => ({
id: status.key,
label: status.label,
value: {
key: "inbox_status",
value: status.value,
},
selected: filters?.inbox_status?.includes(status.value),
})),
],
},
]}
/>
);
return (
<div className="relative">
<MultiLevelDropdown
label="Filters"
onSelect={(option) => {
const key = option.key as keyof typeof filters;
const valueExists = (filters[key] as any[])?.includes(option.value);
if (valueExists) {
setFilters({
[option.key]: ((filters[key] ?? []) as any[])?.filter((val) => val !== option.value),
});
} else {
setFilters({
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
});
}
}}
direction="right"
height="rg"
options={[
{
id: "priority",
label: "Priority",
value: PRIORITIES,
children: [
...PRIORITIES.map((priority) => ({
id: priority ?? "none",
label: (
<div className="flex items-center gap-2">
{getPriorityIcon(priority)} {priority ?? "None"}
</div>
),
value: {
key: "priority",
value: priority,
},
selected: filters?.priority?.includes(priority ?? "none"),
})),
],
},
{
id: "inbox_status",
label: "Status",
value: INBOX_STATUS.map((status) => status.value),
children: [
...INBOX_STATUS.map((status) => ({
id: status.key,
label: status.label,
value: {
key: "inbox_status",
value: status.value,
},
selected: filters?.inbox_status?.includes(status.value),
})),
],
},
]}
/>
{filtersLength > 0 && (
<div className="absolute -top-2 -right-2 h-4 w-4 text-[0.65rem] grid place-items-center rounded-full text-brand-base bg-brand-surface-2 border border-brand-base z-10">
<span>{filtersLength}</span>
</div>
)}
</div>
);
};

View File

@ -2,17 +2,27 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-datepicker
import DatePicker from "react-datepicker";
// headless ui
import { Popover } from "@headlessui/react";
// contexts
import { useProjectMyMembership } from "contexts/project-member.context";
// services
import inboxServices from "services/inbox.service";
// hooks
import useInboxView from "hooks/use-inbox-view";
import useUserAuth from "hooks/use-user-auth";
import useToast from "hooks/use-toast";
// components
import { FiltersDropdown } from "components/inbox";
import {
DeclineIssueModal,
DeleteIssueModal,
FiltersDropdown,
SelectDuplicateInboxIssueModal,
} from "components/inbox";
// ui
import { PrimaryButton, SecondaryButton } from "components/ui";
// icons
@ -26,47 +36,84 @@ import {
TrashIcon,
} from "@heroicons/react/24/outline";
// types
import type { IInboxIssue } from "types";
type Props = {
issueCount: number;
currentIssueIndex: number;
issue?: IInboxIssue;
onAccept: () => Promise<void>;
onDecline: () => void;
onMarkAsDuplicate: () => void;
onSnooze: (date: Date | string) => void;
onDelete: () => void;
};
export const InboxActionHeader: React.FC<Props> = (props) => {
const {
issueCount,
currentIssueIndex,
onAccept,
onDecline,
onMarkAsDuplicate,
onSnooze,
onDelete,
issue,
} = props;
import type { IInboxIssueDetail, TInboxStatus } from "types";
// fetch-keys
import { INBOX_ISSUE_DETAILS } from "constants/fetch-keys";
export const InboxActionHeader = () => {
const [isAccepting, setIsAccepting] = useState(false);
const [date, setDate] = useState(new Date());
const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false);
const [declineIssueModal, setDeclineIssueModal] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const router = useRouter();
const { inboxIssueId } = router.query;
const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query;
const { memberRole } = useProjectMyMembership();
const { filters, setFilters, filtersLength } = useInboxView();
const { user } = useUserAuth();
const { memberRole } = useProjectMyMembership();
const { issues: inboxIssues, mutate: mutateInboxIssues } = useInboxView();
const { setToastAlert } = useToast();
const markInboxStatus = async (data: TInboxStatus) => {
if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId) return;
mutate<IInboxIssueDetail>(
INBOX_ISSUE_DETAILS(inboxId as string, inboxIssueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
issue_inbox: [{ ...prevData.issue_inbox[0], ...data }],
};
},
false
);
mutateInboxIssues(
(prevData) =>
(prevData ?? []).map((i) =>
i.bridge_id === inboxIssueId
? { ...i, issue_inbox: [{ ...i.issue_inbox[0], ...data }] }
: i
),
false
);
await inboxServices
.markInboxStatus(
workspaceSlug.toString(),
projectId.toString(),
inboxId.toString(),
inboxIssues?.find((inboxIssue) => inboxIssue.bridge_id === inboxIssueId)?.bridge_id!,
data,
user
)
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong while updating inbox status. Please try again.",
})
)
.finally(() => {
mutate(INBOX_ISSUE_DETAILS(inboxId as string, inboxIssueId as string));
mutateInboxIssues();
});
};
const handleAcceptIssue = () => {
setIsAccepting(true);
onAccept().finally(() => setIsAccepting(false));
markInboxStatus({
status: 1,
}).finally(() => setIsAccepting(false));
};
const issue = inboxIssues?.find((issue) => issue.bridge_id === inboxIssueId);
const currentIssueIndex =
inboxIssues?.findIndex((issue) => issue.bridge_id === inboxIssueId) ?? 0;
useEffect(() => {
if (!issue?.issue_inbox[0].snoozed_till) return;
@ -82,163 +129,165 @@ export const InboxActionHeader: React.FC<Props> = (props) => {
tomorrow.setDate(today.getDate() + 1);
return (
<div className="grid grid-cols-4 border-b border-brand-base divide-x divide-brand-base">
<div className="col-span-1 flex justify-between p-4">
<div className="flex items-center gap-2">
<InboxIcon className="h-4 w-4 text-brand-secondary" />
<h3 className="font-medium">Inbox</h3>
</div>
<div className="relative">
<FiltersDropdown
filters={filters}
onSelect={(option) => {
const key = option.key as keyof typeof filters;
const valueExists = (filters[key] as any[])?.includes(option.value);
if (valueExists) {
setFilters({
[option.key]: ((filters[key] ?? []) as any[])?.filter(
(val) => val !== option.value
),
});
} else {
setFilters({
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
});
}
}}
direction="right"
height="rg"
/>
{filtersLength > 0 && (
<div className="absolute -top-2 -right-2 h-4 w-4 text-[0.65rem] grid place-items-center rounded-full text-brand-base bg-brand-surface-2 border border-brand-base z-10">
<span>{filtersLength}</span>
</div>
)}
</div>
</div>
{inboxIssueId && (
<div className="flex justify-between items-center gap-4 px-4 col-span-3">
<div className="flex items-center gap-x-2">
<button
type="button"
className="rounded border border-brand-base bg-brand-surface-1 p-1.5 hover:bg-brand-surface-2"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "ArrowUp" });
document.dispatchEvent(e);
}}
>
<ChevronUpIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded border border-brand-base bg-brand-surface-1 p-1.5 hover:bg-brand-surface-2"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "ArrowDown" });
document.dispatchEvent(e);
}}
>
<ChevronDownIcon className="h-3.5 w-3.5" />
</button>
<div className="text-sm">
{currentIssueIndex + 1}/{issueCount}
</div>
<>
<SelectDuplicateInboxIssueModal
isOpen={selectDuplicateIssue}
onClose={() => setSelectDuplicateIssue(false)}
value={
inboxIssues?.find((inboxIssue) => inboxIssue.bridge_id === inboxIssueId)?.issue_inbox[0]
.duplicate_to
}
onSubmit={(dupIssueId: string) => {
markInboxStatus({
status: 2,
duplicate_to: dupIssueId,
}).finally(() => setSelectDuplicateIssue(false));
}}
/>
<DeclineIssueModal
isOpen={declineIssueModal}
handleClose={() => setDeclineIssueModal(false)}
data={inboxIssues?.find((i) => i.bridge_id === inboxIssueId)}
onSubmit={async () => {
await markInboxStatus({
status: -1,
}).finally(() => setDeclineIssueModal(false));
}}
/>
<DeleteIssueModal
isOpen={deleteIssueModal}
handleClose={() => setDeleteIssueModal(false)}
data={inboxIssues?.find((i) => i.bridge_id === inboxIssueId)}
/>
<div className="grid grid-cols-4 border-b border-brand-base divide-x divide-brand-base">
<div className="col-span-1 flex justify-between p-4">
<div className="flex items-center gap-2">
<InboxIcon className="h-4 w-4 text-brand-secondary" />
<h3 className="font-medium">Inbox</h3>
</div>
<div className="flex items-center gap-3 flex-wrap">
{isAllowed && (
<div
className={`flex-shrink-0 ${
issueStatus === 0 || issueStatus === -2 ? "" : "opacity-70"
}`}
<FiltersDropdown />
</div>
{inboxIssueId && (
<div className="flex justify-between items-center gap-4 px-4 col-span-3">
<div className="flex items-center gap-x-2">
<button
type="button"
className="rounded border border-brand-base bg-brand-surface-1 p-1.5 hover:bg-brand-surface-2"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "ArrowUp" });
document.dispatchEvent(e);
}}
>
<Popover className="relative">
<Popover.Button
as="button"
type="button"
disabled={!(issueStatus === 0 || issueStatus === -2)}
<ChevronUpIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded border border-brand-base bg-brand-surface-1 p-1.5 hover:bg-brand-surface-2"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "ArrowDown" });
document.dispatchEvent(e);
}}
>
<ChevronDownIcon className="h-3.5 w-3.5" />
</button>
<div className="text-sm">
{currentIssueIndex + 1}/{inboxIssues?.length ?? 0}
</div>
</div>
<div className="flex items-center gap-3 flex-wrap">
{isAllowed && (issueStatus === 0 || issueStatus === -2) && (
<div className="flex-shrink-0">
<Popover className="relative">
<Popover.Button as="button" type="button">
<SecondaryButton className="flex gap-x-1 items-center" size="sm">
<ClockIcon className="h-4 w-4 text-brand-secondary" />
<span>Snooze</span>
</SecondaryButton>
</Popover.Button>
<Popover.Panel className="w-80 p-2 absolute right-0 z-10 mt-2 rounded-md border border-brand-base bg-brand-surface-2 shadow-lg">
{({ close }) => (
<div className="w-full h-full flex flex-col gap-y-1">
<DatePicker
selected={date ? new Date(date) : null}
onChange={(val) => {
if (!val) return;
setDate(val);
}}
dateFormat="dd-MM-yyyy"
minDate={tomorrow}
inline
/>
<PrimaryButton
className="ml-auto"
onClick={() => {
close();
markInboxStatus({
status: 0,
snoozed_till: new Date(date),
});
}}
>
Snooze
</PrimaryButton>
</div>
)}
</Popover.Panel>
</Popover>
</div>
)}
{isAllowed && issueStatus === -2 && (
<div className="flex-shrink-0">
<SecondaryButton
size="sm"
className="flex gap-2 items-center"
onClick={() => setSelectDuplicateIssue(true)}
>
<SecondaryButton
className={`flex gap-x-1 items-center ${
issueStatus === 0 || issueStatus === -2 ? "" : "cursor-not-allowed"
}`}
size="sm"
>
<ClockIcon className="h-4 w-4 text-brand-secondary" />
<span>Snooze</span>
</SecondaryButton>
</Popover.Button>
<Popover.Panel className="w-80 p-2 absolute right-0 z-10 mt-2 rounded-md border border-brand-base bg-brand-surface-2 shadow-lg">
{({ close }) => (
<div className="w-full h-full flex flex-col gap-y-1">
<DatePicker
selected={date ? new Date(date) : null}
onChange={(val) => {
if (!val) return;
setDate(val);
}}
dateFormat="dd-MM-yyyy"
minDate={tomorrow}
inline
/>
<PrimaryButton
className="ml-auto"
onClick={() => {
close();
onSnooze(date);
}}
>
Snooze
</PrimaryButton>
</div>
)}
</Popover.Panel>
</Popover>
</div>
)}
{isAllowed && (
<div className={`flex gap-3 flex-wrap ${issueStatus !== -2 ? "opacity-70" : ""}`}>
<SecondaryButton
size="sm"
className="flex gap-2 items-center"
onClick={onMarkAsDuplicate}
disabled={issueStatus !== -2}
>
<StackedLayersHorizontalIcon className="h-4 w-4 text-brand-secondary" />
<span>Mark as duplicate</span>
</SecondaryButton>
<SecondaryButton
size="sm"
className="flex gap-2 items-center"
onClick={handleAcceptIssue}
disabled={issueStatus !== -2}
loading={isAccepting}
>
<CheckCircleIcon className="h-4 w-4 text-green-500" />
<span>{isAccepting ? "Accepting..." : "Accept"}</span>
</SecondaryButton>
<SecondaryButton
size="sm"
className="flex gap-2 items-center"
onClick={onDecline}
disabled={issueStatus !== -2}
>
<XCircleIcon className="h-4 w-4 text-red-500" />
<span>Decline</span>
</SecondaryButton>
</div>
)}
{(isAllowed || user?.id === issue?.created_by) && (
<div className="flex-shrink-0">
<SecondaryButton size="sm" className="flex gap-2 items-center" onClick={onDelete}>
<TrashIcon className="h-4 w-4 text-red-500" />
<span>Delete</span>
</SecondaryButton>
</div>
)}
<StackedLayersHorizontalIcon className="h-4 w-4 text-brand-secondary" />
<span>Mark as duplicate</span>
</SecondaryButton>
</div>
)}
{isAllowed && (issueStatus === 0 || issueStatus === -2) && (
<div className="flex-shrink-0">
<SecondaryButton
size="sm"
className="flex gap-2 items-center"
onClick={handleAcceptIssue}
loading={isAccepting}
>
<CheckCircleIcon className="h-4 w-4 text-green-500" />
<span>{isAccepting ? "Accepting..." : "Accept"}</span>
</SecondaryButton>
</div>
)}
{isAllowed && issueStatus === -2 && (
<div className="flex-shrink-0">
<SecondaryButton
size="sm"
className="flex gap-2 items-center"
onClick={() => setDeclineIssueModal(true)}
>
<XCircleIcon className="h-4 w-4 text-red-500" />
<span>Decline</span>
</SecondaryButton>
</div>
)}
{(isAllowed || user?.id === issue?.created_by) && (
<div className="flex-shrink-0">
<SecondaryButton
size="sm"
className="flex gap-2 items-center"
onClick={() => setDeleteIssueModal(true)}
>
<TrashIcon className="h-4 w-4 text-red-500" />
<span>Delete</span>
</SecondaryButton>
</div>
)}
</div>
</div>
</div>
)}
</div>
)}
</div>
</>
);
};

View File

@ -4,13 +4,21 @@ import Link from "next/link";
// ui
import { Tooltip } from "components/ui";
// icons
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
import { CalendarDaysIcon, ClockIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon } from "components/icons";
import {
CalendarDaysIcon,
CheckCircleIcon,
ClockIcon,
DocumentDuplicateIcon,
ExclamationTriangleIcon,
XCircleIcon,
} from "@heroicons/react/24/outline";
// helpers
import { renderShortNumericDateFormat } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import type { IInboxIssue } from "types";
// constants
import { INBOX_STATUS } from "constants/inbox";
type Props = {
issue: IInboxIssue;
@ -30,93 +38,88 @@ export const InboxIssueCard: React.FC<Props> = (props) => {
href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${issue.bridge_id}`}
>
<a>
<Tooltip
tooltipContent={
issueStatus === -2
? "Pending issue"
: issueStatus === -1
? "Declined issue"
: issueStatus === 0
? "Snoozed issue"
: issueStatus === 1
? "Accepted issue"
: "Marked as duplicate"
}
position="right"
<div
id={issue.id}
className={`relative min-h-[5rem] cursor-pointer select-none space-y-3 py-2 px-4 border-b border-brand-base hover:bg-brand-accent hover:bg-opacity-10 ${
active ? "bg-brand-accent bg-opacity-5" : " "
} ${issue.issue_inbox[0].status !== -2 ? "opacity-60" : ""}`}
>
<div
id={issue.id}
className={`relative min-h-[5rem] cursor-pointer select-none space-y-3 py-2 px-4 border-b border-brand-base hover:bg-brand-accent hover:bg-opacity-10 ${
active ? "bg-brand-accent bg-opacity-5" : " "
} ${issue.issue_inbox[0].status !== -2 ? "opacity-60" : ""}`}
>
<div className="flex items-center gap-x-2">
<p className="flex-shrink-0 text-brand-secondary text-xs">
{issue.project_detail?.identifier}-{issue.sequence_id}
</p>
<h5 className="truncate text-sm">{issue.name}</h5>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Tooltip
tooltipHeading="State"
tooltipContent={addSpaceIfCamelCase(issue.state_detail?.name ?? "Triage")}
>
<div className="flex items-center gap-2 rounded border border-brand-base shadow-sm text-xs px-2 py-[0.19rem] text-brand-secondary">
{getStateGroupIcon(
issue.state_detail?.group ?? "backlog",
"14",
"14",
issue.state_detail?.color
)}
{issue.state_detail?.name ?? "Triage"}
</div>
</Tooltip>
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`}>
<div
className={`grid h-6 w-6 place-items-center rounded border items-center shadow-sm ${
issue.priority === "urgent"
? "border-red-500/20 bg-red-500/20 text-red-500"
: issue.priority === "high"
? "border-orange-500/20 bg-orange-500/20 text-orange-500"
: issue.priority === "medium"
? "border-yellow-500/20 bg-yellow-500/20 text-yellow-500"
: issue.priority === "low"
? "border-green-500/20 bg-green-500/20 text-green-500"
: "border-brand-base"
}`}
>
{getPriorityIcon(
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
"text-sm"
)}
</div>
</Tooltip>
<Tooltip
tooltipHeading="Created at"
tooltipContent={`${renderShortNumericDateFormat(issue.created_at ?? "")}`}
>
<div className="flex items-center gap-1 rounded border border-brand-base shadow-sm text-xs px-2 py-[0.19rem] text-brand-secondary">
<CalendarDaysIcon className="h-3.5 w-3.5" />
<span>{renderShortNumericDateFormat(issue.created_at ?? "")}</span>
</div>
</Tooltip>
{issue.issue_inbox[0].snoozed_till && (
<div
className={`text-xs flex items-center gap-1 ${
new Date(issue.issue_inbox[0].snoozed_till ?? "") < new Date()
? "text-red-500"
: "text-blue-500"
}`}
>
<ClockIcon className="h-3.5 w-3.5" />
<span>
Snoozed till {renderShortNumericDateFormat(issue.issue_inbox[0].snoozed_till)}
</span>
</div>
)}
</div>
<div className="flex items-center gap-x-2">
<p className="flex-shrink-0 text-brand-secondary text-xs">
{issue.project_detail?.identifier}-{issue.sequence_id}
</p>
<h5 className="truncate text-sm">{issue.name}</h5>
</div>
</Tooltip>
<div className="flex items-center gap-2 flex-wrap">
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`}>
<div
className={`grid h-6 w-6 place-items-center rounded border items-center shadow-sm ${
issue.priority === "urgent"
? "border-red-500/20 bg-red-500/20 text-red-500"
: issue.priority === "high"
? "border-orange-500/20 bg-orange-500/20 text-orange-500"
: issue.priority === "medium"
? "border-yellow-500/20 bg-yellow-500/20 text-yellow-500"
: issue.priority === "low"
? "border-green-500/20 bg-green-500/20 text-green-500"
: "border-brand-base"
}`}
>
{getPriorityIcon(
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
"text-sm"
)}
</div>
</Tooltip>
<Tooltip
tooltipHeading="Created at"
tooltipContent={`${renderShortNumericDateFormat(issue.created_at ?? "")}`}
>
<div className="flex items-center gap-1 rounded border border-brand-base shadow-sm text-xs px-2 py-[0.19rem] text-brand-secondary">
<CalendarDaysIcon className="h-3.5 w-3.5" />
<span>{renderShortNumericDateFormat(issue.created_at ?? "")}</span>
</div>
</Tooltip>
</div>
<div
className={`text-xs flex items-center justify-end gap-1 w-full ${
issueStatus === 0 && new Date(issue.issue_inbox[0].snoozed_till ?? "") < new Date()
? "text-red-500"
: INBOX_STATUS.find((s) => s.value === issueStatus)?.textColor
}`}
>
{issueStatus === -2 ? (
<>
<ExclamationTriangleIcon className="h-3.5 w-3.5" />
<span>Pending</span>
</>
) : issueStatus === -1 ? (
<>
<XCircleIcon className="h-3.5 w-3.5" />
<span>Declined</span>
</>
) : issueStatus === 0 ? (
<>
<ClockIcon className="h-3.5 w-3.5" />
<span>
{new Date(issue.issue_inbox[0].snoozed_till ?? "") < new Date()
? "Snoozed date passed"
: "Snoozed"}
</span>
</>
) : issueStatus === 1 ? (
<>
<CheckCircleIcon className="h-3.5 w-3.5" />
<span>Accepted</span>
</>
) : (
<>
<DocumentDuplicateIcon className="h-3.5 w-3.5" />
<span>Duplicate</span>
</>
)}
</div>
</div>
</a>
</Link>
);

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect } from "react";
import { useRouter } from "next/router";
import Router, { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
@ -29,6 +29,7 @@ import {
ClockIcon,
DocumentDuplicateIcon,
ExclamationTriangleIcon,
InboxIcon,
XCircleIcon,
} from "@heroicons/react/24/outline";
// helpers
@ -37,6 +38,8 @@ import { renderShortNumericDateFormat } from "helpers/date-time.helper";
import type { IInboxIssue, IIssue } from "types";
// fetch-keys
import { INBOX_ISSUES, INBOX_ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// constants
import { INBOX_STATUS } from "constants/inbox";
const defaultValues = {
name: "",
@ -55,7 +58,7 @@ export const InboxMainContent: React.FC = () => {
const { user } = useUserAuth();
const { memberRole } = useProjectMyMembership();
const { params } = useInboxView();
const { params, issues: inboxIssues } = useInboxView();
const { reset, control, watch } = useForm<IIssue>({
defaultValues,
@ -76,17 +79,6 @@ export const InboxMainContent: React.FC = () => {
: null
);
useEffect(() => {
if (!issueDetails || !inboxIssueId) return;
reset({
...issueDetails,
assignees_list:
issueDetails.assignees_list ?? (issueDetails.assignee_details ?? []).map((user) => user.id),
labels_list: issueDetails.labels_list ?? issueDetails.labels,
});
}, [issueDetails, reset, inboxIssueId]);
const submitChanges = useCallback(
async (formData: Partial<IInboxIssue>) => {
if (!workspaceSlug || !projectId || !inboxIssueId || !inboxId || !issueDetails) return;
@ -144,7 +136,86 @@ export const InboxMainContent: React.FC = () => {
]
);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!inboxIssues || !inboxIssueId) return;
const currentIssueIndex = inboxIssues.findIndex((issue) => issue.bridge_id === inboxIssueId);
switch (e.key) {
case "ArrowUp":
Router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
query: {
inboxIssueId:
currentIssueIndex === 0
? inboxIssues[inboxIssues.length - 1].bridge_id
: inboxIssues[currentIssueIndex - 1].bridge_id,
},
});
break;
case "ArrowDown":
Router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
query: {
inboxIssueId:
currentIssueIndex === inboxIssues.length - 1
? inboxIssues[0].bridge_id
: inboxIssues[currentIssueIndex + 1].bridge_id,
},
});
break;
default:
break;
}
},
[workspaceSlug, projectId, inboxIssueId, inboxId, inboxIssues]
);
useEffect(() => {
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [onKeyDown]);
useEffect(() => {
if (!issueDetails || !inboxIssueId) return;
reset({
...issueDetails,
assignees_list:
issueDetails.assignees_list ?? (issueDetails.assignee_details ?? []).map((user) => user.id),
labels_list: issueDetails.labels_list ?? issueDetails.labels,
});
}, [issueDetails, reset, inboxIssueId]);
const issueStatus = issueDetails?.issue_inbox[0].status;
const inboxStatusDetails = INBOX_STATUS.find((s) => s.value === issueStatus);
if (!inboxIssueId)
return (
<div className="h-full p-4 grid place-items-center text-brand-secondary">
<div className="grid h-full place-items-center">
<div className="my-5 flex flex-col items-center gap-4">
<InboxIcon height={60} width={60} />
{inboxIssues && inboxIssues.length > 0 ? (
<span className="text-brand-secondary">
{inboxIssues?.length} issues found. Select an issue from the sidebar to view its
details.
</span>
) : (
<span className="text-brand-secondary">
No issues found. Use{" "}
<pre className="inline rounded bg-brand-surface-2 px-2 py-1">C</pre> shortcut to
create a new issue
</span>
)}
</div>
</div>
</div>
);
return (
<>
@ -153,19 +224,10 @@ export const InboxMainContent: React.FC = () => {
<div className="basis-2/3 h-full overflow-auto p-5 space-y-3">
<div
className={`flex items-center gap-2 p-3 text-sm border rounded-md ${
issueStatus === -2
? "text-orange-500 border-orange-500 bg-orange-500/10"
: issueStatus === -1
issueStatus === 0 &&
new Date(issueDetails.issue_inbox[0].snoozed_till ?? "") < new Date()
? "text-red-500 border-red-500 bg-red-500/10"
: issueStatus === 0
? new Date(issueDetails.issue_inbox[0].snoozed_till ?? "") < new Date()
? "text-red-500 border-red-500 bg-red-500/10"
: "text-blue-500 border-blue-500 bg-blue-500/10"
: issueStatus === 1
? "text-green-500 border-green-500 bg-green-500/10"
: issueStatus === 2
? "text-yellow-500 border-yellow-500 bg-yellow-500/10"
: ""
: `${inboxStatusDetails?.textColor} ${inboxStatusDetails?.bgColor} ${inboxStatusDetails?.borderColor}`
}`}
>
{issueStatus === -2 ? (
@ -266,6 +328,4 @@ export const InboxMainContent: React.FC = () => {
)}
</>
);
return null;
};

View File

@ -163,6 +163,8 @@ export const IssueForm: FC<IssueFormProps> = ({
const handleCreateUpdateIssue = async (formData: Partial<IIssue>) => {
await handleFormSubmit(formData);
setGptAssistantModal(false);
reset({
...defaultValues,
project: projectId,

View File

@ -188,6 +188,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
message: "Issue created successfully.",
});
router.push(
`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${res.issue_inbox[0].id}`
);
mutate(INBOX_ISSUES(inboxId.toString(), inboxParams));
})
.catch(() => {

View File

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

View File

@ -49,7 +49,7 @@ const WorkspacePage: NextPage = () => {
)}
<div className="p-8">
<div className="flex flex-col gap-8">
<div className="text-brand-muted-1 flex flex-col justify-between gap-x-2 gap-y-6 rounded-lg border border-brand-base bg-brand-base px-8 py-6 md:flex-row md:items-center md:py-3">
<div className="text-brand-muted-1 flex flex-col justify-between gap-x-2 gap-y-6 rounded-lg border border-brand-base bg-brand-base px-4 py-6 md:flex-row md:items-center md:py-3">
<p className="font-semibold">
Plane is open source, support us by starring us on GitHub.
</p>

View File

@ -1,29 +1,13 @@
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/router";
import Router, { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// services
import inboxServices from "services/inbox.service";
import projectService from "services/project.service";
// hooks
import useInboxView from "hooks/use-inbox-view";
import useUserAuth from "hooks/use-user-auth";
import useToast from "hooks/use-toast";
import useProjectDetails from "hooks/use-project-details";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// contexts
import { InboxViewContextProvider } from "contexts/inbox-view-context";
// components
import {
InboxActionHeader,
InboxMainContent,
SelectDuplicateInboxIssueModal,
DeclineIssueModal,
DeleteIssueModal,
IssuesListSidebar,
} from "components/inbox";
import { InboxActionHeader, InboxMainContent, IssuesListSidebar } from "components/inbox";
// helper
import { truncateText } from "helpers/string.helper";
// ui
@ -31,123 +15,14 @@ import { PrimaryButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
import { InboxIcon } from "components/icons";
// types
import { IInboxIssueDetail, TInboxStatus } from "types";
import type { NextPage } from "next";
// fetch-keys
import { INBOX_ISSUE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
const ProjectInbox: NextPage = () => {
const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false);
const [declineIssueModal, setDeclineIssueModal] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query;
const { workspaceSlug } = router.query;
const { user } = useUserAuth();
const { issues: inboxIssues, mutate: mutateInboxIssues } = useInboxView();
const { setToastAlert } = useToast();
const { data: projectDetails } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!inboxIssues || !inboxIssueId) return;
const currentIssueIndex = inboxIssues.findIndex((issue) => issue.bridge_id === inboxIssueId);
switch (e.key) {
case "ArrowUp":
Router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
query: {
inboxIssueId:
currentIssueIndex === 0
? inboxIssues[inboxIssues.length - 1].bridge_id
: inboxIssues[currentIssueIndex - 1].bridge_id,
},
});
break;
case "ArrowDown":
Router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
query: {
inboxIssueId:
currentIssueIndex === inboxIssues.length - 1
? inboxIssues[0].bridge_id
: inboxIssues[currentIssueIndex + 1].bridge_id,
},
});
break;
default:
break;
}
},
[workspaceSlug, projectId, inboxIssueId, inboxId, inboxIssues]
);
useEffect(() => {
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [onKeyDown]);
const markInboxStatus = async (data: TInboxStatus) => {
if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId) return;
mutate<IInboxIssueDetail>(
INBOX_ISSUE_DETAILS(inboxId as string, inboxIssueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
issue_inbox: [{ ...prevData.issue_inbox[0], ...data }],
};
},
false
);
mutateInboxIssues(
(prevData) =>
(prevData ?? []).map((i) =>
i.bridge_id === inboxIssueId
? { ...i, issue_inbox: [{ ...i.issue_inbox[0], ...data }] }
: i
),
false
);
await inboxServices
.markInboxStatus(
workspaceSlug.toString(),
projectId.toString(),
inboxId.toString(),
inboxIssues?.find((inboxIssue) => inboxIssue.bridge_id === inboxIssueId)?.bridge_id!,
data,
user
)
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong while updating inbox status. Please try again.",
})
)
.finally(() => {
mutate(INBOX_ISSUE_DETAILS(inboxId as string, inboxIssueId as string));
mutateInboxIssues();
});
};
const { projectDetails } = useProjectDetails();
return (
<InboxViewContextProvider>
@ -175,88 +50,15 @@ const ProjectInbox: NextPage = () => {
</div>
}
>
<>
<SelectDuplicateInboxIssueModal
isOpen={selectDuplicateIssue}
onClose={() => setSelectDuplicateIssue(false)}
value={
inboxIssues?.find((inboxIssue) => inboxIssue.bridge_id === inboxIssueId)
?.issue_inbox[0].duplicate_to
}
onSubmit={(dupIssueId: string) => {
markInboxStatus({
status: 2,
duplicate_to: dupIssueId,
}).finally(() => setSelectDuplicateIssue(false));
}}
/>
<DeclineIssueModal
isOpen={declineIssueModal}
handleClose={() => setDeclineIssueModal(false)}
data={inboxIssues?.find((i) => i.bridge_id === inboxIssueId)}
onSubmit={async () => {
await markInboxStatus({
status: -1,
}).finally(() => setDeclineIssueModal(false));
}}
/>
<DeleteIssueModal
isOpen={deleteIssueModal}
handleClose={() => setDeleteIssueModal(false)}
data={inboxIssues?.find((i) => i.bridge_id === inboxIssueId)}
/>
<div className="flex flex-col h-full">
<InboxActionHeader
issue={inboxIssues?.find((issue) => issue.bridge_id === inboxIssueId)}
currentIssueIndex={
inboxIssues?.findIndex((issue) => issue.bridge_id === inboxIssueId) ?? 0
}
issueCount={inboxIssues?.length ?? 0}
onAccept={() =>
markInboxStatus({
status: 1,
})
}
onDecline={() => setDeclineIssueModal(true)}
onMarkAsDuplicate={() => setSelectDuplicateIssue(true)}
onSnooze={(date) => {
markInboxStatus({
status: 0,
snoozed_till: new Date(date),
});
}}
onDelete={() => setDeleteIssueModal(true)}
/>
<div className="grid grid-cols-4 flex-1 divide-x divide-brand-base overflow-hidden">
<IssuesListSidebar />
<div className="col-span-3 h-full overflow-auto">
{inboxIssueId ? (
<InboxMainContent />
) : (
<div className="h-full p-4 grid place-items-center text-brand-secondary">
<div className="grid h-full place-items-center">
<div className="my-5 flex flex-col items-center gap-4">
<InboxIcon height={60} width={60} />
{inboxIssues && inboxIssues.length > 0 ? (
<span className="text-brand-secondary">
{inboxIssues?.length} issues found. Select an issue from the sidebar to
view its details.
</span>
) : (
<span className="text-brand-secondary">
No issues found. Use{" "}
<pre className="inline rounded bg-brand-surface-2 px-2 py-1">C</pre>{" "}
shortcut to create a new issue
</span>
)}
</div>
</div>
</div>
)}
</div>
<div className="flex flex-col h-full">
<InboxActionHeader />
<div className="grid grid-cols-4 flex-1 divide-x divide-brand-base overflow-hidden">
<IssuesListSidebar />
<div className="col-span-3 h-full overflow-auto">
<InboxMainContent />
</div>
</div>
</>
</div>
</ProjectAuthorizationWrapper>
</InboxViewContextProvider>
);

View File

@ -162,7 +162,7 @@ class InboxServices extends APIService {
inboxId: string,
data: any,
user: ICurrentUserResponse | undefined
): Promise<IInboxIssue> {
): Promise<IInboxIssueDetail> {
return this.post(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/`,
data