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

729 lines
21 KiB
TypeScript
Raw Normal View History

import * as React from 'react';
import {
AlertTriangle,
AlertCircle,
Info,
X,
ChevronDown,
ChevronUp,
Bug,
Copy,
} 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 '@/schemas/apiSchemas';
import { formatUserFriendlyError } from '@/utils/errorMessages';
import { parseApiError, getErrorCategory } from '@/utils/apiErrorHandler';
import {
formatIssueReport,
copyIssueReportToClipboard,
openGitHubIssue,
} from '@/utils/reportIssue';
// CRITICAL FIX: Utiliser le wrapper lazy pour éviter les collisions de noms de variables
import toast from '@/utils/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(() => {
const normalized = normalizeError(error);
return (
errorCategory === 'server_error' ||
(normalized.status !== undefined && normalized.status >= 500)
);
}, [errorCategory, error]);
// 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-steel')} />;
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-steel/10',
border: 'border-kodo-steel/30',
text: 'text-kodo-steel',
icon: 'text-kodo-steel',
};
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-4',
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-4',
};
}
}, [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]);
// Action 5.3.1.2: Copy request ID to clipboard
const handleCopyRequestId = React.useCallback(async () => {
if (!apiError.request_id) return;
try {
if (
typeof navigator !== 'undefined' &&
navigator.clipboard &&
navigator.clipboard.writeText
) {
await navigator.clipboard.writeText(apiError.request_id);
toast.success('Request ID copied to clipboard');
} else {
// Fallback: create temporary textarea
const textarea = document.createElement('textarea');
textarea.value = apiError.request_id;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
toast.success('Request ID copied to clipboard');
} finally {
document.body.removeChild(textarea);
}
}
} catch (err) {
toast.error('Failed to copy request ID');
}
}, [apiError.request_id]);
// 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 && apiError.request_id && (
<>
<Button
variant="outline"
size={size === 'sm' ? 'sm' : 'default'}
onClick={handleCopyRequestId}
className="border-current text-current hover:bg-current/10"
title="Copy Request ID to clipboard"
>
<Copy className="w-4 h-4 mr-1.5" />
Copy Request ID
</Button>
<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 && apiError.request_id && (
<>
<Button
variant="outline"
onClick={handleCopyRequestId}
title="Copy Request ID to clipboard"
>
<Copy className="w-4 h-4 mr-1.5" />
Copy Request ID
</Button>
<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';