feat: inbox (#1023)

* dev: initialize inbox

* dev: inbox and inbox issues models, views and serializers

* dev: issue object filter for inbox

* dev: filter for search issues

* dev: inbox snooze and duplicates

* dev: set duplicate to null by default

* feat: inbox ui and services

* feat: project detail in inbox

* style: layout, popover, icons, sidebar

* dev: default inbox for project and pending issues count

* dev: fix exception when creating default inbox

* fix: empty state for inbox

* dev: auto issue state updation when rejected or marked duplicate

* fix: inbox update status

* fix: hydrating chose with old values

filters workflow

* feat: inbox issue filtering

* fix: issue inbox filtering

* feat: filter inbox issues

* refactor: analytics, border colors

* dev: filters and views for inbox

* dev: source for inboxissue and update list inbox issue

* dev: update list endpoint to house filters and additional data

* dev: bridge id for list

* dev: remove print logs

* dev: update inbox issue workflow

* dev: add description_html in issue details

* fix: inbox track event auth, chore: inbox issue action authorization

* fix: removed unnecessary api calls

* style: viewed issues

* fix: priority validation

* dev: remove print logs

* dev: update issue inbox update workflow

* chore: added inbox view context

* fix: type errors

* fix: build errors and warnings

* dev: update issue inbox workflow and log all the changes

* fix: filters logic, sidebar fields to show

* dev: update issue filtering status

* chore: update create inbox issue modal, fix: mutation issues

* dev: update issue accept workflow

* chore: add comment to inbox issues

* chore: remove inboxIssueId from url after deleting

* dev: update the issue triage workflow

* fix: mutation after issue status change

* chore: issue details sidebar divider

* fix: issue activity for inbox issues

* dev: update inbox perrmissions

* dev: create new permission layer

* chore: auth layer for inbox

* chore: show accepting status

* chore: show issue status at the top of issue details

---------

Co-authored-by: Dakshesh Jain <dakshesh.jain14@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
pablohashescobar 2023-06-16 18:57:17 +05:30 committed by GitHub
parent 963ccd808d
commit e9a0eb87cc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
71 changed files with 3712 additions and 737 deletions

View file

@ -0,0 +1,248 @@
import { useState, useEffect, useCallback } from "react";
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";
// 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";
// helper
import { truncateText } from "helpers/string.helper";
// ui
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 { user } = useUserAuth();
const { issues: inboxIssues, mutate: mutateInboxIssues } = useInboxView();
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;
await inboxServices
.markInboxStatus(
workspaceSlug.toString(),
projectId.toString(),
inboxId.toString(),
inboxIssues?.find((inboxIssue) => inboxIssue.bridge_id === inboxIssueId)?.bridge_id!,
data,
user
)
.then(() => {
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
);
});
};
return (
<InboxViewContextProvider>
<ProjectAuthorizationWrapper
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Projects" link={`/${workspaceSlug}/projects`} />
<BreadcrumbItem
title={`${truncateText(projectDetails?.name ?? "Project", 12)} Inbox`}
/>
</Breadcrumbs>
}
right={
<div className="flex items-center gap-2">
<PrimaryButton
className="flex items-center gap-2"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</PrimaryButton>
</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)}
/>
<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 overflow-auto divide-x divide-brand-base">
<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>
</div>
</>
</ProjectAuthorizationWrapper>
</InboxViewContextProvider>
);
};
export default ProjectInbox;

View file

@ -7,10 +7,12 @@ import useSWR, { mutate } from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// hooks
import useUserAuth from "hooks/use-user-auth";
// contexts
import { useProjectMyMembership } from "contexts/project-member.context";
// services
import issuesService from "services/issues.service";
// hooks
import useUserAuth from "hooks/use-user-auth";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// components
@ -53,6 +55,7 @@ const IssueDetailsPage: NextPage = () => {
const { workspaceSlug, projectId, issueId } = router.query;
const { user } = useUserAuth();
const { memberRole } = useProjectMyMembership();
const { data: issueDetails, mutate: mutateIssueDetails } = useSWR<IIssue | undefined>(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
@ -105,7 +108,7 @@ const IssueDetailsPage: NextPage = () => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId, mutateIssueDetails]
[workspaceSlug, issueId, projectId, mutateIssueDetails, user]
);
useEffect(() => {
@ -194,7 +197,11 @@ const IssueDetailsPage: NextPage = () => {
</CustomMenu>
</div>
) : null}
<IssueDescriptionForm issue={issueDetails} handleFormSubmit={submitChanges} />
<IssueDescriptionForm
issue={issueDetails}
handleFormSubmit={submitChanges}
isAllowed={memberRole.isMember || memberRole.isOwner}
/>
<div className="mt-2 space-y-2">
<SubIssuesList parentIssue={issueDetails} user={user} />
</div>
@ -208,8 +215,8 @@ const IssueDetailsPage: NextPage = () => {
</div>
<div className="space-y-5 pt-3">
<h3 className="text-lg text-brand-base">Comments/Activity</h3>
<IssueActivitySection user={user} />
<AddComment user={user} />
<IssueActivitySection issueId={issueId as string} user={user} />
<AddComment issueId={issueId as string} user={user} />
</div>
</div>
<div className="basis-1/3 space-y-5 border-l border-brand-base p-5">

View file

@ -1,11 +1,13 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import projectService from "services/project.service";
import inboxService from "services/inbox.service";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// contexts
@ -23,7 +25,7 @@ import { PlusIcon } from "@heroicons/react/24/outline";
// types
import type { NextPage } from "next";
// fetch-keys
import { PROJECT_DETAILS } from "constants/fetch-keys";
import { PROJECT_DETAILS, INBOX_LIST } from "constants/fetch-keys";
const ProjectIssues: NextPage = () => {
const [analyticsModal, setAnalyticsModal] = useState(false);
@ -38,6 +40,13 @@ const ProjectIssues: NextPage = () => {
: null
);
const { data: inboxList } = useSWR(
workspaceSlug && projectId ? INBOX_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => inboxService.getInboxes(workspaceSlug as string, projectId as string)
: null
);
return (
<IssueViewContextProvider>
<ProjectAuthorizationWrapper
@ -59,6 +68,23 @@ const ProjectIssues: NextPage = () => {
>
Analytics
</SecondaryButton>
{projectDetails && projectDetails.inbox_view && (
<Link href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}`}>
<a>
<SecondaryButton
className="relative !py-1.5 rounded-md font-normal text-brand-secondary"
outline
>
<span>Inbox</span>
{inboxList && inboxList?.[0]?.pending_issue_count !== 0 && (
<span className="absolute -top-1 -right-1 h-4 w-4 rounded-full text-brand-base bg-brand-surface-2 border border-brand-base">
{inboxList?.[0]?.pending_issue_count}
</span>
)}
</SecondaryButton>
</a>
</Link>
)}
<PrimaryButton
className="flex items-center gap-2"
onClick={() => {

View file

@ -18,7 +18,7 @@ import { SettingsHeader } from "components/project";
import { SecondaryButton, ToggleSwitch } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons
import { ContrastIcon, PeopleGroupIcon, ViewListIcon } from "components/icons";
import { ContrastIcon, PeopleGroupIcon, ViewListIcon, InboxIcon } from "components/icons";
import { DocumentTextIcon } from "@heroicons/react/24/outline";
// types
import { IFavoriteProject, IProject } from "types";
@ -55,6 +55,13 @@ const featuresList = [
icon: <DocumentTextIcon color="#fcbe1d" width={28} height={28} className="flex-shrink-0" />,
property: "page_view",
},
{
title: "Inbox",
description:
"Inbox are enabled for all the projects in this workspace. Access it from the issues views page.",
icon: <InboxIcon color="#fcbe1d" width={24} height={24} className="flex-shrink-0" />,
property: "inbox_view",
},
];
const getEventType = (feature: string, toggle: boolean): MiscellaneousEventType => {
@ -67,8 +74,10 @@ const getEventType = (feature: string, toggle: boolean): MiscellaneousEventType
return toggle ? "TOGGLE_VIEW_ON" : "TOGGLE_VIEW_OFF";
case "Pages":
return toggle ? "TOGGLE_PAGES_ON" : "TOGGLE_PAGES_OFF";
case "Inbox":
return toggle ? "TOGGLE_INBOX_ON" : "TOGGLE_INBOX_OFF";
default:
return toggle ? "TOGGLE_PAGES_ON" : "TOGGLE_PAGES_OFF";
throw new Error("Invalid feature");
}
};
@ -195,9 +204,10 @@ const FeaturesSettings: NextPage = () => {
projectIdentifier: projectDetails?.identifier,
projectName: projectDetails?.name,
},
!projectDetails?.[feature.property as keyof IProject]
? getEventType(feature.title, true)
: getEventType(feature.title, false),
getEventType(
feature.title,
!projectDetails?.[feature.property as keyof IProject]
),
user
);
handleSubmit({