diff --git a/src/components/notifications/NotificationList.tsx b/src/components/notifications/NotificationList.tsx new file mode 100644 index 0000000..7957bd8 --- /dev/null +++ b/src/components/notifications/NotificationList.tsx @@ -0,0 +1,164 @@ +import { useEffect, useRef } from 'react'; +import { Bell, CheckCheck } from 'lucide-react'; +import { NotificationItem } from './NotificationItem'; +import { EmptyState } from '../ui/emptyState'; +import { Skeleton } from '../ui/Skeleton'; +import type { Notification } from '../../types/notifications'; + +/** + * Props for NotificationList component. + */ +export interface NotificationListProps { + /** Array of notification data objects */ + notifications: Notification[]; + /** Whether the list is currently fetching more notifications */ + isFetching?: boolean; + /** Whether there are more notifications that can be loaded */ + hasNextPage?: boolean; + /** Callback triggered to mark all notifications as read */ + onMarkAllRead: () => void; + /** Callback triggered to mark a single notification as read */ + onMarkRead: (id: string | number) => void; + /** Callback triggered when the end of the list is reached */ + onLoadMore?: () => void; + /** Set of notification IDs that have been read */ + readNotificationIds?: Set; +} + +/** + * A beautiful, scrollable list of notifications with infinite loading support. + * Features a mark-all-read option, empty states, and skeleton loading. + */ +export function NotificationList({ + notifications, + isFetching, + hasNextPage, + onMarkAllRead, + onMarkRead, + onLoadMore, + readNotificationIds = new Set(), +}: NotificationListProps) { + const triggerRef = useRef(null); + + // Setup Intersection Observer for infinite scrolling + useEffect(() => { + if (!onLoadMore || !hasNextPage || isFetching) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + onLoadMore(); + } + }, + { threshold: 0.1 } + ); + + const currentTrigger = triggerRef.current; + if (currentTrigger) { + observer.observe(currentTrigger); + } + + return () => { + if (currentTrigger) { + observer.unobserve(currentTrigger); + } + }; + }, [hasNextPage, isFetching, onLoadMore]); + + // Initial loading state + if (isFetching && notifications.length === 0) { + return ( +
+
+

+ + Notifications +

+ +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + + +
+
+ ))} +
+
+ ); + } + + // Header Component + const Header = ( +
+

+ + Notifications +

+ +
+ ); + + // Empty State + if (notifications.length === 0) { + return ( +
+ {Header} +
+ +
+
+ ); + } + + return ( +
+ {Header} + +
+
+ {notifications.map((n) => ( + + ))} + + {/* Trigger element for infinite scroll */} + +
+
+ ); +} diff --git a/src/components/notifications/__tests__/NotificationList.test.tsx b/src/components/notifications/__tests__/NotificationList.test.tsx new file mode 100644 index 0000000..04b49fb --- /dev/null +++ b/src/components/notifications/__tests__/NotificationList.test.tsx @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { NotificationList } from '../NotificationList'; +import type { Notification } from '../../../types/notifications'; + +const mockNotifications: Notification[] = [ + { + id: '1', + type: 'success', + title: 'Test Notif 1', + message: 'Message 1', + time: '2026-03-29T10:00:00Z', + }, + { + id: '2', + type: 'adoption', + title: 'Test Notif 2', + message: 'Message 2', + time: '2026-03-29T11:00:00Z', + }, +]; + +// Mock useNavigate for NotificationItem +vi.mock('react-router-dom', () => ({ + useNavigate: () => vi.fn(), +})); + +// Mock IntersectionObserver +const mockObserve = vi.fn(); +const mockUnobserve = vi.fn(); +const mockDisconnect = vi.fn(); + +window.IntersectionObserver = vi.fn().mockImplementation(() => ({ + observe: mockObserve, + unobserve: mockUnobserve, + disconnect: mockDisconnect, +})) as any; + +describe('NotificationList', () => { + const onMarkAllRead = vi.fn(); + const onMarkRead = vi.fn(); + const onLoadMore = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders a list of notifications', () => { + render( + + ); + + expect(screen.getByText('Test Notif 1')).toBeTruthy(); + expect(screen.getByText('Test Notif 2')).toBeTruthy(); + expect(screen.getByText('Notifications')).toBeTruthy(); + }); + + it('shows empty state when no notifications are provided', () => { + render( + + ); + + expect(screen.getByText('You are all caught up!')).toBeTruthy(); + expect(screen.getByText(/You don't have any new notifications/)).toBeTruthy(); + }); + + it('calls onMarkAllRead when the "Mark all read" button is clicked', () => { + render( + + ); + + const markAllReadBtn = screen.getByLabelText('Mark all notifications as read'); + fireEvent.click(markAllReadBtn); + expect(onMarkAllRead).toHaveBeenCalledTimes(1); + }); + + it('renders skeleton rows while loading the initial list', () => { + render( + + ); + + // Should find multiple skeletons (one for header button, and multiple for items) + const skeletons = screen.getAllByTestId('skeleton'); + expect(skeletons.length).toBeGreaterThanOrEqual(5); + }); + + it('triggers onLoadMore when the bottom of the list is reached (IntersectionObserver)', () => { + let intersectionCallback: any; + window.IntersectionObserver = vi.fn().mockImplementation((callback) => { + intersectionCallback = callback; + return { + observe: mockObserve, + unobserve: mockUnobserve, + disconnect: mockDisconnect, + }; + }) as any; + + render( + + ); + + // Simulate intersection entry + intersectionCallback([{ isIntersecting: true }]); + + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); + + it('renders load more skeletons when hasNextPage is true', () => { + render( + + ); + + // Load more skeletons should be visible + const skeletons = screen.getAllByTestId('skeleton'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('marks items as read based on readNotificationIds prop', () => { + const readIds = new Set(['1']); + render( + + ); + + // Find the item by title and check if it has the "Read" aria-label (or check the dot) + const firstItem = screen.getByLabelText(/Read notification: Test Notif 1/i); + const secondItem = screen.getByLabelText(/Unread notification: Test Notif 2/i); + + expect(firstItem).toBeTruthy(); + expect(secondItem).toBeTruthy(); + }); +}); diff --git a/src/components/notifications/index.ts b/src/components/notifications/index.ts index 5c568c0..0c11aeb 100644 --- a/src/components/notifications/index.ts +++ b/src/components/notifications/index.ts @@ -1,2 +1,4 @@ export { NotificationItem } from './NotificationItem'; -export type { NotificationItemProps } from './NotificationItem'; \ No newline at end of file +export type { NotificationItemProps } from './NotificationItem'; +export { NotificationList } from './NotificationList'; +export type { NotificationListProps } from './NotificationList'; \ No newline at end of file