refactor: notification pagination & made context for handling functions related to notification (#1654)

* refactor: add pagination and notification context

* refactor: moved header & snooze option to their own files

added check before toUpperCase function
This commit is contained in:
Dakshesh Jain 2023-07-26 12:02:14 +05:30 committed by GitHub
parent 10059b2150
commit 9c28011ea5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 847 additions and 283 deletions

View File

@ -1,3 +1,4 @@
export * from "./notification-card";
export * from "./notification-popover";
export * from "./select-snooze-till-modal";
export * from "./notification-header";

View File

@ -21,6 +21,8 @@ import {
// type
import type { IUserNotification } from "types";
// constants
import { snoozeOptions } from "constants/notification";
type NotificationCardProps = {
notification: IUserNotification;
@ -30,33 +32,6 @@ type NotificationCardProps = {
markSnoozeNotification: (notificationId: string, dateTime?: Date | undefined) => Promise<void>;
};
const snoozeOptions = [
{
label: "1 day",
value: new Date(new Date().getTime() + 24 * 60 * 60 * 1000),
},
{
label: "3 days",
value: new Date(new Date().getTime() + 3 * 24 * 60 * 60 * 1000),
},
{
label: "5 days",
value: new Date(new Date().getTime() + 5 * 24 * 60 * 60 * 1000),
},
{
label: "1 week",
value: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000),
},
{
label: "2 weeks",
value: new Date(new Date().getTime() + 14 * 24 * 60 * 60 * 1000),
},
{
label: "Custom",
value: null,
},
];
export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
const {
notification,
@ -101,7 +76,11 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
) : (
<div className="w-12 h-12 bg-custom-background-80 rounded-full flex justify-center items-center">
<span className="text-custom-text-100 font-medium text-lg">
{notification.triggered_by_details.first_name[0].toUpperCase()}
{notification.triggered_by_details.first_name?.[0] ? (
notification.triggered_by_details.first_name?.[0]?.toUpperCase()
) : (
<Icon iconName="person" className="h-6 w-6" />
)}
</span>
</div>
)}

View File

@ -0,0 +1,211 @@
import React from "react";
import { useRouter } from "next/router";
// hooks
import useWorkspaceMembers from "hooks/use-workspace-members";
// components
import { Icon, Tooltip } from "components/ui";
// helpers
import { getNumberCount } from "helpers/string.helper";
// type
import type { NotificationType, NotificationCount } from "types";
type NotificationHeaderProps = {
notificationCount?: NotificationCount | null;
notificationMutate: () => void;
closePopover: () => void;
isRefreshing?: boolean;
snoozed: boolean;
archived: boolean;
readNotification: boolean;
selectedTab: NotificationType;
setSnoozed: React.Dispatch<React.SetStateAction<boolean>>;
setArchived: React.Dispatch<React.SetStateAction<boolean>>;
setReadNotification: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedTab: React.Dispatch<React.SetStateAction<NotificationType>>;
};
export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) => {
const {
notificationCount,
notificationMutate,
closePopover,
isRefreshing,
snoozed,
archived,
readNotification,
selectedTab,
setSnoozed,
setArchived,
setReadNotification,
setSelectedTab,
} = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { isOwner, isMember } = useWorkspaceMembers(workspaceSlug?.toString() ?? "");
const notificationTabs: Array<{
label: string;
value: NotificationType;
unreadCount?: number;
}> = [
{
label: "My Issues",
value: "assigned",
unreadCount: notificationCount?.my_issues,
},
{
label: "Created by me",
value: "created",
unreadCount: notificationCount?.created_issues,
},
{
label: "Subscribed",
value: "watching",
unreadCount: notificationCount?.watching_issues,
},
];
return (
<>
<div className="flex items-center justify-between px-5 pt-5">
<h2 className="text-xl font-semibold mb-2">Notifications</h2>
<div className="flex gap-x-4 justify-center items-center text-custom-text-200">
<Tooltip tooltipContent="Refresh">
<button
type="button"
onClick={() => {
notificationMutate();
}}
>
<Icon iconName="refresh" className={`${isRefreshing ? "animate-spin" : ""}`} />
</button>
</Tooltip>
<Tooltip tooltipContent="Unread notifications">
<button
type="button"
onClick={() => {
setSnoozed(false);
setArchived(false);
setReadNotification((prev) => !prev);
}}
>
<Icon iconName="filter_list" />
</button>
</Tooltip>
<Tooltip tooltipContent="Snoozed notifications">
<button
type="button"
onClick={() => {
setArchived(false);
setReadNotification(false);
setSnoozed((prev) => !prev);
}}
>
<Icon iconName="schedule" />
</button>
</Tooltip>
<Tooltip tooltipContent="Archived notifications">
<button
type="button"
onClick={() => {
setSnoozed(false);
setReadNotification(false);
setArchived((prev) => !prev);
}}
>
<Icon iconName="archive" />
</button>
</Tooltip>
<button type="button" onClick={() => closePopover()}>
<Icon iconName="close" />
</button>
</div>
</div>
<div className="border-b border-custom-border-300 w-full px-5 mt-5">
{snoozed || archived || readNotification ? (
<button
type="button"
onClick={() => {
setSnoozed(false);
setArchived(false);
setReadNotification(false);
}}
>
<h4 className="flex items-center gap-2 pb-4">
<Icon iconName="arrow_back" />
<span className="ml-2 font-medium">
{snoozed
? "Snoozed Notifications"
: readNotification
? "Unread Notifications"
: "Archived Notifications"}
</span>
</h4>
</button>
) : (
<nav className="flex space-x-5 overflow-x-auto" aria-label="Tabs">
{notificationTabs.map((tab) =>
tab.value === "created" ? (
isMember || isOwner ? (
<button
type="button"
key={tab.value}
onClick={() => setSelectedTab(tab.value)}
className={`whitespace-nowrap border-b-2 pb-4 px-1 text-sm font-medium outline-none ${
tab.value === selectedTab
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent text-custom-text-200"
}`}
>
{tab.label}
{tab.unreadCount && tab.unreadCount > 0 ? (
<span
className={`ml-2 rounded-full text-xs px-2 py-0.5 ${
tab.value === selectedTab
? "bg-custom-primary-100 text-white"
: "bg-custom-background-80 text-custom-text-200"
}`}
>
{getNumberCount(tab.unreadCount)}
</span>
) : null}
</button>
) : null
) : (
<button
type="button"
key={tab.value}
onClick={() => setSelectedTab(tab.value)}
className={`whitespace-nowrap border-b-2 pb-4 px-1 text-sm font-medium ${
tab.value === selectedTab
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent text-custom-text-200"
}`}
>
{tab.label}
{tab.unreadCount && tab.unreadCount > 0 ? (
<span
className={`ml-2 rounded-full text-xs px-2 py-0.5 ${
tab.value === selectedTab
? "bg-custom-primary-100 text-white"
: "bg-custom-background-80 text-custom-text-200"
}`}
>
{getNumberCount(tab.unreadCount)}
</span>
) : null}
</button>
)
)}
</nav>
)}
</div>
</>
);
};

View File

@ -1,19 +1,20 @@
import React, { Fragment } from "react";
import { useRouter } from "next/router";
// hooks
import useTheme from "hooks/use-theme";
import { Popover, Transition } from "@headlessui/react";
// hooks
import useWorkspaceMembers from "hooks/use-workspace-members";
import useUserNotification from "hooks/use-user-notifications";
// components
import { Icon, Loader, EmptyState, Tooltip } from "components/ui";
import { SnoozeNotificationModal, NotificationCard } from "components/notifications";
import { Loader, EmptyState, Tooltip } from "components/ui";
import {
SnoozeNotificationModal,
NotificationCard,
NotificationHeader,
} from "components/notifications";
// icons
import { NotificationsOutlined } from "@mui/icons-material";
// images
@ -21,9 +22,6 @@ import emptyNotification from "public/empty-state/notification.svg";
// helpers
import { getNumberCount } from "helpers/string.helper";
// type
import type { NotificationType } from "types";
export const NotificationPopover = () => {
const {
notifications,
@ -37,44 +35,21 @@ export const NotificationPopover = () => {
setSelectedTab,
setSnoozed,
snoozed,
notificationsMutate,
notificationMutate,
markNotificationArchivedStatus,
markNotificationReadStatus,
markSnoozeNotification,
notificationCount,
totalNotificationCount,
setSize,
isLoadingMore,
hasMore,
isRefreshing,
} = useUserNotification();
const router = useRouter();
const { workspaceSlug } = router.query;
const { isOwner, isMember } = useWorkspaceMembers(workspaceSlug?.toString() ?? "");
// theme context
const { collapsed: sidebarCollapse } = useTheme();
const notificationTabs: Array<{
label: string;
value: NotificationType;
unreadCount?: number;
}> = [
{
label: "My Issues",
value: "assigned",
unreadCount: notificationCount?.my_issues,
},
{
label: "Created by me",
value: "created",
unreadCount: notificationCount?.created_issues,
},
{
label: "Subscribed",
value: "watching",
unreadCount: notificationCount?.watching_issues,
},
];
return (
<>
<SnoozeNotificationModal
@ -87,7 +62,6 @@ export const NotificationPopover = () => {
) || null
}
onSuccess={() => {
notificationsMutate();
setSelectedNotificationForSnooze(null);
}}
/>
@ -126,159 +100,72 @@ export const NotificationPopover = () => {
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute bg-custom-background-100 flex flex-col left-0 md:left-full ml-8 z-10 top-0 md:w-[36rem] w-[20rem] h-[50vh] border border-custom-border-300 shadow-lg rounded-xl">
<div className="flex items-center justify-between px-5 pt-5">
<h2 className="text-xl font-semibold mb-2">Notifications</h2>
<div className="flex gap-x-4 justify-center items-center text-custom-text-200">
<Tooltip tooltipContent="Refresh">
<button
type="button"
onClick={(e) => {
notificationsMutate();
const target = e.target as HTMLButtonElement;
target?.classList.add("animate-spin");
setTimeout(() => {
target?.classList.remove("animate-spin");
}, 1000);
}}
>
<Icon iconName="refresh" />
</button>
</Tooltip>
<Tooltip tooltipContent="Unread notifications">
<button
type="button"
onClick={() => {
setSnoozed(false);
setArchived(false);
setReadNotification((prev) => !prev);
}}
>
<Icon iconName="filter_list" />
</button>
</Tooltip>
<Tooltip tooltipContent="Snoozed notifications">
<button
type="button"
onClick={() => {
setArchived(false);
setReadNotification(false);
setSnoozed((prev) => !prev);
}}
>
<Icon iconName="schedule" />
</button>
</Tooltip>
<Tooltip tooltipContent="Archived notifications">
<button
type="button"
onClick={() => {
setSnoozed(false);
setReadNotification(false);
setArchived((prev) => !prev);
}}
>
<Icon iconName="archive" />
</button>
</Tooltip>
<button type="button" onClick={() => closePopover()}>
<Icon iconName="close" />
</button>
</div>
</div>
<div className="border-b border-custom-border-300 w-full px-5 mt-5">
{snoozed || archived || readNotification ? (
<button
type="button"
onClick={() => {
setSnoozed(false);
setArchived(false);
setReadNotification(false);
}}
>
<h4 className="flex items-center gap-2 pb-4">
<Icon iconName="arrow_back" />
<span className="ml-2 font-medium">
{snoozed
? "Snoozed Notifications"
: readNotification
? "Unread Notifications"
: "Archived Notifications"}
</span>
</h4>
</button>
) : (
<nav className="flex space-x-5 overflow-x-auto" aria-label="Tabs">
{notificationTabs.map((tab) =>
tab.value === "created" ? (
isMember || isOwner ? (
<button
type="button"
key={tab.value}
onClick={() => setSelectedTab(tab.value)}
className={`whitespace-nowrap border-b-2 pb-4 px-1 text-sm font-medium outline-none ${
tab.value === selectedTab
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent text-custom-text-200"
}`}
>
{tab.label}
{tab.unreadCount && tab.unreadCount > 0 ? (
<span
className={`ml-2 rounded-full text-xs px-2 py-0.5 ${
tab.value === selectedTab
? "bg-custom-primary-100 text-white"
: "bg-custom-background-80 text-custom-text-200"
}`}
>
{getNumberCount(tab.unreadCount)}
</span>
) : null}
</button>
) : null
) : (
<button
type="button"
key={tab.value}
onClick={() => setSelectedTab(tab.value)}
className={`whitespace-nowrap border-b-2 pb-4 px-1 text-sm font-medium ${
tab.value === selectedTab
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent text-custom-text-200"
}`}
>
{tab.label}
{tab.unreadCount && tab.unreadCount > 0 ? (
<span
className={`ml-2 rounded-full text-xs px-2 py-0.5 ${
tab.value === selectedTab
? "bg-custom-primary-100 text-white"
: "bg-custom-background-80 text-custom-text-200"
}`}
>
{getNumberCount(tab.unreadCount)}
</span>
) : null}
</button>
)
)}
</nav>
)}
</div>
<NotificationHeader
notificationCount={notificationCount}
notificationMutate={notificationMutate}
closePopover={closePopover}
isRefreshing={isRefreshing}
snoozed={snoozed}
archived={archived}
readNotification={readNotification}
selectedTab={selectedTab}
setSnoozed={setSnoozed}
setArchived={setArchived}
setReadNotification={setReadNotification}
setSelectedTab={setSelectedTab}
/>
{notifications ? (
notifications.length > 0 ? (
<div className="divide-y divide-custom-border-100 overflow-y-auto h-full">
{notifications.map((notification) => (
<NotificationCard
key={notification.id}
notification={notification}
markNotificationArchivedStatus={markNotificationArchivedStatus}
markNotificationReadStatus={markNotificationReadStatus}
setSelectedNotificationForSnooze={setSelectedNotificationForSnooze}
markSnoozeNotification={markSnoozeNotification}
/>
))}
<div className="h-full overflow-y-auto">
<div className="divide-y divide-custom-border-100">
{notifications.map((notification) => (
<NotificationCard
key={notification.id}
notification={notification}
markNotificationArchivedStatus={markNotificationArchivedStatus}
markNotificationReadStatus={markNotificationReadStatus}
setSelectedNotificationForSnooze={setSelectedNotificationForSnooze}
markSnoozeNotification={markSnoozeNotification}
/>
))}
</div>
{isLoadingMore && (
<div className="mt-6 flex justify-center items-center text-sm">
<div role="status">
<svg
aria-hidden="true"
className="mr-2 h-6 w-6 animate-spin fill-blue-600 text-custom-text-200"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
<p>Loading notifications</p>
</div>
)}
{hasMore && !isLoadingMore && (
<button
type="button"
className="text-custom-primary-100 mt-6 flex justify-center items-center w-full text-sm font-medium"
disabled={isLoadingMore}
onClick={() => {
setSize((prev) => prev + 1);
}}
>
Load More
</button>
)}
</div>
) : (
<div className="grid h-full w-full place-items-center overflow-hidden scale-75">

View File

@ -1,3 +1,4 @@
import { objToQueryParams } from "helpers/string.helper";
import { IAnalyticsParams, IJiraMetadata, INotificationParams } from "types";
const paramsToKey = (params: any) => {
@ -230,16 +231,42 @@ export const USER_WORKSPACE_NOTIFICATIONS = (
) => {
const { type, snoozed, archived, read } = params;
return `USER_WORKSPACE_NOTIFICATIONS_${workspaceSlug.toUpperCase()}_TYPE_${(
return `USER_WORKSPACE_NOTIFICATIONS_${workspaceSlug?.toUpperCase()}_TYPE_${(
type ?? "assigned"
).toUpperCase()}_SNOOZED_${snoozed}_ARCHIVED_${archived}_READ_${read}`;
)?.toUpperCase()}_SNOOZED_${snoozed}_ARCHIVED_${archived}_READ_${read}`;
};
export const USER_WORKSPACE_NOTIFICATIONS_DETAILS = (
workspaceSlug: string,
notificationId: string
) =>
`USER_WORKSPACE_NOTIFICATIONS_DETAILS_${workspaceSlug.toUpperCase()}_${notificationId.toUpperCase()}`;
`USER_WORKSPACE_NOTIFICATIONS_DETAILS_${workspaceSlug?.toUpperCase()}_${notificationId?.toUpperCase()}`;
export const UNREAD_NOTIFICATIONS_COUNT = (workspaceSlug: string) =>
`UNREAD_NOTIFICATIONS_COUNT_${workspaceSlug.toUpperCase()}`;
`UNREAD_NOTIFICATIONS_COUNT_${workspaceSlug?.toUpperCase()}`;
export const getPaginatedNotificationKey = (
index: number,
prevData: any,
workspaceSlug: string,
params: any
) => {
if (prevData && !prevData?.results?.length) return null;
if (index === 0)
return `/api/workspaces/${workspaceSlug}/users/notifications?${objToQueryParams({
...params,
// TODO: change to '100:0:0'
cursor: "2:0:0",
})}`;
const cursor = prevData?.next_cursor;
const nextPageResults = prevData?.next_page_results;
if (!nextPageResults) return null;
return `/api/workspaces/${workspaceSlug}/users/notifications?${objToQueryParams({
...params,
cursor,
})}`;
};

View File

@ -0,0 +1,26 @@
export const snoozeOptions = [
{
label: "1 day",
value: new Date(new Date().getTime() + 24 * 60 * 60 * 1000),
},
{
label: "3 days",
value: new Date(new Date().getTime() + 3 * 24 * 60 * 60 * 1000),
},
{
label: "5 days",
value: new Date(new Date().getTime() + 5 * 24 * 60 * 60 * 1000),
},
{
label: "1 week",
value: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000),
},
{
label: "2 weeks",
value: new Date(new Date().getTime() + 14 * 24 * 60 * 60 * 1000),
},
{
label: "Custom",
value: null,
},
];

View File

@ -0,0 +1,308 @@
import { createContext, useCallback, useEffect, useReducer } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import userNotificationServices from "services/notifications.service";
// fetch-keys
import { UNREAD_NOTIFICATIONS_COUNT, USER_WORKSPACE_NOTIFICATIONS } from "constants/fetch-keys";
// type
import type { NotificationType, NotificationCount, IUserNotification } from "types";
export const userNotificationContext = createContext<ContextType>({} as ContextType);
type UserNotificationProps = {
selectedTab: NotificationType;
snoozed: boolean;
archived: boolean;
readNotification: boolean;
selectedNotificationForSnooze: string | null;
};
type ReducerActionType = {
type:
| "READ_NOTIFICATION_COUNT"
| "SET_SELECTED_TAB"
| "SET_SNOOZED"
| "SET_ARCHIVED"
| "SET_READ_NOTIFICATION"
| "SET_SELECTED_NOTIFICATION_FOR_SNOOZE"
| "SET_NOTIFICATIONS";
payload?: Partial<ContextType>;
};
type ContextType = UserNotificationProps & {
notifications?: IUserNotification[];
notificationCount?: NotificationCount | null;
setSelectedTab: (tab: NotificationType) => void;
setSnoozed: (snoozed: boolean) => void;
setArchived: (archived: boolean) => void;
setReadNotification: (readNotification: boolean) => void;
setSelectedNotificationForSnooze: (notificationId: string | null) => void;
markNotificationReadStatus: (notificationId: string) => void;
markNotificationArchivedStatus: (notificationId: string) => void;
markSnoozeNotification: (notificationId: string, dateTime?: Date) => void;
};
type StateType = {
selectedTab: NotificationType;
snoozed: boolean;
archived: boolean;
readNotification: boolean;
selectedNotificationForSnooze: string | null;
};
type ReducerFunctionType = (state: StateType, action: ReducerActionType) => StateType;
export const initialState: StateType = {
selectedTab: "assigned",
snoozed: false,
archived: false,
readNotification: false,
selectedNotificationForSnooze: null,
};
export const reducer: ReducerFunctionType = (state, action) => {
const { type, payload } = action;
switch (type) {
case "READ_NOTIFICATION_COUNT":
case "SET_SELECTED_TAB":
case "SET_SNOOZED":
case "SET_ARCHIVED":
case "SET_READ_NOTIFICATION":
case "SET_SELECTED_NOTIFICATION_FOR_SNOOZE":
case "SET_NOTIFICATIONS": {
return { ...state, ...payload };
}
default:
return state;
}
};
const UserNotificationContextProvider: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const [state, dispatch] = useReducer(reducer, initialState);
const { selectedTab, snoozed, archived, readNotification, selectedNotificationForSnooze } = state;
const params = {
type: snoozed || archived || readNotification ? undefined : selectedTab,
snoozed,
archived,
read: !readNotification ? undefined : false,
};
const { data: notifications, mutate: notificationsMutate } = useSWR(
workspaceSlug ? USER_WORKSPACE_NOTIFICATIONS(workspaceSlug.toString(), params) : null,
workspaceSlug
? () => userNotificationServices.getUserNotifications(workspaceSlug.toString(), params)
: null
);
const { data: notificationCount, mutate: mutateNotificationCount } = useSWR(
workspaceSlug ? UNREAD_NOTIFICATIONS_COUNT(workspaceSlug.toString()) : null,
() =>
workspaceSlug
? userNotificationServices.getUnreadNotificationsCount(workspaceSlug.toString())
: null
);
const handleReadMutation = (action: "read" | "unread") => {
const notificationCountNumber = action === "read" ? -1 : 1;
mutateNotificationCount((prev) => {
if (!prev) return prev;
const notificationType: keyof NotificationCount =
selectedTab === "assigned"
? "my_issues"
: selectedTab === "created"
? "created_issues"
: "watching_issues";
return {
...prev,
[notificationType]: prev[notificationType] + notificationCountNumber,
};
}, false);
};
const markNotificationReadStatus = async (notificationId: string) => {
if (!workspaceSlug) return;
const isRead =
notifications?.find((notification) => notification.id === notificationId)?.read_at !== null;
notificationsMutate(
(previousNotifications) =>
previousNotifications?.map((notification) =>
notification.id === notificationId
? { ...notification, read_at: isRead ? null : new Date() }
: notification
),
false
);
handleReadMutation(isRead ? "unread" : "read");
if (isRead) {
await userNotificationServices
.markUserNotificationAsUnread(workspaceSlug.toString(), notificationId)
.catch(() => {
throw new Error("Something went wrong");
})
.finally(() => {
notificationsMutate();
mutateNotificationCount();
});
} else {
await userNotificationServices
.markUserNotificationAsRead(workspaceSlug.toString(), notificationId)
.catch(() => {
throw new Error("Something went wrong");
})
.finally(() => {
notificationsMutate();
mutateNotificationCount();
});
}
};
const markNotificationArchivedStatus = async (notificationId: string) => {
if (!workspaceSlug) return;
const isArchived =
notifications?.find((notification) => notification.id === notificationId)?.archived_at !==
null;
if (!isArchived) {
handleReadMutation("read");
}
if (isArchived) {
await userNotificationServices
.markUserNotificationAsUnarchived(workspaceSlug.toString(), notificationId)
.catch(() => {
throw new Error("Something went wrong");
})
.finally(() => {
notificationsMutate();
mutateNotificationCount();
});
} else {
notificationsMutate(
(prev) => prev?.filter((prevNotification) => prevNotification.id !== notificationId),
false
);
await userNotificationServices
.markUserNotificationAsArchived(workspaceSlug.toString(), notificationId)
.catch(() => {
throw new Error("Something went wrong");
})
.finally(() => {
notificationsMutate();
mutateNotificationCount();
});
}
};
const markSnoozeNotification = async (notificationId: string, dateTime?: Date) => {
if (!workspaceSlug) return;
const isSnoozed =
notifications?.find((notification) => notification.id === notificationId)?.snoozed_till !==
null;
notificationsMutate(
(previousNotifications) =>
previousNotifications?.map((notification) =>
notification.id === notificationId
? { ...notification, snoozed_till: isSnoozed ? null : new Date(dateTime!) }
: notification
) || [],
false
);
if (isSnoozed) {
await userNotificationServices
.patchUserNotification(workspaceSlug.toString(), notificationId, {
snoozed_till: null,
})
.finally(() => {
notificationsMutate();
});
} else {
await userNotificationServices
.patchUserNotification(workspaceSlug.toString(), notificationId, {
snoozed_till: dateTime,
})
.catch(() => {
new Error("Something went wrong");
})
.finally(() => {
notificationsMutate();
});
}
};
const setSelectedTab = useCallback((tab: NotificationType) => {
dispatch({ type: "SET_SELECTED_TAB", payload: { selectedTab: tab } });
}, []);
const setSnoozed = useCallback((snoozed: boolean) => {
dispatch({ type: "SET_SNOOZED", payload: { snoozed } });
}, []);
const setArchived = useCallback((archived: boolean) => {
dispatch({ type: "SET_ARCHIVED", payload: { archived } });
}, []);
const setReadNotification = useCallback((readNotification: boolean) => {
dispatch({ type: "SET_READ_NOTIFICATION", payload: { readNotification } });
}, []);
const setSelectedNotificationForSnooze = useCallback((notificationId: string | null) => {
dispatch({
type: "SET_SELECTED_NOTIFICATION_FOR_SNOOZE",
payload: { selectedNotificationForSnooze: notificationId },
});
}, []);
useEffect(() => {
dispatch({ type: "SET_NOTIFICATIONS", payload: { notifications } });
}, [notifications]);
useEffect(() => {
dispatch({ type: "READ_NOTIFICATION_COUNT", payload: { notificationCount } });
}, [notificationCount]);
return (
<userNotificationContext.Provider
value={{
...state,
notifications,
notificationCount,
setSelectedTab,
setSnoozed,
setArchived,
setReadNotification,
setSelectedNotificationForSnooze,
markNotificationReadStatus,
markNotificationArchivedStatus,
markSnoozeNotification,
}}
>
{children}
</userNotificationContext.Provider>
);
};
export default UserNotificationContextProvider;

View File

@ -112,3 +112,13 @@ export const getNumberCount = (number: number): string => {
}
return number.toString();
};
export const objToQueryParams = (obj: any) => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined && value !== null) params.append(key, value as string);
}
return params.toString();
};

View File

@ -1,18 +1,22 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
// services
import userNotificationServices from "services/notifications.service";
// fetch-keys
import { UNREAD_NOTIFICATIONS_COUNT, USER_WORKSPACE_NOTIFICATIONS } from "constants/fetch-keys";
import { UNREAD_NOTIFICATIONS_COUNT, getPaginatedNotificationKey } from "constants/fetch-keys";
// type
import type { NotificationType } from "types";
import type { NotificationType, NotificationCount } from "types";
// TODO: change to 100
const PER_PAGE = 2;
const useUserNotification = () => {
const router = useRouter();
@ -26,20 +30,40 @@ const useUserNotification = () => {
);
const [selectedTab, setSelectedTab] = useState<NotificationType>("assigned");
const params = {
type: snoozed || archived || readNotification ? undefined : selectedTab,
snoozed,
archived,
read: !readNotification ? undefined : false,
};
const { data: notifications, mutate: notificationsMutate } = useSWR(
workspaceSlug ? USER_WORKSPACE_NOTIFICATIONS(workspaceSlug.toString(), params) : null,
workspaceSlug
? () => userNotificationServices.getUserNotifications(workspaceSlug.toString(), params)
: null
const params = useMemo(
() => ({
type: snoozed || archived || readNotification ? undefined : selectedTab,
snoozed,
archived,
read: !readNotification ? null : false,
per_page: PER_PAGE,
}),
[archived, readNotification, selectedTab, snoozed]
);
const {
data: paginatedData,
size,
setSize,
isLoading,
isValidating,
mutate: notificationMutate,
} = useSWRInfinite(
workspaceSlug
? (index, prevData) =>
getPaginatedNotificationKey(index, prevData, workspaceSlug.toString(), params)
: () => null,
async (url: string) => await userNotificationServices.getNotifications(url)
);
const isLoadingMore =
isLoading || (size > 0 && paginatedData && typeof paginatedData[size - 1] === "undefined");
const isEmpty = paginatedData?.[0]?.results?.length === 0;
const notifications = paginatedData ? paginatedData.map((d) => d.results).flat() : undefined;
const hasMore =
isEmpty || (paginatedData && paginatedData[paginatedData.length - 1].next_page_results);
const isRefreshing = isValidating && paginatedData && paginatedData.length === size;
const { data: notificationCount, mutate: mutateNotificationCount } = useSWR(
workspaceSlug ? UNREAD_NOTIFICATIONS_COUNT(workspaceSlug.toString()) : null,
() =>
@ -48,55 +72,114 @@ const useUserNotification = () => {
: null
);
const handleReadMutation = (action: "read" | "unread") => {
const notificationCountNumber = action === "read" ? -1 : 1;
mutateNotificationCount((prev) => {
if (!prev) return prev;
const notificationType: keyof NotificationCount =
selectedTab === "assigned"
? "my_issues"
: selectedTab === "created"
? "created_issues"
: "watching_issues";
return {
...prev,
[notificationType]: prev[notificationType] + notificationCountNumber,
};
}, false);
};
const mutateNotification = (notificationId: string, value: Object) => {
notificationMutate((previousNotifications) => {
if (!previousNotifications) return previousNotifications;
const notificationIndex = Math.floor(
previousNotifications
.map((d) => d.results)
.flat()
.findIndex((notification) => notification.id === notificationId) / PER_PAGE
);
let notificationIndexInPage = previousNotifications[notificationIndex].results.findIndex(
(notification) => notification.id === notificationId
);
if (notificationIndexInPage === -1) return previousNotifications;
notificationIndexInPage =
notificationIndexInPage === -1 ? 0 : notificationIndexInPage % PER_PAGE;
if (notificationIndex === -1) return previousNotifications;
if (notificationIndexInPage === -1) return previousNotifications;
const key = Object.keys(value)[0];
(previousNotifications[notificationIndex].results[notificationIndexInPage] as any)[key] = (
value as any
)[key];
return previousNotifications;
}, false);
};
const removeNotification = (notificationId: string) => {
notificationMutate((previousNotifications) => {
if (!previousNotifications) return previousNotifications;
const notificationIndex = Math.floor(
previousNotifications
.map((d) => d.results)
.flat()
.findIndex((notification) => notification.id === notificationId) / PER_PAGE
);
let notificationIndexInPage = previousNotifications[notificationIndex].results.findIndex(
(notification) => notification.id === notificationId
);
if (notificationIndexInPage === -1) return previousNotifications;
notificationIndexInPage =
notificationIndexInPage === -1 ? 0 : notificationIndexInPage % PER_PAGE;
if (notificationIndex === -1) return previousNotifications;
if (notificationIndexInPage === -1) return previousNotifications;
previousNotifications[notificationIndex].results.splice(notificationIndexInPage, 1);
return previousNotifications;
}, false);
};
const markNotificationReadStatus = async (notificationId: string) => {
if (!workspaceSlug) return;
const isRead =
notifications?.find((notification) => notification.id === notificationId)?.read_at !== null;
handleReadMutation(isRead ? "unread" : "read");
mutateNotification(notificationId, { read_at: isRead ? null : new Date() });
if (isRead) {
notificationsMutate(
(prev) =>
prev?.map((prevNotification) => {
if (prevNotification.id === notificationId) {
return {
...prevNotification,
read_at: null,
};
}
return prevNotification;
}),
false
);
await userNotificationServices
.markUserNotificationAsUnread(workspaceSlug.toString(), notificationId)
.catch(() => {
throw new Error("Something went wrong");
})
.finally(() => {
notificationsMutate();
mutateNotificationCount();
});
} else {
notificationsMutate(
(prev) =>
prev?.map((prevNotification) => {
if (prevNotification.id === notificationId) {
return {
...prevNotification,
read_at: new Date(),
};
}
return prevNotification;
}),
false
);
await userNotificationServices
.markUserNotificationAsRead(workspaceSlug.toString(), notificationId)
.catch(() => {
throw new Error("Something went wrong");
})
.finally(() => {
notificationsMutate();
mutateNotificationCount();
});
}
@ -108,6 +191,15 @@ const useUserNotification = () => {
notifications?.find((notification) => notification.id === notificationId)?.archived_at !==
null;
if (!isArchived) {
handleReadMutation("read");
removeNotification(notificationId);
} else {
if (archived) {
removeNotification(notificationId);
}
}
if (isArchived) {
await userNotificationServices
.markUserNotificationAsUnarchived(workspaceSlug.toString(), notificationId)
@ -115,21 +207,17 @@ const useUserNotification = () => {
throw new Error("Something went wrong");
})
.finally(() => {
notificationsMutate();
notificationMutate();
mutateNotificationCount();
});
} else {
notificationsMutate(
(prev) => prev?.filter((prevNotification) => prevNotification.id !== notificationId),
false
);
await userNotificationServices
.markUserNotificationAsArchived(workspaceSlug.toString(), notificationId)
.catch(() => {
throw new Error("Something went wrong");
})
.finally(() => {
notificationsMutate();
notificationMutate();
mutateNotificationCount();
});
}
@ -142,20 +230,17 @@ const useUserNotification = () => {
notifications?.find((notification) => notification.id === notificationId)?.snoozed_till !==
null;
mutateNotification(notificationId, { snoozed_till: isSnoozed ? null : dateTime });
if (isSnoozed) {
await userNotificationServices
.patchUserNotification(workspaceSlug.toString(), notificationId, {
snoozed_till: null,
})
.finally(() => {
notificationsMutate();
mutateNotificationCount();
notificationMutate();
});
} else {
notificationsMutate(
(prevData) => prevData?.filter((prev) => prev.id !== notificationId) || [],
false
);
await userNotificationServices
.patchUserNotification(workspaceSlug.toString(), notificationId, {
snoozed_till: dateTime,
@ -164,15 +249,14 @@ const useUserNotification = () => {
new Error("Something went wrong");
})
.finally(() => {
notificationsMutate();
mutateNotificationCount();
notificationMutate();
});
}
};
return {
notifications,
notificationsMutate,
notificationMutate,
markNotificationReadStatus,
markNotificationArchivedStatus,
markSnoozeNotification,
@ -193,6 +277,11 @@ const useUserNotification = () => {
: null,
notificationCount,
mutateNotificationCount,
setSize,
isLoading,
isLoadingMore,
hasMore,
isRefreshing,
};
};

View File

@ -4,7 +4,12 @@ import APIService from "services/api.service";
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
// types
import { IUserNotification, INotificationParams } from "types";
import type {
IUserNotification,
INotificationParams,
NotificationCount,
PaginatedUserNotification,
} from "types";
class UserNotificationsServices extends APIService {
constructor() {
@ -152,17 +157,21 @@ class UserNotificationsServices extends APIService {
});
}
async getUnreadNotificationsCount(workspaceSlug: string): Promise<{
created_issues: number;
my_issues: number;
watching_issues: number;
}> {
async getUnreadNotificationsCount(workspaceSlug: string): Promise<NotificationCount> {
return this.get(`/api/workspaces/${workspaceSlug}/users/notifications/unread/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getNotifications(url: string): Promise<PaginatedUserNotification> {
return this.get(url)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
const userNotificationServices = new UserNotificationsServices();

View File

@ -1,5 +1,16 @@
import type { IUserLite } from "./users";
export interface PaginatedUserNotification {
next_cursor: string;
prev_cursor: string;
next_page_results: boolean;
prev_page_results: boolean;
count: number;
total_pages: number;
extra_stats: null;
results: IUserNotification[];
}
export interface IUserNotification {
id: string;
created_at: Date;
@ -54,3 +65,9 @@ export interface INotificationParams {
archived?: boolean;
read?: boolean;
}
export type NotificationCount = {
created_issues: number;
my_issues: number;
watching_issues: number;
};