diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1c78aeb02..bbd15e908 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import React, { Suspense } from 'react'; import { Routes, Route } from 'react-router-dom'; import { AuthGuard } from './components/auth/AuthGuard'; +import { ToastContainer } from './components/ui/Toast'; // Lazy load pages const HomePage = React.lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage }))); @@ -23,7 +24,9 @@ function PageLoader() { export default function App() { return ( - }> + <> + + }> } /> } /> @@ -49,6 +52,7 @@ export default function App() { } /> } /> - + + ); } diff --git a/frontend/src/__tests__/toast.test.tsx b/frontend/src/__tests__/toast.test.tsx new file mode 100644 index 000000000..fc2433fb8 --- /dev/null +++ b/frontend/src/__tests__/toast.test.tsx @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { ToastProvider, useToastContext } from '../contexts/ToastContext'; +import { ToastContainer } from '../components/ui/Toast'; + +// Mock framer-motion to avoid animation issues in tests +vi.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: any) => { + // Remove animation-related props + const { layout, initial, animate, exit, transition, ...domProps } = props; + return
{children}
; + }, + }, + AnimatePresence: ({ children }: any) => <>{children}, +})); + +function ToastDemo() { + const { success, error, warning, info } = useToastContext(); + + return ( +
+ + + + +
+ ); +} + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + + ); +} + +describe('Toast Notification System', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders toast container with no toasts initially', () => { + render( + + + + ); + + expect(screen.getByLabelText('Notifications')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('displays a success toast with correct message', () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('success')); + + const alert = screen.getByRole('alert'); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveTextContent('Success message'); + }); + + it('displays an error toast with correct message', () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('error')); + + const alert = screen.getByRole('alert'); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveTextContent('Error message'); + }); + + it('displays a warning toast', () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('warning')); + + expect(screen.getByRole('alert')).toHaveTextContent('Warning message'); + }); + + it('displays an info toast', () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('info')); + + expect(screen.getByRole('alert')).toHaveTextContent('Info message'); + }); + + it('dismisses toast when close button is clicked', () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('success')); + expect(screen.getByRole('alert')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Dismiss notification')); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('auto-dismisses toast after 5 seconds', () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('success')); + expect(screen.getByRole('alert')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('stacks multiple toasts', () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('success')); + fireEvent.click(screen.getByTestId('error')); + fireEvent.click(screen.getByTestId('warning')); + + const alerts = screen.getAllByRole('alert'); + expect(alerts).toHaveLength(3); + expect(alerts[0]).toHaveTextContent('Success message'); + expect(alerts[1]).toHaveTextContent('Error message'); + expect(alerts[2]).toHaveTextContent('Warning message'); + }); + + it('has accessible role="alert" on each toast', () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('success')); + + const alert = screen.getByRole('alert'); + expect(alert).toHaveAttribute('aria-live', 'assertive'); + }); + + it('throws error when useToastContext is used outside provider', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + function BadComponent() { + useToastContext(); + return null; + } + + expect(() => render()).toThrow( + 'useToastContext must be used within a ToastProvider' + ); + + spy.mockRestore(); + }); +}); diff --git a/frontend/src/components/ui/Toast.tsx b/frontend/src/components/ui/Toast.tsx new file mode 100644 index 000000000..45beb953a --- /dev/null +++ b/frontend/src/components/ui/Toast.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react'; +import { useToastContext } from '../../contexts/ToastContext'; +import type { Toast, ToastVariant } from '../../contexts/ToastContext'; + +const variantStyles: Record = { + success: { + bg: 'bg-emerald-bg', + border: 'border-emerald-border', + icon: CheckCircle, + iconColor: 'text-emerald', + }, + error: { + bg: 'bg-red-950/30', + border: 'border-red-500/30', + icon: AlertCircle, + iconColor: 'text-red-400', + }, + warning: { + bg: 'bg-amber-950/30', + border: 'border-amber-500/30', + icon: AlertTriangle, + iconColor: 'text-amber-400', + }, + info: { + bg: 'bg-purple-bg', + border: 'border-purple-border', + icon: Info, + iconColor: 'text-purple-light', + }, +}; + +function ToastItem({ id, message, variant }: { id: string; message: string; variant: ToastVariant }) { + const { removeToast } = useToastContext(); + const style = variantStyles[variant]; + const Icon = style.icon; + + return ( + + + ); +} + +export function ToastContainer() { + const { toasts } = useToastContext(); + + return ( +
+ + {toasts.map((toast: Toast) => ( + + ))} + +
+ ); +} diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx new file mode 100644 index 000000000..f420cad88 --- /dev/null +++ b/frontend/src/contexts/ToastContext.tsx @@ -0,0 +1,85 @@ +import React, { createContext, useContext, useState, useCallback, useRef } from 'react'; + +export type ToastVariant = 'success' | 'error' | 'warning' | 'info'; + +export interface Toast { + id: string; + message: string; + variant: ToastVariant; +} + +interface ToastContextValue { + toasts: Toast[]; + addToast: (message: string, variant?: ToastVariant) => void; + removeToast: (id: string) => void; + success: (message: string) => void; + error: (message: string) => void; + warning: (message: string) => void; + info: (message: string) => void; +} + +const ToastContext = createContext(null); + +let toastCounter = 0; + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + const timersRef = useRef>>(new Map()); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + const timer = timersRef.current.get(id); + if (timer) { + clearTimeout(timer); + timersRef.current.delete(id); + } + }, []); + + const addToast = useCallback( + (message: string, variant: ToastVariant = 'info') => { + const id = `toast-${++toastCounter}`; + const toast: Toast = { id, message, variant }; + + setToasts((prev) => { + const next = [...prev, toast]; + // Keep max 5 toasts visible + if (next.length > 5) { + const removed = next[0]; + const timer = timersRef.current.get(removed.id); + if (timer) { + clearTimeout(timer); + timersRef.current.delete(removed.id); + } + return next.slice(1); + } + return next; + }); + + // Auto-dismiss after 5 seconds + const timer = setTimeout(() => { + removeToast(id); + }, 5000); + timersRef.current.set(id, timer); + }, + [removeToast] + ); + + const success = useCallback((message: string) => addToast(message, 'success'), [addToast]); + const error = useCallback((message: string) => addToast(message, 'error'), [addToast]); + const warning = useCallback((message: string) => addToast(message, 'warning'), [addToast]); + const info = useCallback((message: string) => addToast(message, 'info'), [addToast]); + + return ( + + {children} + + ); +} + +export function useToastContext() { + const ctx = useContext(ToastContext); + if (!ctx) { + throw new Error('useToastContext must be used within a ToastProvider'); + } + return ctx; +} diff --git a/frontend/src/hooks/useToast.ts b/frontend/src/hooks/useToast.ts new file mode 100644 index 000000000..e9ebcfadc --- /dev/null +++ b/frontend/src/hooks/useToast.ts @@ -0,0 +1,2 @@ +export { useToastContext as useToast } from '../contexts/ToastContext'; +export type { Toast, ToastVariant } from '../contexts/ToastContext'; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b20036806..9f347d2a4 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { AuthProvider } from './contexts/AuthContext'; +import { ToastProvider } from './contexts/ToastContext'; import { queryClient } from './services/queryClient'; import App from './App'; import './index.css'; @@ -15,7 +16,9 @@ createRoot(root).render( - + + +