diff --git a/src/__testing__/NotificationCenter.test.tsx b/src/__testing__/NotificationCenter.test.tsx new file mode 100644 index 000000000..68d5e8d2a --- /dev/null +++ b/src/__testing__/NotificationCenter.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { notificationStore } from '../custom/Helpers/Notification'; +import { NotificationCenter } from '../custom/NotificationCenter'; +import { SistentThemeProvider } from '../theme'; + +const renderCenter = () => + render( + + + + ); + +describe('NotificationCenter', () => { + afterEach(() => { + // The history store is a module-level singleton shared across every + // instance, so each test starts from a clean slate. + notificationStore.clear(); + }); + + it('renders with no unread badge when there is no history', () => { + renderCenter(); + expect(screen.queryByText('0')).toBeNull(); + }); + + it('shows an unread badge count as notifications are added', () => { + notificationStore.add('Design published'); + notificationStore.add('Model imported'); + renderCenter(); + + expect(screen.queryByText('2')).not.toBeNull(); + }); + + it('lists notification history in the popover and shows an empty state before any exist', () => { + renderCenter(); + + fireEvent.click(screen.getByLabelText('Notifications')); + expect(screen.queryByText("You're all caught up.")).not.toBeNull(); + }); + + it('marks a notification as read when clicked, clearing the unread badge', () => { + notificationStore.add('Workspace invite accepted'); + renderCenter(); + + fireEvent.click(screen.getByLabelText('Notifications')); + fireEvent.click(screen.getByText('Workspace invite accepted')); + + // MUI's Badge intentionally keeps rendering the last non-zero value + // (behind an 'invisible' class) while it fades out, so we assert on the + // store's actual read state rather than the badge's transient DOM text. + expect(notificationStore.getSnapshot().every((record) => record.read)).toBe(true); + }); + + it('dismisses a single notification via its close action', () => { + notificationStore.add('Deployment finished'); + renderCenter(); + + fireEvent.click(screen.getByLabelText('Notifications')); + fireEvent.click(screen.getByLabelText('Dismiss notification')); + + expect(screen.queryByText('Deployment finished')).toBeNull(); + }); + + it('clears all notifications via the header action', () => { + notificationStore.add('First notification'); + notificationStore.add('Second notification'); + renderCenter(); + + fireEvent.click(screen.getByLabelText('Notifications')); + fireEvent.click(screen.getByLabelText('Clear all notifications')); + + expect(screen.queryByText("You're all caught up.")).not.toBeNull(); + }); +}); diff --git a/src/custom/Helpers/Notification/index.tsx b/src/custom/Helpers/Notification/index.tsx index 01c24112f..b8df58980 100644 --- a/src/custom/Helpers/Notification/index.tsx +++ b/src/custom/Helpers/Notification/index.tsx @@ -1,3 +1,5 @@ import useNotificationHandler from './notification-handler'; +import { notificationStore, useNotificationStore } from './notification-store'; -export { useNotificationHandler }; +export type { NotificationRecord, NotificationVariant } from './notification-store'; +export { notificationStore, useNotificationHandler, useNotificationStore }; diff --git a/src/custom/Helpers/Notification/notification-handler.ts b/src/custom/Helpers/Notification/notification-handler.ts index 446011303..c778e6a20 100644 --- a/src/custom/Helpers/Notification/notification-handler.ts +++ b/src/custom/Helpers/Notification/notification-handler.ts @@ -1,5 +1,6 @@ import { OptionsObject, useSnackbar } from 'notistack'; import React from 'react'; +import { notificationStore } from './notification-store'; type NotificationHandler = (message: string, options?: OptionsObject) => void; @@ -19,6 +20,7 @@ const useNotificationHandler = (): NotificationHandler => { if (options) { enqueueSnackbar(message, options); } + notificationStore.add(message, options?.variant); }; return notify; diff --git a/src/custom/Helpers/Notification/notification-store.ts b/src/custom/Helpers/Notification/notification-store.ts new file mode 100644 index 000000000..aac0d74db --- /dev/null +++ b/src/custom/Helpers/Notification/notification-store.ts @@ -0,0 +1,107 @@ +import { OptionsObject } from 'notistack'; +import React from 'react'; + +export type NotificationVariant = OptionsObject['variant']; + +export interface NotificationRecord { + id: string; + message: string; + variant: NotificationVariant; + createdAt: number; + read: boolean; +} + +type Listener = () => void; + +/** + * Module-level store (outside React) that aggregates every notification + * dispatched through `useNotificationHandler` so a persistent surface like + * `NotificationCenter` can render history after a toast disappears. + * + * This intentionally does not replace notification-handler.ts / notistack - + * it just mirrors what already flows through that pipeline. + */ +class NotificationStore { + private records: NotificationRecord[] = []; + private listeners = new Set(); + + private emit = (): void => { + this.listeners.forEach((listener) => listener()); + }; + + subscribe = (listener: Listener): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getSnapshot = (): NotificationRecord[] => this.records; + + add = (message: string, variant?: NotificationVariant): void => { + const record: NotificationRecord = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + message, + variant: variant ?? 'default', + createdAt: Date.now(), + read: false + }; + this.records = [record, ...this.records]; + this.emit(); + }; + + markRead = (id: string): void => { + this.records = this.records.map((record) => + record.id === id ? { ...record, read: true } : record + ); + this.emit(); + }; + + markAllRead = (): void => { + this.records = this.records.map((record) => ({ ...record, read: true })); + this.emit(); + }; + + dismiss = (id: string): void => { + this.records = this.records.filter((record) => record.id !== id); + this.emit(); + }; + + clear = (): void => { + this.records = []; + this.emit(); + }; +} + +export const notificationStore = new NotificationStore(); + +/** + * Subscribes a component to the notification history so it re-renders + * whenever a new notification is added, read, dismissed, or cleared. + */ +export const useNotificationStore = (): { + notifications: NotificationRecord[]; + unreadCount: number; + markRead: (id: string) => void; + markAllRead: () => void; + dismiss: (id: string) => void; + clear: () => void; +} => { + const notifications = React.useSyncExternalStore( + notificationStore.subscribe, + notificationStore.getSnapshot, + notificationStore.getSnapshot + ); + + const unreadCount = React.useMemo( + () => notifications.filter((notification) => !notification.read).length, + [notifications] + ); + + return { + notifications, + unreadCount, + markRead: notificationStore.markRead, + markAllRead: notificationStore.markAllRead, + dismiss: notificationStore.dismiss, + clear: notificationStore.clear + }; +}; diff --git a/src/custom/NotificationCenter/NotificationCenter.tsx b/src/custom/NotificationCenter/NotificationCenter.tsx new file mode 100644 index 000000000..ed9d7ff9f --- /dev/null +++ b/src/custom/NotificationCenter/NotificationCenter.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { Badge, Divider, IconButton, Popover, Stack, Typography } from '../../base'; +import { BellIcon, CloseIcon, DoneAllIcon } from '../../icons'; +import { useNotificationStore } from '../Helpers/Notification'; +import { NotificationListItemRoot, NotificationPopoverContent } from './style'; + +export interface NotificationCenterProps { + /** Optional accessible label for the bell trigger button. */ + ariaLabel?: string; +} + +const formatRelativeTime = (timestamp: number): string => { + const diffSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); + if (diffSeconds < 60) return 'just now'; + const diffMinutes = Math.floor(diffSeconds / 60); + if (diffMinutes < 60) return `${diffMinutes}m ago`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +}; + +export const NotificationCenter: React.FC = ({ + ariaLabel = 'Notifications' +}) => { + const [anchorEl, setAnchorEl] = React.useState(null); + const { notifications, unreadCount, markRead, markAllRead, dismiss, clear } = + useNotificationStore(); + + const open = Boolean(anchorEl); + + const handleOpen = (event: React.MouseEvent): void => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = (): void => { + setAnchorEl(null); + }; + + return ( + <> + + + + + + + +
+ Notifications + + + + + + + + +
+ +
+ {notifications.length === 0 ? ( +
+ You're all caught up. +
+ ) : ( + notifications.map((notification) => ( + +
!notification.read && markRead(notification.id)} + role="button" + tabIndex={0} + > + {notification.message} + + {formatRelativeTime(notification.createdAt)} + +
+
+ dismiss(notification.id)} + > + + +
+
+ )) + )} +
+ {notifications.length > 0 && } +
+
+ + ); +}; + +export default NotificationCenter; diff --git a/src/custom/NotificationCenter/index.tsx b/src/custom/NotificationCenter/index.tsx new file mode 100644 index 000000000..453cd4e5d --- /dev/null +++ b/src/custom/NotificationCenter/index.tsx @@ -0,0 +1,4 @@ +import NotificationCenter from './NotificationCenter'; + +export { NotificationCenter }; +export type { NotificationCenterProps } from './NotificationCenter'; diff --git a/src/custom/NotificationCenter/style.tsx b/src/custom/NotificationCenter/style.tsx new file mode 100644 index 000000000..ce3aa38c3 --- /dev/null +++ b/src/custom/NotificationCenter/style.tsx @@ -0,0 +1,56 @@ +import { styled } from '@mui/material'; +import { GRAY, LIGHT_GRAY, WHITE } from '../../theme'; + +export const NotificationPopoverContent = styled('div')({ + width: '360px', + maxHeight: '420px', + display: 'flex', + flexDirection: 'column', + background: WHITE, + + '.notification-center-header': { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0.75rem 1rem', + borderBottom: `1px solid ${LIGHT_GRAY}` + }, + + '.notification-center-list': { + overflowY: 'auto', + flex: 1 + }, + + '.notification-center-empty': { + padding: '2rem 1rem', + textAlign: 'center', + color: GRAY + } +}); + +export const NotificationListItemRoot = styled('div')<{ read: boolean }>(({ read }) => ({ + display: 'flex', + alignItems: 'flex-start', + gap: '0.5rem', + padding: '0.75rem 1rem', + borderBottom: `1px solid ${LIGHT_GRAY}`, + background: read ? WHITE : 'rgba(25, 118, 210, 0.06)', + + '.notification-center-item-message': { + margin: 0, + flex: 1, + wordBreak: 'break-word' + }, + + '.notification-center-item-time': { + color: GRAY, + fontSize: '0.75rem', + marginTop: '0.25rem' + }, + + '.notification-center-item-actions': { + display: 'flex', + gap: '0.25rem', + flexShrink: 0 + } +})); diff --git a/src/custom/index.tsx b/src/custom/index.tsx index bff31634c..dae0fc8ac 100644 --- a/src/custom/index.tsx +++ b/src/custom/index.tsx @@ -44,6 +44,7 @@ import { ColView, updateVisibleColumns } from './Helpers/ResponsiveColumns/respo import { LearningCard } from './LearningCard'; import { BasicMarkdown, RenderMarkdown } from './Markdown'; import { ModalCard } from './ModalCard'; +import { NotificationCenter } from './NotificationCenter'; import PopperListener, { IPopperListener } from './PopperListener'; import ResponsiveDataTable, { DataTableEllipsisMenu, @@ -120,6 +121,7 @@ export { InfoTooltip, LearningCard, ModalCard, + NotificationCenter, PopperListener, ResponsiveDataTable, sanitizeCatalogImageUrl,