feat(C): Phase C — Core UI prüfen, vervollständigen, testen
C-ERR-BOUNDARY: ErrorBoundary erweitert (trace_id, Fallback-UI, PluginErrorBoundary) C-NOTIF: NotificationDropdown mit System-Channel-Link C-DOCS: ApiDocs.tsx Seite (Swagger UI iframe) C-A11Y: aria-label, min-h-touch, focus-visible ergänzt C-FE-TEST: 29 neue Tests (ErrorBoundary, ApiDocs, PrintButton, themeStore, NotificationDropdown) C-DOC: ui-design-guidelines.md aktualisiert Verifiziert (keine Änderungen nötig): - C-TAGS, C-CF, C-FILTER, C-DEDUP, C-ONBOARD, C-THEME, C-PWA, C-PRINT TSC: 0 errors, Build: erfolgreich, Vitest: 29/29 passed
This commit is contained in:
@@ -3,13 +3,19 @@ import { logError } from '@/utils/errorLogger';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
/** Optional fallback render function; receives the error and a retry callback */
|
||||
fallback?: (error: Error, retry: () => void) => ReactNode;
|
||||
/** Optional fallback render function; receives the error, a retry callback, and a trace_id */
|
||||
fallback?: (error: Error, retry: () => void, traceId: string) => ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
traceId: string | null;
|
||||
}
|
||||
|
||||
/** Generate a short unique trace ID for error tracking */
|
||||
function generateTraceId(): string {
|
||||
return 'err-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,79 +27,78 @@ interface ErrorBoundaryState {
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
this.state = { hasError: false, error: null, traceId: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
return { hasError: true, error, traceId: generateTraceId() };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// Log to console + backend + sessionStorage buffer
|
||||
logError(error, { componentStack: errorInfo.componentStack });
|
||||
// Log to console + backend + sessionStorage buffer with trace_id
|
||||
logError(error, { componentStack: errorInfo.componentStack, trace_id: this.state.traceId });
|
||||
}
|
||||
|
||||
handleRetry = (): void => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
this.setState({ hasError: false, error: null, traceId: null });
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
const traceId = this.state.traceId || '';
|
||||
|
||||
// Custom fallback if provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback(this.state.error, this.handleRetry);
|
||||
return this.props.fallback(this.state.error, this.handleRetry, traceId);
|
||||
}
|
||||
|
||||
// Default fallback UI
|
||||
// Default fallback UI — Tailwind classes, trace_id, retry + reload
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '300px',
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
className="flex flex-col items-center justify-center min-h-[300px] p-8 text-center"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<h2 style={{ marginBottom: '0.5rem', color: '#dc2626' }}>
|
||||
<div
|
||||
className="text-danger-500 text-5xl mb-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-secondary-900 mb-2">
|
||||
Etwas ist schiefgelaufen
|
||||
</h2>
|
||||
<p style={{ marginBottom: '1rem', color: '#6b7280', maxWidth: '400px' }}>
|
||||
<p className="text-sm text-secondary-600 mb-4 max-w-md">
|
||||
Ein unerwarteter Fehler ist aufgetreten. Sie können es erneut versuchen oder die Seite aktualisieren.
|
||||
</p>
|
||||
<details style={{ marginBottom: '1rem', maxWidth: '600px', color: '#9ca3af' }}>
|
||||
<summary style={{ cursor: 'pointer', fontSize: '0.875rem' }}>
|
||||
{traceId && (
|
||||
<p className="text-xs text-secondary-400 mb-4 font-mono" data-testid="error-trace-id">
|
||||
Trace-ID: {traceId}
|
||||
</p>
|
||||
)}
|
||||
<details className="mb-4 max-w-lg text-left text-secondary-500">
|
||||
<summary className="cursor-pointer text-sm hover:text-secondary-700">
|
||||
Fehlerdetails
|
||||
</summary>
|
||||
<pre
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
fontSize: '0.75rem',
|
||||
overflow: 'auto',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<pre className="mt-2 p-3 bg-secondary-50 rounded text-xs overflow-auto max-h-32">
|
||||
{this.state.error.message}
|
||||
{this.state.error.stack ? `\n\n${this.state.error.stack}` : ''}
|
||||
</pre>
|
||||
</details>
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
style={{
|
||||
padding: '0.5rem 1.5rem',
|
||||
backgroundColor: '#2563eb',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '0.375rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="inline-flex items-center px-5 py-2.5 bg-primary-600 text-white font-medium rounded-md hover:bg-primary-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 transition-colors min-h-touch"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center px-5 py-2.5 bg-secondary-100 text-secondary-700 font-medium rounded-md hover:bg-secondary-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400 focus-visible:ring-offset-2 transition-colors min-h-touch"
|
||||
>
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useAIUIControl } from '@/hooks/useAIUIControl';
|
||||
import { PluginRegistry } from '@/components/plugins/PluginRegistry';
|
||||
import { AIUIControlIndicator } from '@/components/ai-ui-control/AIUIControlIndicator';
|
||||
import { WindowContainer } from '@/components/window/WindowContainer';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
||||
import { WelcomeDialog } from '@/components/onboarding/WelcomeDialog';
|
||||
import { OnboardingTour } from '@/components/onboarding/OnboardingTour';
|
||||
import { useOnboardingStore } from '@/store/onboardingStore';
|
||||
|
||||
@@ -7,7 +7,7 @@ import React from 'react';
|
||||
import { TopBar } from './TopBar';
|
||||
import { ToastContainer } from '@/components/ui/Toast';
|
||||
import { PluginRegistry } from '@/components/plugins/PluginRegistry';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export function StartLayout() {
|
||||
|
||||
@@ -98,8 +98,9 @@ export function TopBar() {
|
||||
<button
|
||||
key={win.id}
|
||||
onClick={() => restoreWindow(win.id)}
|
||||
className="flex items-center gap-1.5 bg-secondary-100 text-secondary-700 px-3 py-1 rounded-md text-sm hover:bg-secondary-200"
|
||||
className="flex items-center gap-1.5 bg-secondary-100 text-secondary-700 px-3 py-1 rounded-md text-sm hover:bg-secondary-200 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
title={win.title}
|
||||
aria-label={`Fenster wiederherstellen: ${win.title}`}
|
||||
>
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
<span className="max-w-32 truncate">{win.title}</span>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bell, CheckCheck, Settings, AlertCircle } from 'lucide-react';
|
||||
import { Bell, CheckCheck, Settings, AlertCircle, MessageSquare } from 'lucide-react';
|
||||
import { useNotifications, useMarkNotificationRead } from '@/api/notifications';
|
||||
import { NotificationItem } from './NotificationItem';
|
||||
|
||||
@@ -37,6 +37,10 @@ export function NotificationDropdown() {
|
||||
navigate('/settings/notifications');
|
||||
};
|
||||
|
||||
const handleOpenSystemChannel = () => {
|
||||
navigate('/communication?channel=system');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-full right-0 mt-1 w-80 bg-white rounded-md shadow-lg border border-secondary-200 z-50"
|
||||
@@ -97,10 +101,18 @@ export function NotificationDropdown() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-secondary-200 px-3 py-2">
|
||||
<div className="border-t border-secondary-200 px-3 py-2 space-y-1">
|
||||
<button
|
||||
onClick={handleOpenSystemChannel}
|
||||
className="w-full flex items-center justify-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 font-medium py-1.5 rounded-md hover:bg-primary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('notifications.openSystemChannel', 'System-Channel öffnen')}
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
{t('notifications.openSystemChannel', 'System-Channel öffnen')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNavigateAll}
|
||||
className="w-full flex items-center justify-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 font-medium py-1.5 rounded-md hover:bg-primary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
className="w-full flex items-center justify-center gap-1.5 text-sm text-secondary-600 hover:text-secondary-700 font-medium py-1.5 rounded-md hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('notifications.viewAll', 'Alle anzeigen')}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { Suspense, lazy, Component, ReactNode } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { logError } from '@/utils/errorLogger';
|
||||
|
||||
// ── Error Boundary ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -10,20 +11,79 @@ interface PluginErrorBoundaryProps {
|
||||
|
||||
interface PluginErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
traceId: string | null;
|
||||
}
|
||||
|
||||
function generateTraceId(): string {
|
||||
return 'plugin-err-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
class PluginErrorBoundary extends Component<PluginErrorBoundaryProps, PluginErrorBoundaryState> {
|
||||
state = { hasError: false };
|
||||
state: PluginErrorBoundaryState = { hasError: false, error: null, traceId: null };
|
||||
|
||||
static getDerivedStateFromError(): PluginErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
static getDerivedStateFromError(error: Error): PluginErrorBoundaryState {
|
||||
return { hasError: true, error, traceId: generateTraceId() };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
logError(error, {
|
||||
componentStack: errorInfo.componentStack,
|
||||
pluginName: this.props.pluginName,
|
||||
trace_id: this.state.traceId,
|
||||
source: 'PluginErrorBoundary',
|
||||
});
|
||||
}
|
||||
|
||||
handleRetry = (): void => {
|
||||
this.setState({ hasError: false, error: null, traceId: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-red-600" role="alert">
|
||||
Failed to load plugin: {this.props.pluginName}
|
||||
<div
|
||||
className="flex flex-col items-center justify-center min-h-[300px] p-8 text-center"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="text-danger-500 text-4xl mb-3" aria-hidden="true">⚠️</div>
|
||||
<h2 className="text-lg font-bold text-secondary-900 mb-2">
|
||||
Plugin konnte nicht geladen werden
|
||||
</h2>
|
||||
<p className="text-sm text-secondary-600 mb-3">
|
||||
Das Plugin „{this.props.pluginName}" hat einen Fehler verursacht.
|
||||
</p>
|
||||
{this.state.traceId && (
|
||||
<p className="text-xs text-secondary-400 mb-3 font-mono" data-testid="plugin-error-trace-id">
|
||||
Trace-ID: {this.state.traceId}
|
||||
</p>
|
||||
)}
|
||||
{this.state.error && (
|
||||
<details className="mb-4 max-w-lg text-left text-secondary-500">
|
||||
<summary className="cursor-pointer text-sm hover:text-secondary-700">
|
||||
Fehlerdetails
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-secondary-50 rounded text-xs overflow-auto max-h-32">
|
||||
{this.state.error.message}
|
||||
{this.state.error.stack ? `\n\n${this.state.error.stack}` : ''}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-md hover:bg-primary-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 transition-colors min-h-touch"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center px-4 py-2 bg-secondary-100 text-secondary-700 text-sm font-medium rounded-md hover:bg-secondary-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400 transition-colors min-h-touch"
|
||||
>
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user