From 53e443d816e0dea1db8af34f473d1515023c0368 Mon Sep 17 00:00:00 2001 From: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com> Date: Wed, 19 Jul 2023 14:44:04 +0530 Subject: [PATCH] feat: notifications (#1566) * feat: added new issue subscriber table * dev: notification model * feat: added CRUD operation for issue subscriber * Revert "feat: added CRUD operation for issue subscriber" This reverts commit b22e0625768f0b096b5898936ace76d6882b0736. * feat: added CRUD operation for issue subscriber * dev: notification models and operations * dev: remove delete endpoint response data * dev: notification endpoints and fix bg worker for saving notifications * feat: added list and unsubscribe function in issue subscriber * dev: filter by snoozed and response update for list and permissions * dev: update issue notifications * dev: notification segregation * dev: update notifications * dev: notification filtering * dev: add issue name in notifications * dev: notification new endpoints * fix: pushing local settings * feat: notification workflow setup and made basic UI * style: improved UX with toast alerts and other interactions refactor: changed classnames according to new theme structure, changed all icons to material icons * feat: showing un-read notification count * feat: not showing 'subscribe' button on issue created by user & assigned to user not showing 'Create by you' for view & guest of the workspace * fix: 'read' -> 'unread' heading, my issue wrong filter * feat: made snooze dropdown & modal feat: switched to calendar * fix: minor ui fixes * feat: snooze modal date/time select * fix: params for read/un-read notification * style: snooze notification modal --------- Co-authored-by: NarayanBavisetti Co-authored-by: pablohashescobar Co-authored-by: Aaryan Khandelwal --- apps/app/components/issues/sidebar.tsx | 4 +- .../notifications/notification-card.tsx | 318 ++++++++++------- .../notifications/notification-popover.tsx | 198 +++++------ .../select-snooze-till-modal.tsx | 335 ++++++++---------- apps/app/components/ui/datepicker.tsx | 3 + .../components/ui/dropdowns/custom-menu.tsx | 11 +- .../ui/dropdowns/custom-search-select.tsx | 4 +- .../components/ui/dropdowns/custom-select.tsx | 4 +- apps/app/components/ui/empty-state.tsx | 12 +- .../components/workspace/issues-pie-chart.tsx | 4 +- apps/app/helpers/date-time.helper.ts | 43 ++- .../use-issue-notification-subscription.tsx | 2 - apps/app/hooks/use-user-notifications.tsx | 24 +- apps/app/pages/[workspaceSlug]/index.tsx | 4 +- ...mpty-notification.svg => notification.svg} | 0 15 files changed, 518 insertions(+), 448 deletions(-) rename apps/app/public/empty-state/{empty-notification.svg => notification.svg} (100%) diff --git a/apps/app/components/issues/sidebar.tsx b/apps/app/components/issues/sidebar.tsx index 184bca061..75ff4f77a 100644 --- a/apps/app/components/issues/sidebar.tsx +++ b/apps/app/components/issues/sidebar.tsx @@ -61,6 +61,7 @@ type Props = { | "link" | "delete" | "all" + | "subscribe" )[]; uneditable?: boolean; }; @@ -232,7 +233,8 @@ export const IssueDetailsSidebar: React.FC = ({
{issueDetail?.created_by !== user?.id && !issueDetail?.assignees.includes(user?.id ?? "") && - (fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( + !router.pathname.includes("[archivedIssueId]") && + (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && ( - ))} -
+ + ))} + + + { + e.stopPropagation(); + }} + customButton={ + + } + optionsClassName="!z-20" + > + {snoozeOptions.map((item) => ( + { + e.stopPropagation(); + + if (!item.value) { + setSelectedNotificationForSnooze(notification.id); + return; + } + + markSnoozeNotification(notification.id, item.value).then(() => { + setToastAlert({ + title: `Notification snoozed till ${renderLongDateFormat(item.value)}`, + type: "success", + }); + }); + }} + > + {item.label} + + ))} + + ); diff --git a/apps/app/components/notifications/notification-popover.tsx b/apps/app/components/notifications/notification-popover.tsx index 486a091e4..5ab2220f5 100644 --- a/apps/app/components/notifications/notification-popover.tsx +++ b/apps/app/components/notifications/notification-popover.tsx @@ -1,6 +1,5 @@ import React, { Fragment } from "react"; -import Image from "next/image"; import { useRouter } from "next/router"; // hooks @@ -13,9 +12,10 @@ import useWorkspaceMembers from "hooks/use-workspace-members"; import useUserNotification from "hooks/use-user-notifications"; // components -import { Spinner, Icon } from "components/ui"; +import { Icon, Loader, EmptyState } from "components/ui"; import { SnoozeNotificationModal, NotificationCard } from "components/notifications"; - +// images +import emptyNotification from "public/empty-state/notification.svg"; // helpers import { getNumberCount } from "helpers/string.helper"; @@ -116,16 +116,15 @@ export const NotificationPopover = () => { leaveFrom="opacity-100 translate-y-0" leaveTo="opacity-0 translate-y-1" > - -
-

- Notifications -

-
+ +
+

Notifications

+
- -
+
{snoozed || archived || readNotification ? ( -
-
- -
-
+ ) : ( -
-
+ ) : null + ) : ( + + ) + )} + )}
-
- {notifications ? ( - notifications.filter( - (notification) => notification.data.issue_activity.field !== "None" - ).length > 0 ? ( - notifications.map((notification) => ( + {notifications ? ( + notifications.length > 0 ? ( +
+ {notifications.map((notification) => ( { setSelectedNotificationForSnooze={setSelectedNotificationForSnooze} markSnoozeNotification={markSnoozeNotification} /> - )) - ) : ( -
- Empty -

- You{"'"}re updated with all the notifications -

-

- You have read all the notifications. -

-
- ) - ) : ( -
- + ))}
- )} -
+ ) : ( +
+ +
+ ) + ) : ( + + + + + + + + )} diff --git a/apps/app/components/notifications/select-snooze-till-modal.tsx b/apps/app/components/notifications/select-snooze-till-modal.tsx index 7bd78862b..99281350e 100644 --- a/apps/app/components/notifications/select-snooze-till-modal.tsx +++ b/apps/app/components/notifications/select-snooze-till-modal.tsx @@ -9,13 +9,19 @@ import { useForm, Controller } from "react-hook-form"; import { Transition, Dialog, Listbox } from "@headlessui/react"; // date helper -import { getDatesAfterCurrentDate, getTimestampAfterCurrentTime } from "helpers/date-time.helper"; +import { getAllTimeIn30MinutesInterval } from "helpers/date-time.helper"; // hooks import useToast from "hooks/use-toast"; // components -import { PrimaryButton, SecondaryButton, Icon } from "components/ui"; +import { + PrimaryButton, + SecondaryButton, + Icon, + CustomDatePicker, + CustomSelect, +} from "components/ui"; // types import type { IUserNotification } from "types"; @@ -28,14 +34,20 @@ type SnoozeModalProps = { onSubmit: (notificationId: string, dateTime?: Date | undefined) => Promise; }; -const dates = getDatesAfterCurrentDate(); -const timeStamps = getTimestampAfterCurrentTime(); +type FormValues = { + time: string | null; + date: Date | null; + period: "AM" | "PM"; +}; -const defaultValues = { +const defaultValues: FormValues = { time: null, date: null, + period: "AM", }; +const timeStamps = getAllTimeIn30MinutesInterval(); + export const SnoozeNotificationModal: React.FC = (props) => { const { isOpen, onClose, notification, onSuccess, onSubmit: handleSubmitSnooze } = props; @@ -49,19 +61,57 @@ export const SnoozeNotificationModal: React.FC = (props) => { reset, handleSubmit, control, - } = useForm({ + watch, + setValue, + } = useForm({ defaultValues, }); - const onSubmit = async (formData: any) => { - if (!workspaceSlug || !notification) return; + const getTimeStamp = () => { + const today = new Date(); + const formDataDate = watch("date"); - const dateTime = new Date( - `${formData.date.toLocaleDateString()} ${formData.time.toLocaleTimeString()}` + if (!formDataDate) return timeStamps; + + const isToday = today.toDateString() === new Date(formDataDate).toDateString(); + + if (!isToday) return timeStamps; + + const hours = today.getHours(); + const minutes = today.getMinutes(); + + return timeStamps.filter((optionTime) => { + let optionHours = parseInt(optionTime.value.split(":")[0]); + const optionMinutes = parseInt(optionTime.value.split(":")[1]); + + const period = watch("period"); + + if (period === "PM" && optionHours !== 12) optionHours += 12; + + if (optionHours < hours) return false; + if (optionHours === hours && optionMinutes < minutes) return false; + + return true; + }); + }; + + const onSubmit = async (formData: FormValues) => { + if (!workspaceSlug || !notification || !formData.date || !formData.time) return; + + const period = formData.period; + + const time = formData.time.split(":"); + const hours = parseInt( + `${period === "AM" ? time[0] : parseInt(time[0]) + 12 === 24 ? "00" : parseInt(time[0]) + 12}` ); + const minutes = parseInt(time[1]); + + const dateTime = new Date(formData.date); + dateTime.setHours(hours); + dateTime.setMinutes(minutes); await handleSubmitSnooze(notification.id, dateTime).then(() => { - onClose(); + handleClose(); onSuccess(); setToastAlert({ title: "Notification snoozed", @@ -74,7 +124,7 @@ export const SnoozeNotificationModal: React.FC = (props) => { const handleClose = () => { onClose(); const timeout = setTimeout(() => { - reset(); + reset({ ...defaultValues }); clearTimeout(timeout); }, 500); }; @@ -105,7 +155,7 @@ export const SnoozeNotificationModal: React.FC = (props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
= (props) => {
-
-
-
- ( - - {({ open }) => ( - <> -
- - - - {value - ? new Date(value)?.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) - : "Select Time"} - - - - - - - - - {timeStamps.map((time, index) => ( - - `relative cursor-default select-none py-2 pl-3 pr-9 ${ - active - ? "bg-custom-primary-100/80 text-custom-text-100" - : "text-custom-text-700" - }` - } - value={time.value} - > - {({ selected, active }) => ( - <> -
- - {time.label} - -
- - {selected ? ( - - - ) : null} - - )} -
- ))} -
-
-
- - )} -
- )} - /> -
+
+
+ Pick a date +
( - - {({ open }) => ( - <> -
- - - - {value - ? new Date(value)?.toLocaleDateString([], { - day: "numeric", - month: "long", - year: "numeric", - }) - : "Select Date"} - - - - - - - - - {dates.map((date, index) => ( - - `relative cursor-default select-none py-2 pl-3 pr-9 ${ - active - ? "bg-custom-primary-100/80 text-custom-text-100" - : "text-custom-text-700" - }` - } - value={date.value} - > - {({ selected, active }) => ( - <> -
- - {date.label} - -
- - {selected ? ( - - - ) : null} - - )} -
- ))} -
-
-
- + { + setValue("time", null); + onChange(val); + }} + className="px-3 py-2 w-full rounded-md border border-custom-border-300 bg-custom-background-100 text-custom-text-100 focus:outline-none !text-sm" + noBorder + minDate={new Date()} + /> + )} + /> +
+
+
+ Pick a time +
+ ( + + {value ? ( + + {value} {watch("period").toLowerCase()} + + ) : ( + + Select a time + + )} +
+ } + width="w-full" + input + > +
+
{ + setValue("period", "AM"); + }} + className={`w-1/2 h-full cursor-pointer flex justify-center items-center text-center ${ + watch("period") === "AM" + ? "bg-custom-primary-100/90 text-custom-primary-0" + : "bg-custom-background-80" + }`} + > + AM +
+
{ + setValue("period", "PM"); + }} + className={`w-1/2 h-full cursor-pointer flex justify-center items-center text-center ${ + watch("period") === "PM" + ? "bg-custom-primary-100/90 text-custom-primary-0" + : "bg-custom-background-80" + }`} + > + PM +
+
+ {getTimeStamp().length > 0 ? ( + getTimeStamp().map((time, index) => ( + +
+ {time.label} +
+
+ )) + ) : ( +

+ No available time for this date. +

)} - + )} />
diff --git a/apps/app/components/ui/datepicker.tsx b/apps/app/components/ui/datepicker.tsx index b98642b28..56289a727 100644 --- a/apps/app/components/ui/datepicker.tsx +++ b/apps/app/components/ui/datepicker.tsx @@ -15,6 +15,7 @@ type Props = { className?: string; isClearable?: boolean; disabled?: boolean; + minDate?: Date; }; export const CustomDatePicker: React.FC = ({ @@ -28,6 +29,7 @@ export const CustomDatePicker: React.FC = ({ className = "", isClearable = true, disabled = false, + minDate, }) => ( = ({ dateFormat="MMM dd, yyyy" isClearable={isClearable} disabled={disabled} + minDate={minDate} /> ); diff --git a/apps/app/components/ui/dropdowns/custom-menu.tsx b/apps/app/components/ui/dropdowns/custom-menu.tsx index bc195b0c5..e33583ce0 100644 --- a/apps/app/components/ui/dropdowns/custom-menu.tsx +++ b/apps/app/components/ui/dropdowns/custom-menu.tsx @@ -13,6 +13,7 @@ export type CustomMenuProps = DropdownProps & { ellipsis?: boolean; noBorder?: boolean; verticalEllipsis?: boolean; + menuButtonOnClick?: (...args: any) => void; }; const CustomMenu = ({ @@ -32,17 +33,21 @@ const CustomMenu = ({ verticalEllipsis = false, verticalPosition = "bottom", width = "auto", + menuButtonOnClick, }: CustomMenuProps) => ( {({ open }) => ( <> {customButton ? ( - {customButton} + + {customButton} + ) : ( <> {ellipsis || verticalEllipsis ? ( {customButton} ) : ( {customButton} ) : ( void; isFullScreen?: boolean; @@ -33,10 +33,12 @@ export const EmptyState: React.FC = ({ {buttonText}
{title}

{description}

- - {buttonIcon} - {buttonText} - + {buttonText && ( + + {buttonIcon} + {buttonText} + + )}
); diff --git a/apps/app/components/workspace/issues-pie-chart.tsx b/apps/app/components/workspace/issues-pie-chart.tsx index 2d055c2c6..7d453d8a9 100644 --- a/apps/app/components/workspace/issues-pie-chart.tsx +++ b/apps/app/components/workspace/issues-pie-chart.tsx @@ -36,7 +36,7 @@ export const IssuesPieChart: React.FC = ({ groupedIssues }) => ( activeInnerRadiusOffset={5} colors={(datum) => datum.data.color} tooltip={(datum) => ( -
+
{datum.datum.label} issues:{" "} {datum.datum.value}
@@ -59,7 +59,7 @@ export const IssuesPieChart: React.FC = ({ groupedIssues }) => ( className="h-2 w-2" style={{ backgroundColor: STATE_GROUP_COLORS[cell.state_group] }} /> -
+
{cell.state_group}- {cell.state_count}
diff --git a/apps/app/helpers/date-time.helper.ts b/apps/app/helpers/date-time.helper.ts index 894bf317d..ea2170afb 100644 --- a/apps/app/helpers/date-time.helper.ts +++ b/apps/app/helpers/date-time.helper.ts @@ -243,7 +243,7 @@ export const isDateGreaterThanToday = (dateStr: string) => { return date > today; }; -export const renderLongDateFormat = (dateString: string) => { +export const renderLongDateFormat = (dateString: string | Date) => { const date = new Date(dateString); const day = date.getDate(); const year = date.getFullYear(); @@ -333,3 +333,44 @@ export const getDatesAfterCurrentDate = (): Array<{ export const checkIfStringIsDate = (date: string): boolean => new Date(date).toString() !== "Invalid Date"; + +// return an array of dates starting from 12:00 to 23:30 with 30 minutes interval as dates +export const getDatesWith30MinutesInterval = (): Array => { + const dates = []; + const current = new Date(); + for (let i = 0; i < 24; i++) { + const newDate = new Date(current.getTime() + i * 60 * 60 * 1000); + dates.push(newDate); + } + return dates; +}; + +export const getAllTimeIn30MinutesInterval = (): Array<{ + label: string; + value: string; +}> => [ + { label: "12:00", value: "12:00" }, + { label: "12:30", value: "12:30" }, + { label: "01:00", value: "01:00" }, + { label: "01:30", value: "01:30" }, + { label: "02:00", value: "02:00" }, + { label: "02:30", value: "02:30" }, + { label: "03:00", value: "03:00" }, + { label: "03:30", value: "03:30" }, + { label: "04:00", value: "04:00" }, + { label: "04:30", value: "04:30" }, + { label: "05:00", value: "05:00" }, + { label: "05:30", value: "05:30" }, + { label: "06:00", value: "06:00" }, + { label: "06:30", value: "06:30" }, + { label: "07:00", value: "07:00" }, + { label: "07:30", value: "07:30" }, + { label: "08:00", value: "08:00" }, + { label: "08:30", value: "08:30" }, + { label: "09:00", value: "09:00" }, + { label: "09:30", value: "09:30" }, + { label: "10:00", value: "10:00" }, + { label: "10:30", value: "10:30" }, + { label: "11:00", value: "11:00" }, + { label: "11:30", value: "11:30" }, +]; diff --git a/apps/app/hooks/use-issue-notification-subscription.tsx b/apps/app/hooks/use-issue-notification-subscription.tsx index 38bf8bf22..2abe353b0 100644 --- a/apps/app/hooks/use-issue-notification-subscription.tsx +++ b/apps/app/hooks/use-issue-notification-subscription.tsx @@ -45,8 +45,6 @@ const useUserIssueNotificationSubscription = ( }, [workspaceSlug, projectId, issueId, mutate]); const handleSubscribe = useCallback(() => { - console.log(workspaceSlug, projectId, issueId, user); - if (!workspaceSlug || !projectId || !issueId || !user) return; userNotificationServices diff --git a/apps/app/hooks/use-user-notifications.tsx b/apps/app/hooks/use-user-notifications.tsx index 4de9d296c..3225e9639 100644 --- a/apps/app/hooks/use-user-notifications.tsx +++ b/apps/app/hooks/use-user-notifications.tsx @@ -26,23 +26,17 @@ const useUserNotification = () => { ); const [selectedTab, setSelectedTab] = useState("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 - ? USER_WORKSPACE_NOTIFICATIONS(workspaceSlug.toString(), { - type: selectedTab, - snoozed, - archived, - read: selectedTab === null ? !readNotification : undefined, - }) - : null, - workspaceSlug - ? () => - userNotificationServices.getUserNotifications(workspaceSlug.toString(), { - type: selectedTab, - snoozed, - archived, - read: selectedTab === null ? !readNotification : undefined, - }) + ? () => userNotificationServices.getUserNotifications(workspaceSlug.toString(), params) : null ); diff --git a/apps/app/pages/[workspaceSlug]/index.tsx b/apps/app/pages/[workspaceSlug]/index.tsx index a02a157a1..aca40f85d 100644 --- a/apps/app/pages/[workspaceSlug]/index.tsx +++ b/apps/app/pages/[workspaceSlug]/index.tsx @@ -69,13 +69,13 @@ const WorkspacePage: NextPage = () => { return ( +
Dashboard
} right={ -
+