- Add extended error types with severity levels, retry policies, and contextual information (types/errors.ts) - Create centralized error handler service with user-friendly message translation and classification (services/errorHandler.ts) - Add ErrorContext for global error state management with auto-dismiss and error queue support (contexts/ErrorContext.tsx) - Create error UI components: ErrorToast, FieldError, FieldErrorSummary, FullScreenError, EmptyState, OfflineState - Add useError hook with retry strategies and API response handling - Add useAsync hook for async operations with comprehensive state - Create error message utilities with validation helpers - Add tests for errorHandler and errorMessages (88 tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
385 lines
9.7 KiB
TypeScript
385 lines
9.7 KiB
TypeScript
/**
|
|
* ErrorContext - Global Error State Management
|
|
*
|
|
* Provides centralized error handling with:
|
|
* - Error queue for multiple errors
|
|
* - Toast notifications for transient errors
|
|
* - Persistent errors that require user action
|
|
* - Error dismissal and retry callbacks
|
|
*/
|
|
|
|
import React, { createContext, useContext, useCallback, useReducer, useMemo, useRef, ReactNode } from 'react';
|
|
import { AppError, ErrorSeverity, isAuthError, isCriticalError } from '@/types/errors';
|
|
import { logError } from '@/services/errorHandler';
|
|
|
|
// Maximum number of errors to keep in history
|
|
const MAX_ERROR_HISTORY = 50;
|
|
|
|
// Auto-dismiss timeout for non-critical errors (ms)
|
|
const AUTO_DISMISS_TIMEOUT = 5000;
|
|
|
|
// Error with display metadata
|
|
interface DisplayableError extends AppError {
|
|
isVisible: boolean;
|
|
autoDismiss: boolean;
|
|
onRetry?: () => void;
|
|
onDismiss?: () => void;
|
|
}
|
|
|
|
// State shape
|
|
interface ErrorContextState {
|
|
// Current visible error (top of queue)
|
|
currentError: DisplayableError | null;
|
|
|
|
// All pending errors
|
|
errorQueue: DisplayableError[];
|
|
|
|
// Error history (for debugging)
|
|
errorHistory: AppError[];
|
|
|
|
// Global loading state for error recovery
|
|
isRecovering: boolean;
|
|
}
|
|
|
|
// Actions
|
|
type ErrorAction =
|
|
| { type: 'ADD_ERROR'; payload: DisplayableError }
|
|
| { type: 'DISMISS_CURRENT' }
|
|
| { type: 'DISMISS_ALL' }
|
|
| { type: 'DISMISS_BY_ID'; payload: string }
|
|
| { type: 'SET_RECOVERING'; payload: boolean }
|
|
| { type: 'CLEAR_HISTORY' };
|
|
|
|
// Initial state
|
|
const initialState: ErrorContextState = {
|
|
currentError: null,
|
|
errorQueue: [],
|
|
errorHistory: [],
|
|
isRecovering: false,
|
|
};
|
|
|
|
// Reducer
|
|
function errorReducer(state: ErrorContextState, action: ErrorAction): ErrorContextState {
|
|
switch (action.type) {
|
|
case 'ADD_ERROR': {
|
|
const newError = action.payload;
|
|
|
|
// Add to history
|
|
const newHistory = [newError, ...state.errorHistory].slice(0, MAX_ERROR_HISTORY);
|
|
|
|
// If no current error, set this as current
|
|
if (!state.currentError) {
|
|
return {
|
|
...state,
|
|
currentError: { ...newError, isVisible: true },
|
|
errorHistory: newHistory,
|
|
};
|
|
}
|
|
|
|
// Add to queue
|
|
return {
|
|
...state,
|
|
errorQueue: [...state.errorQueue, newError],
|
|
errorHistory: newHistory,
|
|
};
|
|
}
|
|
|
|
case 'DISMISS_CURRENT': {
|
|
// Get next error from queue
|
|
const [nextError, ...remainingQueue] = state.errorQueue;
|
|
|
|
return {
|
|
...state,
|
|
currentError: nextError ? { ...nextError, isVisible: true } : null,
|
|
errorQueue: remainingQueue,
|
|
};
|
|
}
|
|
|
|
case 'DISMISS_ALL': {
|
|
return {
|
|
...state,
|
|
currentError: null,
|
|
errorQueue: [],
|
|
};
|
|
}
|
|
|
|
case 'DISMISS_BY_ID': {
|
|
const errorId = action.payload;
|
|
|
|
// If current error matches, dismiss it
|
|
if (state.currentError?.errorId === errorId) {
|
|
const [nextError, ...remainingQueue] = state.errorQueue;
|
|
return {
|
|
...state,
|
|
currentError: nextError ? { ...nextError, isVisible: true } : null,
|
|
errorQueue: remainingQueue,
|
|
};
|
|
}
|
|
|
|
// Remove from queue
|
|
return {
|
|
...state,
|
|
errorQueue: state.errorQueue.filter((e) => e.errorId !== errorId),
|
|
};
|
|
}
|
|
|
|
case 'SET_RECOVERING': {
|
|
return {
|
|
...state,
|
|
isRecovering: action.payload,
|
|
};
|
|
}
|
|
|
|
case 'CLEAR_HISTORY': {
|
|
return {
|
|
...state,
|
|
errorHistory: [],
|
|
};
|
|
}
|
|
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
// Context value shape
|
|
interface ErrorContextValue {
|
|
// State
|
|
currentError: DisplayableError | null;
|
|
errorQueue: DisplayableError[];
|
|
errorHistory: AppError[];
|
|
isRecovering: boolean;
|
|
hasErrors: boolean;
|
|
errorCount: number;
|
|
|
|
// Actions
|
|
showError: (
|
|
error: AppError,
|
|
options?: {
|
|
autoDismiss?: boolean;
|
|
onRetry?: () => void;
|
|
onDismiss?: () => void;
|
|
}
|
|
) => void;
|
|
dismissCurrent: () => void;
|
|
dismissAll: () => void;
|
|
dismissById: (errorId: string) => void;
|
|
clearHistory: () => void;
|
|
setRecovering: (recovering: boolean) => void;
|
|
|
|
// Utilities
|
|
getErrorsByCategory: (category: string) => AppError[];
|
|
getErrorsBySeverity: (severity: ErrorSeverity) => AppError[];
|
|
}
|
|
|
|
// Create context
|
|
const ErrorContext = createContext<ErrorContextValue | undefined>(undefined);
|
|
|
|
// Provider component
|
|
interface ErrorProviderProps {
|
|
children: ReactNode;
|
|
onAuthError?: (error: AppError) => void;
|
|
onCriticalError?: (error: AppError) => void;
|
|
}
|
|
|
|
export function ErrorProvider({
|
|
children,
|
|
onAuthError,
|
|
onCriticalError,
|
|
}: ErrorProviderProps) {
|
|
const [state, dispatch] = useReducer(errorReducer, initialState);
|
|
|
|
// Timer refs for auto-dismiss
|
|
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// Clear existing timer
|
|
const clearDismissTimer = useCallback(() => {
|
|
if (dismissTimerRef.current) {
|
|
clearTimeout(dismissTimerRef.current);
|
|
dismissTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
// Schedule auto-dismiss
|
|
const scheduleAutoDismiss = useCallback((timeout: number = AUTO_DISMISS_TIMEOUT) => {
|
|
clearDismissTimer();
|
|
dismissTimerRef.current = setTimeout(() => {
|
|
dispatch({ type: 'DISMISS_CURRENT' });
|
|
}, timeout);
|
|
}, [clearDismissTimer]);
|
|
|
|
// Show error
|
|
const showError = useCallback(
|
|
(
|
|
error: AppError,
|
|
options?: {
|
|
autoDismiss?: boolean;
|
|
onRetry?: () => void;
|
|
onDismiss?: () => void;
|
|
}
|
|
) => {
|
|
// Log error
|
|
logError(error);
|
|
|
|
// Handle auth errors specially
|
|
if (isAuthError(error) && onAuthError) {
|
|
onAuthError(error);
|
|
return;
|
|
}
|
|
|
|
// Handle critical errors specially
|
|
if (isCriticalError(error) && onCriticalError) {
|
|
onCriticalError(error);
|
|
}
|
|
|
|
// Determine auto-dismiss behavior
|
|
const shouldAutoDismiss =
|
|
options?.autoDismiss ??
|
|
(error.severity !== 'critical' && error.severity !== 'error');
|
|
|
|
const displayableError: DisplayableError = {
|
|
...error,
|
|
isVisible: true,
|
|
autoDismiss: shouldAutoDismiss,
|
|
onRetry: options?.onRetry,
|
|
onDismiss: options?.onDismiss,
|
|
};
|
|
|
|
dispatch({ type: 'ADD_ERROR', payload: displayableError });
|
|
|
|
// Schedule auto-dismiss if applicable
|
|
if (shouldAutoDismiss && !state.currentError) {
|
|
scheduleAutoDismiss();
|
|
}
|
|
},
|
|
[state.currentError, onAuthError, onCriticalError, scheduleAutoDismiss]
|
|
);
|
|
|
|
// Dismiss current error
|
|
const dismissCurrent = useCallback(() => {
|
|
clearDismissTimer();
|
|
|
|
// Call onDismiss callback if provided
|
|
state.currentError?.onDismiss?.();
|
|
|
|
dispatch({ type: 'DISMISS_CURRENT' });
|
|
|
|
// Schedule auto-dismiss for next error if applicable
|
|
const nextError = state.errorQueue[0];
|
|
if (nextError?.autoDismiss) {
|
|
scheduleAutoDismiss();
|
|
}
|
|
}, [state.currentError, state.errorQueue, clearDismissTimer, scheduleAutoDismiss]);
|
|
|
|
// Dismiss all errors
|
|
const dismissAll = useCallback(() => {
|
|
clearDismissTimer();
|
|
dispatch({ type: 'DISMISS_ALL' });
|
|
}, [clearDismissTimer]);
|
|
|
|
// Dismiss by ID
|
|
const dismissById = useCallback(
|
|
(errorId: string) => {
|
|
if (state.currentError?.errorId === errorId) {
|
|
dismissCurrent();
|
|
} else {
|
|
dispatch({ type: 'DISMISS_BY_ID', payload: errorId });
|
|
}
|
|
},
|
|
[state.currentError, dismissCurrent]
|
|
);
|
|
|
|
// Clear history
|
|
const clearHistory = useCallback(() => {
|
|
dispatch({ type: 'CLEAR_HISTORY' });
|
|
}, []);
|
|
|
|
// Set recovering state
|
|
const setRecovering = useCallback((recovering: boolean) => {
|
|
dispatch({ type: 'SET_RECOVERING', payload: recovering });
|
|
}, []);
|
|
|
|
// Get errors by category
|
|
const getErrorsByCategory = useCallback(
|
|
(category: string) => {
|
|
return state.errorHistory.filter((e) => e.category === category);
|
|
},
|
|
[state.errorHistory]
|
|
);
|
|
|
|
// Get errors by severity
|
|
const getErrorsBySeverity = useCallback(
|
|
(severity: ErrorSeverity) => {
|
|
return state.errorHistory.filter((e) => e.severity === severity);
|
|
},
|
|
[state.errorHistory]
|
|
);
|
|
|
|
// Context value
|
|
const value = useMemo<ErrorContextValue>(
|
|
() => ({
|
|
currentError: state.currentError,
|
|
errorQueue: state.errorQueue,
|
|
errorHistory: state.errorHistory,
|
|
isRecovering: state.isRecovering,
|
|
hasErrors: state.currentError !== null || state.errorQueue.length > 0,
|
|
errorCount: (state.currentError ? 1 : 0) + state.errorQueue.length,
|
|
showError,
|
|
dismissCurrent,
|
|
dismissAll,
|
|
dismissById,
|
|
clearHistory,
|
|
setRecovering,
|
|
getErrorsByCategory,
|
|
getErrorsBySeverity,
|
|
}),
|
|
[
|
|
state.currentError,
|
|
state.errorQueue,
|
|
state.errorHistory,
|
|
state.isRecovering,
|
|
showError,
|
|
dismissCurrent,
|
|
dismissAll,
|
|
dismissById,
|
|
clearHistory,
|
|
setRecovering,
|
|
getErrorsByCategory,
|
|
getErrorsBySeverity,
|
|
]
|
|
);
|
|
|
|
return (
|
|
<ErrorContext.Provider value={value}>{children}</ErrorContext.Provider>
|
|
);
|
|
}
|
|
|
|
// Hook to use error context
|
|
export function useErrorContext(): ErrorContextValue {
|
|
const context = useContext(ErrorContext);
|
|
if (!context) {
|
|
throw new Error('useErrorContext must be used within an ErrorProvider');
|
|
}
|
|
return context;
|
|
}
|
|
|
|
// Convenience hook for showing errors
|
|
export function useShowError() {
|
|
const { showError } = useErrorContext();
|
|
return showError;
|
|
}
|
|
|
|
// Safe hook that returns undefined if not in ErrorProvider (for optional use)
|
|
export function useShowErrorSafe() {
|
|
const context = useContext(ErrorContext);
|
|
return context?.showError;
|
|
}
|
|
|
|
// Convenience hook for current error
|
|
export function useCurrentError() {
|
|
const { currentError, dismissCurrent } = useErrorContext();
|
|
return { error: currentError, dismiss: dismissCurrent };
|
|
}
|
|
|
|
export default ErrorContext;
|