-
Notifications
You must be signed in to change notification settings - Fork 239
[NotificationCenter] Add NotificationCenter component #1827
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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( | ||
| <SistentThemeProvider> | ||
| <NotificationCenter /> | ||
| </SistentThemeProvider> | ||
| ); | ||
|
|
||
| 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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Listener>(); | ||
|
|
||
| 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 | ||
| }; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<NotificationCenterProps> = ({ | ||
| ariaLabel = 'Notifications' | ||
| }) => { | ||
| const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null); | ||
| const { notifications, unreadCount, markRead, markAllRead, dismiss, clear } = | ||
| useNotificationStore(); | ||
|
|
||
| const open = Boolean(anchorEl); | ||
|
|
||
| const handleOpen = (event: React.MouseEvent<HTMLButtonElement>): void => { | ||
| setAnchorEl(event.currentTarget); | ||
| }; | ||
|
|
||
| const handleClose = (): void => { | ||
| setAnchorEl(null); | ||
| }; | ||
|
|
||
| return ( | ||
| <> | ||
| <IconButton aria-label={ariaLabel} onClick={handleOpen}> | ||
| <Badge badgeContent={unreadCount} color="error" max={99}> | ||
| <BellIcon /> | ||
| </Badge> | ||
| </IconButton> | ||
| <Popover | ||
| open={open} | ||
| anchorEl={anchorEl} | ||
| onClose={handleClose} | ||
| anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} | ||
| transformOrigin={{ vertical: 'top', horizontal: 'right' }} | ||
| > | ||
| <NotificationPopoverContent> | ||
| <div className="notification-center-header"> | ||
| <Typography variant="subtitle1">Notifications</Typography> | ||
| <Stack direction="row" spacing={0.5}> | ||
| <IconButton | ||
| aria-label="Mark all as read" | ||
| size="small" | ||
| onClick={markAllRead} | ||
| disabled={unreadCount === 0} | ||
| > | ||
| <DoneAllIcon width="1.1rem" height="1.1rem" /> | ||
| </IconButton> | ||
| <IconButton | ||
| aria-label="Clear all notifications" | ||
| size="small" | ||
| onClick={clear} | ||
| disabled={notifications.length === 0} | ||
| > | ||
| <CloseIcon width="1.1rem" height="1.1rem" /> | ||
| </IconButton> | ||
| </Stack> | ||
| </div> | ||
|
|
||
| <div className="notification-center-list"> | ||
| {notifications.length === 0 ? ( | ||
| <div className="notification-center-empty"> | ||
| <Typography variant="body2">You're all caught up.</Typography> | ||
| </div> | ||
| ) : ( | ||
| notifications.map((notification) => ( | ||
| <NotificationListItemRoot key={notification.id} read={notification.read}> | ||
| <div | ||
| className="notification-center-item-message" | ||
| onClick={() => !notification.read && markRead(notification.id)} | ||
| role="button" | ||
| tabIndex={0} | ||
| > | ||
| <Typography variant="body2">{notification.message}</Typography> | ||
| <Typography className="notification-center-item-time" component="p"> | ||
| {formatRelativeTime(notification.createdAt)} | ||
| </Typography> | ||
| </div> | ||
| <div className="notification-center-item-actions"> | ||
| <IconButton | ||
| aria-label="Dismiss notification" | ||
| size="small" | ||
| onClick={() => dismiss(notification.id)} | ||
| > | ||
| <CloseIcon width="0.9rem" height="0.9rem" /> | ||
| </IconButton> | ||
| </div> | ||
| </NotificationListItemRoot> | ||
| )) | ||
| )} | ||
| </div> | ||
| {notifications.length > 0 && <Divider />} | ||
| </NotificationPopoverContent> | ||
| </Popover> | ||
| </> | ||
| ); | ||
| }; | ||
|
|
||
| export default NotificationCenter; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| import NotificationCenter from './NotificationCenter'; | ||
|
|
||
| export { NotificationCenter }; | ||
| export type { NotificationCenterProps } from './NotificationCenter'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import { styled } from '@mui/material'; | ||
| import { GRAY, LIGHT_GRAY, WHITE } from '../../theme'; | ||
|
Comment on lines
+1
to
+2
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🤖 get_repo_knowledge executed:
Length of output: 31440 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- changed file ---'
sed -n '1,240p' src/custom/NotificationCenter/style.tsx
printf '%s\n' '--- theme exports and palette definitions ---'
rg -n --glob '*.{ts,tsx,js,jsx}' "export .*styled|const styled|palette|GRAY|LIGHT_GRAY|WHITE" src/theme src/custom/NotificationCenter | head -240
printf '%s\n' '--- NotificationCenter imports/usages ---'
rg -n --glob '*.{ts,tsx}' "NotificationCenter|from ['\"].*style['\"]|unread|LIGHT_GRAY|GRAY|WHITE" src/custom/NotificationCenterRepository: layer5io/sistent Length of output: 11489 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- theme palette contract ---'
sed -n '1,190p' src/theme/palette.ts
printf '%s\n' '--- theme styled export and color exports ---'
sed -n '1,55p' src/theme/index.tsx
sed -n '1,80p' src/theme/colors/index.ts
printf '%s\n' '--- theme construction and provider ---'
sed -n '360,430p' src/theme/theme.ts
rg -n --glob '*.{ts,tsx}' "SistentThemeProvider|ThemeProvider|styled\\(" src/custom src/theme | head -100Repository: layer5io/sistent Length of output: 16967 Use Sistent theme tokens for notification styles. Use 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| 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 | ||
| } | ||
| })); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Add keyboard activation for each notification item.
Keyboard users can focus this element but cannot mark an unread notification as read.
role="button"andtabIndex={0}do not make adivreact to Enter or Space. Use a nativebutton, or handle both keys inonKeyDown.🤖 Prompt for AI Agents