feat(frontend): add comprehensive error handling and toast notifications

- Created Toast component with slide-in animation for success/error/info/warning messages
- Created useToast hook for managing toast notifications globally
- Added ToastContext to App.tsx for sharing toast functionality across components
- Implemented ErrorBoundary component to catch and display React errors gracefully
- Updated RecipeDetailPage to show toast notifications for all operations (create, update, delete, tag management)
- Updated TagSelector to use toast notifications instead of alert()
- Added proper error handling for all API operations with user-friendly messages
- Added loading states for delete operation
- Verified: All 34 backend tests passing, frontend builds successfully
This commit is contained in:
Paul Huliganga 2026-03-24 04:28:28 -04:00
parent 9b6d4d50e2
commit 6b0f2e10c6
7 changed files with 407 additions and 79 deletions

View File

@ -3,9 +3,32 @@ import { RecipeListPage } from './pages/RecipeListPage';
import { RecipeDetailPage } from './pages/RecipeDetailPage'; import { RecipeDetailPage } from './pages/RecipeDetailPage';
import { CookModePage } from './pages/CookModePage'; import { CookModePage } from './pages/CookModePage';
import { NotFoundPage } from './pages/NotFoundPage'; import { NotFoundPage } from './pages/NotFoundPage';
import { ErrorBoundary } from './components/ErrorBoundary';
import { ToastContainer } from './components/Toast';
import { useToast } from './hooks/useToast';
import { createContext, useContext } from 'react';
// Create toast context to share toast functionality across the app
interface ToastContextType {
success: (message: string, duration?: number) => string;
error: (message: string, duration?: number) => string;
info: (message: string, duration?: number) => string;
warning: (message: string, duration?: number) => string;
}
const ToastContext = createContext<ToastContextType | null>(null);
export function useToastContext() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToastContext must be used within ToastProvider');
}
return context;
}
function App() { function App() {
const location = useLocation(); const location = useLocation();
const toast = useToast();
const isActive = (path: string) => { const isActive = (path: string) => {
if (path === '/' && location.pathname === '/') return true; if (path === '/' && location.pathname === '/') return true;
@ -21,46 +44,52 @@ function App() {
}; };
return ( return (
<div className="min-h-screen bg-gray-50"> <ErrorBoundary>
<header className="bg-white shadow"> <ToastContext.Provider value={toast}>
<div className="max-w-7xl mx-auto px-4"> <div className="min-h-screen bg-gray-50">
<div className="flex items-center justify-between h-16"> <ToastContainer messages={toast.messages} onClose={toast.removeToast} />
<div className="flex items-center">
<Link to="/" className="flex-shrink-0"> <header className="bg-white shadow">
<h1 className="text-2xl font-bold text-gray-900">Recipe Manager</h1> <div className="max-w-7xl mx-auto px-4">
</Link> <div className="flex items-center justify-between h-16">
<div className="flex items-center">
<Link to="/" className="flex-shrink-0">
<h1 className="text-2xl font-bold text-gray-900">Recipe Manager</h1>
</Link>
</div>
<nav className="flex space-x-4">
<Link to="/" className={linkClass('/')}>
Recipes
</Link>
<Link to="/recipe/new" className={linkClass('/recipe/new')}>
Add Recipe
</Link>
</nav>
</div>
</div> </div>
</header>
<nav className="flex space-x-4">
<Link to="/" className={linkClass('/')}> <main className="max-w-7xl mx-auto py-6 px-4">
Recipes <Routes>
</Link> <Route path="/" element={<RecipeListPage />} />
<Link to="/recipe/new" className={linkClass('/recipe/new')}> <Route path="/recipe/new" element={<RecipeDetailPage />} />
Add Recipe <Route path="/recipe/:id" element={<RecipeDetailPage />} />
</Link> <Route path="/recipe/:id/cook" element={<CookModePage />} />
</nav> <Route path="*" element={<NotFoundPage />} />
</div> </Routes>
</main>
<footer className="bg-white border-t mt-12">
<div className="max-w-7xl mx-auto py-6 px-4">
<p className="text-center text-sm text-gray-500">
Recipe Manager MVP - Built with React + Vite + TypeScript
</p>
</div>
</footer>
</div> </div>
</header> </ToastContext.Provider>
</ErrorBoundary>
<main className="max-w-7xl mx-auto py-6 px-4">
<Routes>
<Route path="/" element={<RecipeListPage />} />
<Route path="/recipe/new" element={<RecipeDetailPage />} />
<Route path="/recipe/:id" element={<RecipeDetailPage />} />
<Route path="/recipe/:id/cook" element={<CookModePage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
<footer className="bg-white border-t mt-12">
<div className="max-w-7xl mx-auto py-6 px-4">
<p className="text-center text-sm text-gray-500">
Recipe Manager MVP - Built with React + Vite + TypeScript
</p>
</div>
</footer>
</div>
); );
} }

View File

@ -0,0 +1,95 @@
/**
* Error Boundary component to catch React errors
*/
import { Component, type ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: (error: Error, resetError: () => void) => ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
/**
* Error boundary that catches React errors and displays a fallback UI
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
};
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
console.error('Error boundary caught error:', error, errorInfo);
}
resetError = (): void => {
this.setState({
hasError: false,
error: null,
});
};
render(): ReactNode {
if (this.state.hasError && this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.resetError);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-6">
<div className="text-center mb-4">
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Something went wrong</h2>
<p className="text-gray-600 mb-4">
An unexpected error occurred. Please try refreshing the page.
</p>
</div>
<details className="mb-4">
<summary className="cursor-pointer text-sm text-gray-600 hover:text-gray-800 font-medium">
Error details
</summary>
<pre className="mt-2 p-3 bg-gray-50 rounded text-xs text-red-600 overflow-auto">
{this.state.error.toString()}
{this.state.error.stack && `\n\n${this.state.error.stack}`}
</pre>
</details>
<div className="flex gap-3">
<button
onClick={() => window.location.reload()}
className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 font-medium"
>
Refresh Page
</button>
<button
onClick={this.resetError}
className="flex-1 bg-gray-200 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-300 font-medium"
>
Try Again
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@ -4,6 +4,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTags } from '../hooks/useTags'; import { useTags } from '../hooks/useTags';
import { useToastContext } from '../App';
import type { Tag } from '../types/recipe'; import type { Tag } from '../types/recipe';
interface TagSelectorProps { interface TagSelectorProps {
@ -13,6 +14,7 @@ interface TagSelectorProps {
export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) { export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
const { tags, loading, error, addTag } = useTags(); const { tags, loading, error, addTag } = useTags();
const toast = useToastContext();
const [showNewTagForm, setShowNewTagForm] = useState(false); const [showNewTagForm, setShowNewTagForm] = useState(false);
const [newTagName, setNewTagName] = useState(''); const [newTagName, setNewTagName] = useState('');
const [newTagColor, setNewTagColor] = useState('#3B82F6'); const [newTagColor, setNewTagColor] = useState('#3B82F6');
@ -40,8 +42,10 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
setNewTagName(''); setNewTagName('');
setNewTagColor('#3B82F6'); setNewTagColor('#3B82F6');
setShowNewTagForm(false); setShowNewTagForm(false);
toast.success(`Tag "${newTag.name}" created!`);
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : 'Failed to create tag'); const errorMessage = err instanceof Error ? err.message : 'Failed to create tag';
toast.error(errorMessage);
} finally { } finally {
setCreating(false); setCreating(false);
} }
@ -52,7 +56,11 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
} }
if (error) { if (error) {
return <div className="text-red-600">Error loading tags: {error}</div>; return (
<div className="bg-red-50 border border-red-200 rounded-md p-3">
<p className="text-red-700 text-sm">Error loading tags: {error}</p>
</div>
);
} }
return ( return (

View File

@ -0,0 +1,85 @@
/**
* Toast notification component
* Displays temporary success/error/info messages
*/
import { useEffect } from 'react';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface ToastMessage {
id: string;
message: string;
type: ToastType;
duration?: number;
}
interface ToastProps {
message: ToastMessage;
onClose: (id: string) => void;
}
/**
* Single toast notification
*/
export function Toast({ message, onClose }: ToastProps) {
useEffect(() => {
const timer = setTimeout(() => {
onClose(message.id);
}, message.duration || 5000);
return () => clearTimeout(timer);
}, [message.id, message.duration, onClose]);
const bgColor = {
success: 'bg-green-600',
error: 'bg-red-600',
info: 'bg-blue-600',
warning: 'bg-yellow-600',
}[message.type];
const icon = {
success: '✓',
error: '✕',
info: '',
warning: '⚠',
}[message.type];
return (
<div
className={`${bgColor} text-white px-6 py-4 rounded-lg shadow-lg flex items-center justify-between gap-4 min-w-[300px] max-w-[500px] animate-slide-in`}
>
<div className="flex items-center gap-3">
<span className="text-xl font-bold">{icon}</span>
<span className="font-medium">{message.message}</span>
</div>
<button
onClick={() => onClose(message.id)}
className="text-white hover:text-gray-200 text-xl font-bold leading-none"
aria-label="Close"
>
×
</button>
</div>
);
}
/**
* Toast container that displays all active toasts
*/
interface ToastContainerProps {
messages: ToastMessage[];
onClose: (id: string) => void;
}
export function ToastContainer({ messages, onClose }: ToastContainerProps) {
if (messages.length === 0) return null;
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
{messages.map((message) => (
<Toast key={message.id} message={message} onClose={onClose} />
))}
</div>
);
}

View File

@ -0,0 +1,55 @@
/**
* Hook for managing toast notifications
*/
import { useState, useCallback } from 'react';
import type { ToastMessage, ToastType } from '../components/Toast';
let toastId = 0;
export function useToast() {
const [messages, setMessages] = useState<ToastMessage[]>([]);
const addToast = useCallback((message: string, type: ToastType = 'info', duration?: number) => {
const id = `toast-${++toastId}`;
const newMessage: ToastMessage = {
id,
message,
type,
duration,
};
setMessages((prev) => [...prev, newMessage]);
return id;
}, []);
const removeToast = useCallback((id: string) => {
setMessages((prev) => prev.filter((msg) => msg.id !== id));
}, []);
const success = useCallback((message: string, duration?: number) => {
return addToast(message, 'success', duration);
}, [addToast]);
const error = useCallback((message: string, duration?: number) => {
return addToast(message, 'error', duration);
}, [addToast]);
const info = useCallback((message: string, duration?: number) => {
return addToast(message, 'info', duration);
}, [addToast]);
const warning = useCallback((message: string, duration?: number) => {
return addToast(message, 'warning', duration);
}, [addToast]);
return {
messages,
addToast,
removeToast,
success,
error,
info,
warning,
};
}

View File

@ -51,3 +51,19 @@ body {
#root { #root {
min-height: 100vh; min-height: 100vh;
} }
/* Toast animation */
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, Link } from 'react-router-dom';
import { useRecipe } from '../hooks/useRecipe'; import { useRecipe } from '../hooks/useRecipe';
import { useToastContext } from '../App';
import { RecipeForm, type RecipeFormData } from '../components/RecipeForm'; import { RecipeForm, type RecipeFormData } from '../components/RecipeForm';
import { import {
createRecipe, createRecipe,
@ -18,6 +19,7 @@ import type { Tag } from '../types/recipe';
export function RecipeDetailPage() { export function RecipeDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const toast = useToastContext();
// Parse ID or null for "new" route // Parse ID or null for "new" route
const recipeId = id === 'new' ? null : (id ? parseInt(id, 10) : null); const recipeId = id === 'new' ? null : (id ? parseInt(id, 10) : null);
@ -26,53 +28,80 @@ export function RecipeDetailPage() {
const [isEditing, setIsEditing] = useState(recipeId === null); // Start in edit mode for new recipes const [isEditing, setIsEditing] = useState(recipeId === null); // Start in edit mode for new recipes
const [deleteConfirm, setDeleteConfirm] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false);
const [recipeTags, setRecipeTags] = useState<Tag[]>([]); const [recipeTags, setRecipeTags] = useState<Tag[]>([]);
const [isDeleting, setIsDeleting] = useState(false);
// Load recipe tags // Load recipe tags
useEffect(() => { useEffect(() => {
if (recipeId !== null) { if (recipeId !== null) {
fetchRecipeTags(recipeId) fetchRecipeTags(recipeId)
.then(setRecipeTags) .then(setRecipeTags)
.catch(console.error); .catch((err) => {
console.error('Failed to load recipe tags:', err);
toast.error('Failed to load recipe tags');
});
} }
}, [recipeId]); }, [recipeId, toast]);
// Handle form submission // Handle form submission
const handleSubmit = async (data: RecipeFormData, tags: Tag[]) => { const handleSubmit = async (data: RecipeFormData, tags: Tag[]) => {
if (recipeId === null) { try {
// Create new recipe if (recipeId === null) {
const newRecipe = await createRecipe(data); // Create new recipe
const newRecipe = await createRecipe(data);
// Assign tags
for (const tag of tags) { // Assign tags
await assignTagToRecipe(newRecipe.id, tag.id); for (const tag of tags) {
} try {
await assignTagToRecipe(newRecipe.id, tag.id);
navigate(`/recipe/${newRecipe.id}`); } catch (err) {
} else { console.error('Failed to assign tag:', err);
// Update existing recipe toast.warning(`Failed to assign tag "${tag.name}"`);
await updateRecipe(recipeId, data); }
// Update tags: remove old ones, add new ones
const currentTagIds = recipeTags.map(t => t.id);
const newTagIds = tags.map(t => t.id);
// Remove tags that are no longer selected
for (const tagId of currentTagIds) {
if (!newTagIds.includes(tagId)) {
await removeTagFromRecipe(recipeId, tagId);
} }
}
toast.success('Recipe created successfully!');
// Add tags that are newly selected navigate(`/recipe/${newRecipe.id}`);
for (const tagId of newTagIds) { } else {
if (!currentTagIds.includes(tagId)) { // Update existing recipe
await assignTagToRecipe(recipeId, tagId); await updateRecipe(recipeId, data);
// Update tags: remove old ones, add new ones
const currentTagIds = recipeTags.map(t => t.id);
const newTagIds = tags.map(t => t.id);
// Remove tags that are no longer selected
for (const tagId of currentTagIds) {
if (!newTagIds.includes(tagId)) {
try {
await removeTagFromRecipe(recipeId, tagId);
} catch (err) {
console.error('Failed to remove tag:', err);
toast.warning('Failed to remove some tags');
}
}
} }
// Add tags that are newly selected
for (const tagId of newTagIds) {
if (!currentTagIds.includes(tagId)) {
try {
await assignTagToRecipe(recipeId, tagId);
} catch (err) {
console.error('Failed to assign tag:', err);
toast.warning('Failed to assign some tags');
}
}
}
toast.success('Recipe updated successfully!');
setIsEditing(false);
// Refresh the page to show updated data
window.location.reload();
} }
} catch (err) {
setIsEditing(false); const errorMessage = err instanceof Error ? err.message : 'Failed to save recipe';
// Refresh the page to show updated data toast.error(errorMessage);
window.location.reload(); throw err; // Re-throw so form can handle it
} }
}; };
@ -80,8 +109,17 @@ export function RecipeDetailPage() {
const handleDelete = async () => { const handleDelete = async () => {
if (recipeId === null) return; if (recipeId === null) return;
await deleteRecipe(recipeId); try {
navigate('/'); setIsDeleting(true);
await deleteRecipe(recipeId);
toast.success('Recipe deleted successfully');
navigate('/');
} catch (err) {
setIsDeleting(false);
setDeleteConfirm(false);
const errorMessage = err instanceof Error ? err.message : 'Failed to delete recipe';
toast.error(errorMessage);
}
}; };
// Loading state // Loading state
@ -217,13 +255,15 @@ export function RecipeDetailPage() {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={handleDelete} onClick={handleDelete}
className="px-3 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm font-medium" disabled={isDeleting}
className="px-3 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm font-medium disabled:bg-gray-400 disabled:cursor-not-allowed"
> >
Confirm Delete {isDeleting ? 'Deleting...' : 'Confirm Delete'}
</button> </button>
<button <button
onClick={() => setDeleteConfirm(false)} onClick={() => setDeleteConfirm(false)}
className="px-3 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 text-sm font-medium" disabled={isDeleting}
className="px-3 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
Cancel Cancel
</button> </button>