veza/apps/web/src/components/ui/ErrorDisplay.tsx

616 lines
18 KiB
TypeScript
Raw Normal View History

import * as React from 'react';
import { AlertTriangle, AlertCircle, Info, X, ChevronDown, ChevronUp, Bug } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from './button';
import { Card, CardContent } from './card';
import { Dialog } from './dialog';
import type { ApiError } from '@/types/api';
import { formatUserFriendlyError } from '@/utils/errorMessages';
import { parseApiError, getErrorCategory } from '@/utils/apiErrorHandler';
import { formatIssueReport, copyIssueReportToClipboard, openGitHubIssue } from '@/utils/reportIssue';
import toast from 'react-hot-toast';
/**
* ErrorDisplayProps - Props for the ErrorDisplay component
*
* Standardizes error presentation across the application with support for
* different variants, severities, and recovery actions.
*/
export interface ErrorDisplayProps {
/**
* The error to display
* Can be Error object, ApiError, string, or error-like object
*/
error: Error | ApiError | string | {
message: string;
code?: string | number;
status?: number;
details?: Record<string, any>;
};
/**
* Callback when user clicks retry button
* If not provided, retry button is hidden
*/
onRetry?: () => void | Promise<void>;
/**
* Callback when user dismisses the error
* If not provided, dismiss button is hidden
*/
onDismiss?: () => void;
/**
* Whether to show detailed error information
* Default: false (only shows user-friendly message)
* In development, defaults to true
*/
showDetails?: boolean;
/**
* Additional context about where/why the error occurred
* Used for logging and debugging
*/
context?: {
action?: string; // e.g., "fetching tracks", "uploading file"
resource?: string; // e.g., "tracks", "playlist"
resourceId?: string; // e.g., track ID, playlist ID
[key: string]: any; // Additional context
};
/**
* Variant/style of error display
* - "inline": Inline error within content (default)
* - "banner": Full-width banner at top of page
* - "modal": Modal dialog overlay
* - "card": Card-style error display
*/
variant?: 'inline' | 'banner' | 'modal' | 'card';
/**
* Error severity/category
* Affects visual styling (color, icon)
* - "error": Critical errors (default)
* - "warning": Warnings that don't block functionality
* - "info": Informational messages
*/
severity?: 'error' | 'warning' | 'info';
/**
* Size of the error display
* - "sm": Small, compact
* - "md": Medium (default)
* - "lg": Large, prominent
*/
size?: 'sm' | 'md' | 'lg';
/**
* Custom className for styling
*/
className?: string;
/**
* Whether error is dismissible
* Default: true (if onDismiss provided)
*/
dismissible?: boolean;
/**
* Custom title override
* If not provided, uses default title based on error type
*/
title?: string;
/**
* Custom icon override
* If not provided, uses default icon based on severity
*/
icon?: React.ReactNode;
/**
* Additional actions to display alongside retry/dismiss
* Array of { label, onClick, variant } objects
*/
actions?: Array<{
label: string;
onClick: () => void;
variant?: 'default' | 'outline' | 'ghost';
}>;
}
/**
* Normalizes different error types into a consistent structure
*/
function normalizeError(
error: Error | ApiError | string | { message: string; code?: string | number; status?: number; details?: Record<string, any> }
): {
message: string;
code?: string | number;
status?: number;
details?: any;
stack?: string;
} {
// String error
if (typeof error === 'string') {
return { message: error };
}
// Standard Error object
if (error instanceof Error) {
return {
message: error.message,
stack: error.stack,
};
}
// ApiError or error-like object
if (error && typeof error === 'object') {
// Try to parse as ApiError if it has the structure
try {
const apiError = parseApiError(error);
return {
message: apiError.message || 'An error occurred',
code: apiError.code,
status: typeof apiError.code === 'number' ? apiError.code : undefined,
details: apiError.details,
};
} catch {
// Fallback to direct object access
return {
message: (error as any).message || String(error),
code: (error as any).code,
status: (error as any).status,
details: (error as any).details,
};
}
}
return { message: String(error) };
}
/**
* ErrorDisplay - Standardized error display component
*
* Provides consistent error UI across the application with support for
* different variants, severities, and recovery actions.
*
* @example
* ```tsx
* <ErrorDisplay
* error={error}
* onRetry={() => refetch()}
* />
* ```
*/
export const ErrorDisplay = React.forwardRef<HTMLDivElement, ErrorDisplayProps>(
(
{
error,
onRetry,
onDismiss,
showDetails: showDetailsProp,
context,
variant = 'inline',
severity = 'error',
size = 'md',
className,
dismissible,
title: titleProp,
icon: iconProp,
actions = [],
...props
},
ref
) => {
const [isDetailsExpanded, setIsDetailsExpanded] = React.useState(false);
const [isRetrying, setIsRetrying] = React.useState(false);
const [isModalOpen, setIsModalOpen] = React.useState(true);
// Normalize error
const normalizedError = React.useMemo(() => normalizeError(error), [error]);
// Parse error to ApiError for category detection
const apiError = React.useMemo(() => parseApiError(error), [error]);
const errorCategory = React.useMemo(() => getErrorCategory(apiError), [apiError]);
// Determine if this is a server error that should show report issue button
const isServerError = React.useMemo(() => {
return errorCategory === 'server_error' ||
(apiError.status !== undefined && apiError.status >= 500);
}, [errorCategory, apiError]);
// Determine if details should be shown
const isDev = import.meta.env.DEV;
const showDetails = showDetailsProp ?? isDev;
// Extract user-friendly message
const userMessage = React.useMemo(() => {
if (normalizedError.message) {
// Try to format using utility if it's an ApiError-like structure
try {
return formatUserFriendlyError(
normalizedError as ApiError,
context?.resource as any,
false
);
} catch {
return normalizedError.message;
}
}
return 'An unexpected error occurred';
}, [normalizedError, context]);
// Determine title
const title = React.useMemo(() => {
if (titleProp) return titleProp;
if (context?.action) {
return `Error ${context.action}`;
}
switch (severity) {
case 'error':
return 'Error';
case 'warning':
return 'Warning';
case 'info':
return 'Information';
default:
return 'Error';
}
}, [titleProp, context, severity]);
// Icon configuration
const iconConfig = React.useMemo(() => {
if (iconProp) return iconProp;
const iconSize = size === 'sm' ? 'w-4 h-4' : size === 'lg' ? 'w-6 h-6' : 'w-5 h-5';
switch (severity) {
case 'error':
return <AlertCircle className={cn(iconSize, 'text-kodo-red')} />;
case 'warning':
return <AlertTriangle className={cn(iconSize, 'text-kodo-gold')} />;
case 'info':
return <Info className={cn(iconSize, 'text-kodo-cyan')} />;
default:
return <AlertCircle className={cn(iconSize, 'text-kodo-red')} />;
}
}, [iconProp, severity, size]);
// Color styles based on severity
const colorStyles = React.useMemo(() => {
switch (severity) {
case 'error':
return {
bg: 'bg-kodo-red/10',
border: 'border-kodo-red/30',
text: 'text-kodo-red',
icon: 'text-kodo-red',
};
case 'warning':
return {
bg: 'bg-kodo-gold/10',
border: 'border-kodo-gold/30',
text: 'text-kodo-gold',
icon: 'text-kodo-gold',
};
case 'info':
return {
bg: 'bg-kodo-cyan/10',
border: 'border-kodo-cyan/30',
text: 'text-kodo-cyan',
icon: 'text-kodo-cyan',
};
default:
return {
bg: 'bg-kodo-red/10',
border: 'border-kodo-red/30',
text: 'text-kodo-red',
icon: 'text-kodo-red',
};
}
}, [severity]);
// Size styles
const sizeStyles = React.useMemo(() => {
switch (size) {
case 'sm':
return {
padding: 'p-3',
text: 'text-xs',
title: 'text-sm',
gap: 'gap-2',
};
case 'lg':
return {
padding: 'p-6',
text: 'text-base',
title: 'text-lg',
gap: 'gap-4',
};
default:
return {
padding: 'p-4',
text: 'text-sm',
title: 'text-base',
gap: 'gap-3',
};
}
}, [size]);
// Handle retry
const handleRetry = React.useCallback(async () => {
if (!onRetry || isRetrying) return;
setIsRetrying(true);
try {
await onRetry();
} finally {
setIsRetrying(false);
}
}, [onRetry, isRetrying]);
// Handle dismiss
const handleDismiss = React.useCallback(() => {
if (onDismiss) {
onDismiss();
}
if (variant === 'modal') {
setIsModalOpen(false);
}
}, [onDismiss, variant]);
// Handle report issue
const handleReportIssue = React.useCallback(async () => {
try {
const report = formatIssueReport(error, {
component: context?.resource,
action: context?.action,
userId: context?.userId,
additionalInfo: context,
});
// Try to open GitHub issue first, fallback to clipboard
try {
openGitHubIssue(report);
toast.success('Opening GitHub issue...');
} catch {
// Fallback to clipboard
await copyIssueReportToClipboard(report);
toast.success('Issue report copied to clipboard');
}
} catch (err) {
toast.error('Failed to generate issue report');
}
}, [error, context]);
// Determine if dismissible
const isDismissible = dismissible ?? (onDismiss !== undefined || variant === 'modal');
// Render error details
const renderDetails = () => {
if (!showDetails || !isDetailsExpanded) return null;
const details: Array<{ label: string; value: any }> = [];
// Show request ID prominently for server errors
if (apiError.request_id) {
details.push({ label: 'Request ID', value: apiError.request_id });
}
if (normalizedError.code) {
details.push({ label: 'Error Code', value: String(normalizedError.code) });
}
if (normalizedError.status) {
details.push({ label: 'HTTP Status', value: String(normalizedError.status) });
}
if (normalizedError.details) {
details.push({ label: 'Details', value: JSON.stringify(normalizedError.details, null, 2) });
}
if (normalizedError.stack) {
details.push({ label: 'Stack Trace', value: normalizedError.stack });
}
if (context) {
details.push({ label: 'Context', value: JSON.stringify(context, null, 2) });
}
if (details.length === 0) return null;
return (
<div className="mt-4 pt-4 border-t border-white/10">
<div className="space-y-2">
{details.map((detail, idx) => (
<div key={idx} className="text-xs">
<span className="font-semibold opacity-70">{detail.label}:</span>
<pre className="mt-1 p-2 bg-black/20 rounded text-xs overflow-x-auto">
{typeof detail.value === 'string' ? detail.value : JSON.stringify(detail.value, null, 2)}
</pre>
</div>
))}
</div>
</div>
);
};
// Base error content
const errorContent = (
<div
ref={ref}
role="alert"
aria-live="polite"
className={cn(
'rounded-lg border flex',
colorStyles.bg,
colorStyles.border,
colorStyles.text,
sizeStyles.padding,
sizeStyles.gap,
className
)}
{...props}
>
<div className="flex-shrink-0">{iconConfig}</div>
<div className="flex-1 min-w-0">
<div className={cn('font-semibold mb-1', sizeStyles.title)}>
{title}
</div>
<div className={cn('opacity-90', sizeStyles.text)}>
{userMessage}
</div>
{renderDetails()}
{(onRetry || actions.length > 0 || isServerError || (showDetails && (normalizedError.code || normalizedError.details || normalizedError.stack || context))) && (
<div className="mt-4 flex flex-wrap gap-2 items-center">
{onRetry && (
<Button
variant="outline"
size={size === 'sm' ? 'sm' : 'default'}
onClick={handleRetry}
disabled={isRetrying}
className="border-current text-current hover:bg-current/10"
>
{isRetrying ? 'Retrying...' : 'Retry'}
</Button>
)}
{isServerError && (
<Button
variant="outline"
size={size === 'sm' ? 'sm' : 'default'}
onClick={handleReportIssue}
className="border-current text-current hover:bg-current/10"
>
<Bug className="w-4 h-4 mr-1.5" />
Report Issue
</Button>
)}
{actions.map((action, idx) => (
<Button
key={idx}
variant={action.variant || 'outline'}
size={size === 'sm' ? 'sm' : 'default'}
onClick={action.onClick}
className="border-current text-current hover:bg-current/10"
>
{action.label}
</Button>
))}
{showDetails && (normalizedError.code || normalizedError.details || normalizedError.stack || context) && (
<Button
variant="ghost"
size={size === 'sm' ? 'sm' : 'default'}
onClick={() => setIsDetailsExpanded(!isDetailsExpanded)}
className="text-current hover:bg-current/10"
>
{isDetailsExpanded ? (
<>
<ChevronUp className="w-4 h-4 mr-1" />
Hide Details
</>
) : (
<>
<ChevronDown className="w-4 h-4 mr-1" />
Show Details
</>
)}
</Button>
)}
</div>
)}
</div>
{isDismissible && (
<button
onClick={handleDismiss}
className="opacity-70 hover:opacity-100 transition-opacity flex-shrink-0"
aria-label="Dismiss error"
>
<X className={cn(size === 'sm' ? 'w-4 h-4' : 'w-5 h-5')} />
</button>
)}
</div>
);
// Render based on variant
switch (variant) {
case 'banner':
return (
<div className="w-full">
{errorContent}
</div>
);
case 'card':
return (
<Card className={cn(colorStyles.border, className)}>
<CardContent className={cn(sizeStyles.padding, 'pt-6')}>
{errorContent}
</CardContent>
</Card>
);
case 'modal':
return (
<Dialog
open={isModalOpen}
onClose={handleDismiss}
title={title}
variant={severity === 'error' ? 'alert' : severity === 'warning' ? 'alert' : 'info'}
footer={
<div className="flex gap-2 justify-end">
{onRetry && (
<Button
variant="outline"
onClick={handleRetry}
disabled={isRetrying}
>
{isRetrying ? 'Retrying...' : 'Retry'}
</Button>
)}
{isServerError && (
<Button
variant="outline"
onClick={handleReportIssue}
>
<Bug className="w-4 h-4 mr-1.5" />
Report Issue
</Button>
)}
{actions.map((action, idx) => (
<Button
key={idx}
variant={action.variant || 'outline'}
onClick={action.onClick}
>
{action.label}
</Button>
))}
<Button
variant="default"
onClick={handleDismiss}
>
{onRetry ? 'Close' : 'Dismiss'}
</Button>
</div>
}
>
<div className={cn(colorStyles.text)}>
<div className={cn('opacity-90 mb-4', sizeStyles.text)}>
{userMessage}
{isServerError && apiError.request_id && (
<div className="mt-2 text-xs opacity-75">
Request ID: <code className="bg-black/20 px-1.5 py-0.5 rounded">{apiError.request_id}</code>
</div>
)}
</div>
{renderDetails()}
</div>
</Dialog>
);
case 'inline':
default:
return errorContent;
}
}
);
ErrorDisplay.displayName = 'ErrorDisplay';