refactor notifications popover to include unread count, mark all as read button, and loading state
```
This commit is contained in:
louiscklaw
2025-05-12 13:10:19 +08:00
parent 1a77c3a5e8
commit cf70e2af21
8 changed files with 224 additions and 23 deletions

View File

@@ -189,7 +189,15 @@ function NotificationsButton(): React.JSX.Element {
<Tooltip title="Notifications"> <Tooltip title="Notifications">
<Badge <Badge
color="error" color="error"
sx={{ '& .MuiBadge-dot': { borderRadius: '50%', height: '10px', right: '6px', top: '6px', width: '10px' } }} sx={{
'& .MuiBadge-dot': {
borderRadius: '50%',
right: '6px',
top: '6px',
height: '10px',
width: '10px',
},
}}
variant="dot" variant="dot"
> >
<IconButton <IconButton

View File

@@ -25,12 +25,18 @@ import { getUnreadNotificationsByUserId } from '@/db/Notifications/GetUnreadNoti
import { logger } from '@/lib/default-logger'; import { logger } from '@/lib/default-logger';
import { toast } from '@/components/core/toaster'; import { toast } from '@/components/core/toaster';
import { NoteBlank as NoteBlankIcon } from '@phosphor-icons/react/dist/ssr/NoteBlank';
import { Sun as SunIcon } from '@phosphor-icons/react/dist/ssr/Sun';
import { MarkAllAsReadButton } from './mark-all-as-read-button';
import { Button } from '@mui/material';
export interface NotificationsPopoverProps { export interface NotificationsPopoverProps {
anchorEl: null | Element; anchorEl: null | Element;
onClose?: () => void; onClose?: () => void;
onMarkAllAsRead?: () => void; onMarkAllAsRead?: () => void;
onRemoveOne?: (id: string, reload: () => Promise<void>) => void; onRemoveOne?: (id: string, reload: () => Promise<void>) => void;
open?: boolean; open?: boolean;
setListLength: React.Dispatch<React.SetStateAction<number>>;
} }
export function NotificationsPopover({ export function NotificationsPopover({
@@ -39,6 +45,7 @@ export function NotificationsPopover({
onMarkAllAsRead, onMarkAllAsRead,
onRemoveOne, onRemoveOne,
open = false, open = false,
setListLength,
}: NotificationsPopoverProps): React.JSX.Element { }: NotificationsPopoverProps): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
const [notiList, setNotiList] = React.useState<Notification[]>([]); const [notiList, setNotiList] = React.useState<Notification[]>([]);
@@ -53,6 +60,7 @@ export function NotificationsPopover({
if (user?.id) { if (user?.id) {
const tempNotiList: Notification[] = await getUnreadNotificationsByUserId(user.id); const tempNotiList: Notification[] = await getUnreadNotificationsByUserId(user.id);
setNotiList(tempNotiList); setNotiList(tempNotiList);
setListLength(tempNotiList.length);
} }
} catch (loadNotiError) { } catch (loadNotiError) {
logger.error(loadNotiError); logger.error(loadNotiError);
@@ -78,10 +86,60 @@ export function NotificationsPopover({
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
onClose={onClose} onClose={onClose}
open={open} open={open}
slotProps={{ paper: { sx: { width: '380px' } } }} slotProps={{ paper: { sx: { width: 'unset' } } }}
transformOrigin={{ horizontal: 'right', vertical: 'top' }} transformOrigin={{ horizontal: 'right', vertical: 'top' }}
> >
{t('list-is-empty')} <Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', justifyContent: 'space-between', px: 3, py: 2 }}
>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'left' }}
>
<Typography variant="h6">
{t('Notifications')} ({notiList.length})
</Typography>
{loading ? (
<Typography
color="gray"
variant="subtitle2"
>
({t('loading')})
</Typography>
) : (
<></>
)}
</Stack>
{/* MarkAllAsReadButton(onMarkAllAsRead, notiList.length <= 0) */}
<MarkAllAsReadButton
onMarkAllAsRead={onMarkAllAsRead}
disabled={notiList.length <= 0}
/>
</Stack>
<Stack
direction="column"
spacing={2}
sx={{ alignItems: 'center', padding: '50px' }}
>
<SunIcon
size={48}
color="lightgray"
/>
<Typography
color="lightgray"
variant={'subtitle2'}
>
{t('list-is-empty')}
</Typography>
<Button variant="outlined">{t('refresh')}</Button>
</Stack>
</Popover> </Popover>
); );
@@ -91,7 +149,7 @@ export function NotificationsPopover({
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
onClose={onClose} onClose={onClose}
// todo: should not use 'true', fallback to 'open' // todo: should not use 'true', fallback to 'open'
open open={open}
slotProps={{ paper: { sx: { width: '380px' } } }} slotProps={{ paper: { sx: { width: '380px' } } }}
transformOrigin={{ horizontal: 'right', vertical: 'top' }} transformOrigin={{ horizontal: 'right', vertical: 'top' }}
> >
@@ -105,7 +163,9 @@ export function NotificationsPopover({
spacing={2} spacing={2}
sx={{ alignItems: 'left' }} sx={{ alignItems: 'left' }}
> >
<Typography variant="h6">{t('Notifications')}</Typography> <Typography variant="h6">
{t('Notifications')} ({notiList.length})
</Typography>
{loading ? ( {loading ? (
<Typography <Typography
@@ -118,14 +178,10 @@ export function NotificationsPopover({
<></> <></>
)} )}
</Stack> </Stack>
<Tooltip title={t('Mark all as read')}> <MarkAllAsReadButton
<IconButton onMarkAllAsRead={onMarkAllAsRead}
edge="end" disabled={notiList.length <= 0}
onClick={onMarkAllAsRead} />
>
<EnvelopeSimpleIcon />
</IconButton>
</Tooltip>
</Stack> </Stack>
{notiList.length === 0 ? ( {notiList.length === 0 ? (
@@ -151,3 +207,26 @@ export function NotificationsPopover({
</Popover> </Popover>
); );
} }
// TODO: remove me
// function MarkAllAsReadButton({
// onMarkAllAsRead,
// disabled,
// }: {
// onMarkAllAsRead: (() => void) | undefined;
// disabled: boolean;
// }): React.JSX.Element {
// const { t } = useTranslation();
// return (
// <Tooltip title={t('mark-all-as-read')}>
// <IconButton
// edge="end"
// onClick={onMarkAllAsRead}
// disabled={disabled}
// >
// <EnvelopeSimpleIcon color={disabled ? 'lightgray' : 'black'} />
// </IconButton>
// </Tooltip>
// );
// }

View File

@@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import { EnvelopeSimple as EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr/EnvelopeSimple';
import { useTranslation } from 'react-i18next';
export function MarkAllAsReadButton({
onMarkAllAsRead,
disabled,
}: {
onMarkAllAsRead: (() => void) | undefined;
disabled: boolean;
}): React.JSX.Element {
const { t } = useTranslation();
return (
<Tooltip title={t('mark-all-as-read')}>
<IconButton
edge="end"
onClick={onMarkAllAsRead}
disabled={disabled}
>
<EnvelopeSimpleIcon color={disabled ? 'lightgray' : 'black'} />
</IconButton>
</Tooltip>
);
}

View File

@@ -9,18 +9,60 @@ import { Bell as BellIcon } from '@phosphor-icons/react/dist/ssr/Bell';
import { usePopover } from '@/hooks/use-popover'; import { usePopover } from '@/hooks/use-popover';
import { NotificationsPopover } from '../../notifications-popover'; import { NotificationsPopover } from '../../notifications-popover';
import { logger } from '@/lib/default-logger';
// import { NotificationsButton } from './notifications-button'; // import { NotificationsButton } from './notifications-button';
import { toast } from '@/components/core/toaster';
import { MarkOneAsRead } from '@/db/Notifications/mark-one-as-read';
import { getUnreadNotificationsByUserId } from '@/db/Notifications/GetUnreadNotificationsByUserId';
import { Notification } from '@/db/Notifications/type';
import { useUser } from '@/hooks/use-user';
export function NotificationsButton(): React.JSX.Element { export function NotificationsButton(): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>(); const popover = usePopover<HTMLButtonElement>();
const { user } = useUser();
const [loading, setLoading] = React.useState(true);
const [showError, setShowError] = React.useState<boolean>(false);
const [listLength, setListLength] = React.useState<number>(0);
const [notiList, setNotiList] = React.useState<Notification[]>([]);
function handleMarkAllAsRead(): void {
// try {
// await MarkOneAsRead(id);
// toast.success('Notification marked as read');
// } catch (error) {
// logger.debug(error);
// toast.error('Something went wrong');
// }
}
function handleRemoveOne(id: string, cb: () => void): void {
MarkOneAsRead(id)
.then(() => {
toast.success('Notification marked as read');
cb();
})
.catch((error) => {
logger.debug(error);
toast.error('Something went wrong');
});
}
return ( return (
<React.Fragment> <React.Fragment>
<Tooltip title="Notifications"> <Tooltip title="Notifications">
<Badge <Badge
color="error" color="error"
sx={{ '& .MuiBadge-dot': { borderRadius: '50%', height: '10px', right: '6px', top: '6px', width: '10px' } }} sx={{
variant="dot" '& .MuiBadge-badge': {
height: '20px',
width: '20px',
right: '6px',
top: '6px',
},
}}
badgeContent={listLength}
> >
<IconButton <IconButton
onClick={popover.handleOpen} onClick={popover.handleOpen}
@@ -30,10 +72,14 @@ export function NotificationsButton(): React.JSX.Element {
</IconButton> </IconButton>
</Badge> </Badge>
</Tooltip> </Tooltip>
{/* */}
<NotificationsPopover <NotificationsPopover
anchorEl={popover.anchorRef.current} anchorEl={popover.anchorRef.current}
onClose={popover.handleClose} onClose={popover.handleClose}
open={popover.open} open={popover.open}
onMarkAllAsRead={handleMarkAllAsRead}
onRemoveOne={handleRemoveOne}
setListLength={setListLength}
/> />
</React.Fragment> </React.Fragment>
); );

View File

@@ -1,14 +1,18 @@
// //
// RULES: // RULES:
// api method for get notifications by user id // api method for get notifications by user id
import { pb } from '@/lib/pb';
import { COL_NOTIFICATIONS } from '@/constants'; import { COL_NOTIFICATIONS } from '@/constants';
import { pb } from '@/lib/pb';
import type { Notification } from './type.d'; import type { Notification } from './type.d';
export async function getNotificationsByUserId(userId: string): Promise<Notification[]> { export async function getNotificationsByUserId(userId: string): Promise<Notification[]> {
const records = await pb.collection(COL_NOTIFICATIONS).getFullList({ const records = await pb.collection(COL_NOTIFICATIONS).getFullList({
filter: `author.id = "000000000000001"`, expand: 'author, to_user_id',
filter: `to_user_id.id = "${userId}"`,
sort: '-created', sort: '-created',
}); });
return records as unknown as Notification[]; return records as unknown as Notification[];
} }

View File

@@ -0,0 +1,20 @@
//
// RULES:
// api method for get notifications by user id
import { COL_NOTIFICATIONS } from '@/constants';
import { pb } from '@/lib/pb';
import type { Notification } from './type.d';
export async function getUnreadNotificationsByUserId(userId: string): Promise<Notification[]> {
const records = await pb.collection(COL_NOTIFICATIONS).getFullList({
expand: 'author, to_user_id',
filter: `to_user_id.id = "${userId}" && read = false`,
sort: '-created',
cache: 'no-cache',
requestKey: null,
});
return records as unknown as Notification[];
}

View File

@@ -0,0 +1,11 @@
// api method for update notification record
// RULES:
// TBA
import { pb } from '@/lib/pb';
import { COL_NOTIFICATIONS } from '@/constants';
import type { RecordModel } from 'pocketbase';
import type { NotificationFormProps } from '@/components/dashboard/notification/type.d';
export async function MarkOneAsRead(id: string): Promise<RecordModel> {
return pb.collection(COL_NOTIFICATIONS).update(id, { read: true });
}

View File

@@ -1,17 +1,21 @@
'use client'; 'use client';
import type { User } from '@/types/user';
export type SortDir = 'asc' | 'desc'; export type SortDir = 'asc' | 'desc';
export interface Notification { export interface Notification {
id: string; id: string;
created: string;
createdAt: Date;
read: boolean; read: boolean;
type: string; type: string;
author: Record<string, unknown>; author?: User;
job: Record<string, unknown>; job?: { title: string };
description: string; description?: string;
NOTI_ID: string; company?: { name: string };
created: string; to_user_id?: User;
updated: string; link: string;
} }
export interface CreateFormProps { export interface CreateFormProps {