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 { CookModePage } from './pages/CookModePage';
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() {
const location = useLocation();
const toast = useToast();
const isActive = (path: string) => {
if (path === '/' && location.pathname === '/') return true;
@ -21,7 +44,11 @@ function App() {
};
return (
<ErrorBoundary>
<ToastContext.Provider value={toast}>
<div className="min-h-screen bg-gray-50">
<ToastContainer messages={toast.messages} onClose={toast.removeToast} />
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center justify-between h-16">
@ -61,6 +88,8 @@ function App() {
</div>
</footer>
</div>
</ToastContext.Provider>
</ErrorBoundary>
);
}

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 { useTags } from '../hooks/useTags';
import { useToastContext } from '../App';
import type { Tag } from '../types/recipe';
interface TagSelectorProps {
@ -13,6 +14,7 @@ interface TagSelectorProps {
export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
const { tags, loading, error, addTag } = useTags();
const toast = useToastContext();
const [showNewTagForm, setShowNewTagForm] = useState(false);
const [newTagName, setNewTagName] = useState('');
const [newTagColor, setNewTagColor] = useState('#3B82F6');
@ -40,8 +42,10 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
setNewTagName('');
setNewTagColor('#3B82F6');
setShowNewTagForm(false);
toast.success(`Tag "${newTag.name}" created!`);
} 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 {
setCreating(false);
}
@ -52,7 +56,11 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
}
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 (

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 {
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 { useParams, useNavigate, Link } from 'react-router-dom';
import { useRecipe } from '../hooks/useRecipe';
import { useToastContext } from '../App';
import { RecipeForm, type RecipeFormData } from '../components/RecipeForm';
import {
createRecipe,
@ -18,6 +19,7 @@ import type { Tag } from '../types/recipe';
export function RecipeDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const toast = useToastContext();
// Parse ID or null for "new" route
const recipeId = id === 'new' ? null : (id ? parseInt(id, 10) : null);
@ -26,27 +28,38 @@ export function RecipeDetailPage() {
const [isEditing, setIsEditing] = useState(recipeId === null); // Start in edit mode for new recipes
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [recipeTags, setRecipeTags] = useState<Tag[]>([]);
const [isDeleting, setIsDeleting] = useState(false);
// Load recipe tags
useEffect(() => {
if (recipeId !== null) {
fetchRecipeTags(recipeId)
.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
const handleSubmit = async (data: RecipeFormData, tags: Tag[]) => {
try {
if (recipeId === null) {
// Create new recipe
const newRecipe = await createRecipe(data);
// Assign tags
for (const tag of tags) {
try {
await assignTagToRecipe(newRecipe.id, tag.id);
} catch (err) {
console.error('Failed to assign tag:', err);
toast.warning(`Failed to assign tag "${tag.name}"`);
}
}
toast.success('Recipe created successfully!');
navigate(`/recipe/${newRecipe.id}`);
} else {
// Update existing recipe
@ -59,29 +72,54 @@ export function RecipeDetailPage() {
// 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) {
const errorMessage = err instanceof Error ? err.message : 'Failed to save recipe';
toast.error(errorMessage);
throw err; // Re-throw so form can handle it
}
};
// Handle delete
const handleDelete = async () => {
if (recipeId === null) return;
try {
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
@ -217,13 +255,15 @@ export function RecipeDetailPage() {
<div className="flex gap-2">
<button
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
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
</button>