Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions src/components/notifications/NotificationList.tsx
Original file line number Diff line number Diff line change
@@ -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<string | number>;
}

/**
* 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<HTMLDivElement>(null);

// Setup Intersection Observer for infinite scrolling
useEffect(() => {
if (!onLoadMore || !hasNextPage || isFetching) return;

const observer = new IntersectionObserver(

Check failure on line 47 in src/components/notifications/NotificationList.tsx

View workflow job for this annotation

GitHub Actions / validate

src/components/notifications/__tests__/NotificationList.test.tsx > NotificationList > triggers onLoadMore when the bottom of the list is reached (IntersectionObserver)

TypeError: (callback) => { intersectionCallback = callback; return { observe: mockObserve, uno...<omitted>... } is not a constructor ❯ src/components/notifications/NotificationList.tsx:47:22 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25989:20 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom-client.development.js:13249:29 ❯ commitHookPassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13336:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:15484:13 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:15439:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:15519:11 ❯ flushPassiveEffects node_modules/react-dom/cjs/react-dom-client.development.js:18432:9
(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 (
<div className="flex flex-col h-full bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h2 className="text-base font-bold text-gray-900 flex items-center gap-2">
<Bell className="w-4 h-4 text-orange-500" />
Notifications
</h2>
<Skeleton variant="text" width="80px" height="20px" className="rounded-md" />
</div>
<div className="flex flex-col flex-1 divide-y divide-gray-100">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="px-4 py-4 flex gap-4">
<Skeleton className="w-10 h-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton variant="text" width="60%" />
<Skeleton variant="text" width="90%" />
<Skeleton variant="text" width="30%" />
</div>
</div>
))}
</div>
</div>
);
}

// Header Component
const Header = (
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-white sticky top-0 z-10">
<h2 className="text-base font-bold text-gray-900 flex items-center gap-2">
<Bell className="w-4 h-4 text-orange-500" />
Notifications
</h2>
<button
onClick={onMarkAllRead}
className="text-sm font-semibold text-orange-600 hover:text-orange-700 transition-all duration-200 flex items-center gap-1 hover:bg-orange-50 px-2 py-1 rounded-md"
aria-label="Mark all notifications as read"
>
<CheckCheck className="w-3.5 h-3.5" />
Mark all read
</button>
</div>
);

// Empty State
if (notifications.length === 0) {
return (
<div className="flex flex-col h-full bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden min-h-[400px]">
{Header}
<div className="flex-1 flex items-center justify-center p-8 bg-gray-50/50">
<EmptyState
title="You are all caught up!"
description="You don't have any new notifications right now. Enjoy your peaceful day!"
/>
</div>
</div>
);
}

return (
<div className="flex flex-col h-full bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden max-h-[600px]">
{Header}

<div className="flex-1 overflow-y-auto scroll-smooth">
<div className="flex flex-col divide-y divide-gray-100">
{notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
isRead={readNotificationIds.has(n.id)}
onRead={onMarkRead}
/>
))}

{/* Trigger element for infinite scroll */}
<div ref={triggerRef} className="h-4" aria-hidden="true" />

{/* Loading more indicators */}
{hasNextPage && (
<div className="flex flex-col divide-y divide-gray-100">
{Array.from({ length: 2 }).map((_, i) => (
<div key={`skeleton-${i}`} className="px-4 py-4 flex gap-4">
<Skeleton className="w-10 h-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton variant="text" width="60%" />
<Skeleton variant="text" width="90%" />
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
164 changes: 164 additions & 0 deletions src/components/notifications/__tests__/NotificationList.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<NotificationList
notifications={mockNotifications}
onMarkAllRead={onMarkAllRead}
onMarkRead={onMarkRead}
/>
);

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(
<NotificationList
notifications={[]}
onMarkAllRead={onMarkAllRead}
onMarkRead={onMarkRead}
/>
);

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(
<NotificationList
notifications={mockNotifications}
onMarkAllRead={onMarkAllRead}
onMarkRead={onMarkRead}
/>
);

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(
<NotificationList
notifications={[]}
isFetching={true}
onMarkAllRead={onMarkAllRead}
onMarkRead={onMarkRead}
/>
);

// 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(
<NotificationList
notifications={mockNotifications}
hasNextPage={true}
onLoadMore={onLoadMore}
onMarkAllRead={onMarkAllRead}
onMarkRead={onMarkRead}
/>
);

// Simulate intersection entry
intersectionCallback([{ isIntersecting: true }]);

expect(onLoadMore).toHaveBeenCalledTimes(1);
});

it('renders load more skeletons when hasNextPage is true', () => {
render(
<NotificationList
notifications={mockNotifications}
hasNextPage={true}
onMarkAllRead={onMarkAllRead}
onMarkRead={onMarkRead}
/>
);

// 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(
<NotificationList
notifications={mockNotifications}
readNotificationIds={readIds}
onMarkAllRead={onMarkAllRead}
onMarkRead={onMarkRead}
/>
);

// 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();
});
});
4 changes: 3 additions & 1 deletion src/components/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export { NotificationItem } from './NotificationItem';
export type { NotificationItemProps } from './NotificationItem';
export type { NotificationItemProps } from './NotificationItem';
export { NotificationList } from './NotificationList';
export type { NotificationListProps } from './NotificationList';
Loading