WellNuo/contexts/ErrorContext.tsx
Sergei a238b7e35f Add comprehensive error handling system
- 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>
2026-01-31 17:43:07 -08:00

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;