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
8 changes: 6 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 })));
Expand All @@ -23,7 +24,9 @@ function PageLoader() {

export default function App() {
return (
<Suspense fallback={<PageLoader />}>
<>
<ToastContainer />
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
Expand All @@ -49,6 +52,7 @@ export default function App() {
<Route path="/auth/github/callback" element={<GitHubCallbackPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</Suspense>
</>
);
}
190 changes: 190 additions & 0 deletions frontend/src/__tests__/toast.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div {...domProps}>{children}</div>;
},
},
AnimatePresence: ({ children }: any) => <>{children}</>,
}));

function ToastDemo() {
const { success, error, warning, info } = useToastContext();

return (
<div>
<button data-testid="success" onClick={() => success('Success message')}>Trigger Success</button>
<button data-testid="error" onClick={() => error('Error message')}>Trigger Error</button>
<button data-testid="warning" onClick={() => warning('Warning message')}>Trigger Warning</button>
<button data-testid="info" onClick={() => info('Info message')}>Trigger Info</button>
</div>
);
}

function Wrapper({ children }: { children: React.ReactNode }) {
return (
<ToastProvider>
{children}
<ToastContainer />
</ToastProvider>
);
}

describe('Toast Notification System', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it('renders toast container with no toasts initially', () => {
render(
<Wrapper>
<ToastDemo />
</Wrapper>
);

expect(screen.getByLabelText('Notifications')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

it('displays a success toast with correct message', () => {
render(
<Wrapper>
<ToastDemo />
</Wrapper>
);

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(
<Wrapper>
<ToastDemo />
</Wrapper>
);

fireEvent.click(screen.getByTestId('error'));

const alert = screen.getByRole('alert');
expect(alert).toBeInTheDocument();
expect(alert).toHaveTextContent('Error message');
});

it('displays a warning toast', () => {
render(
<Wrapper>
<ToastDemo />
</Wrapper>
);

fireEvent.click(screen.getByTestId('warning'));

expect(screen.getByRole('alert')).toHaveTextContent('Warning message');
});

it('displays an info toast', () => {
render(
<Wrapper>
<ToastDemo />
</Wrapper>
);

fireEvent.click(screen.getByTestId('info'));

expect(screen.getByRole('alert')).toHaveTextContent('Info message');
});

it('dismisses toast when close button is clicked', () => {
render(
<Wrapper>
<ToastDemo />
</Wrapper>
);

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(
<Wrapper>
<ToastDemo />
</Wrapper>
);

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(
<Wrapper>
<ToastDemo />
</Wrapper>
);

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(
<Wrapper>
<ToastDemo />
</Wrapper>
);

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(<BadComponent />)).toThrow(
'useToastContext must be used within a ToastProvider'
);

spy.mockRestore();
});
});
82 changes: 82 additions & 0 deletions frontend/src/components/ui/Toast.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastVariant, { bg: string; border: string; icon: typeof CheckCircle; iconColor: string }> = {
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 (
<motion.div
layout
initial={{ opacity: 0, x: 100, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.95 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
role="alert"
aria-live="assertive"
className={`
flex items-start gap-3 px-4 py-3 rounded-lg border shadow-lg backdrop-blur-sm
${style.bg} ${style.border}
min-w-[300px] max-w-[420px]
`}
>
<Icon className={`w-5 h-5 ${style.iconColor} flex-shrink-0 mt-0.5`} aria-hidden="true" />
<p className="text-sm text-forge-100 flex-1 leading-relaxed">{message}</p>
<button
onClick={() => removeToast(id)}
className="flex-shrink-0 p-1 rounded-md text-forge-400 hover:text-forge-200 hover:bg-forge-700/50 transition-colors"
aria-label="Dismiss notification"
>
<X className="w-4 h-4" />
</button>
</motion.div>
);
}

export function ToastContainer() {
const { toasts } = useToastContext();

return (
<div
className="fixed top-4 right-4 z-50 flex flex-col gap-3"
aria-label="Notifications"
>
<AnimatePresence mode="popLayout">
{toasts.map((toast: Toast) => (
<ToastItem key={toast.id} {...toast} />
))}
</AnimatePresence>
</div>
);
}
85 changes: 85 additions & 0 deletions frontend/src/contexts/ToastContext.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastContextValue | null>(null);

let toastCounter = 0;

export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(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 (
<ToastContext.Provider value={{ toasts, addToast, removeToast, success, error, warning, info }}>
{children}
</ToastContext.Provider>
);
}

export function useToastContext() {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error('useToastContext must be used within a ToastProvider');
}
return ctx;
}
2 changes: 2 additions & 0 deletions frontend/src/hooks/useToast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { useToastContext as useToast } from '../contexts/ToastContext';
export type { Toast, ToastVariant } from '../contexts/ToastContext';
Loading