Files
leocrm/frontend/src/components/plugins/PluginLoader.tsx
T

146 lines
5.3 KiB
TypeScript
Raw Normal View History

2026-07-23 19:01:18 +02:00
import React, { Suspense, lazy, Component, ReactNode } from 'react';
import { Loader2 } from 'lucide-react';
import { logError } from '@/utils/errorLogger';
2026-07-23 19:01:18 +02:00
// ── Error Boundary ──────────────────────────────────────────────────────
interface PluginErrorBoundaryProps {
children: ReactNode;
pluginName: string;
}
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);
2026-07-23 19:01:18 +02:00
}
class PluginErrorBoundary extends Component<PluginErrorBoundaryProps, PluginErrorBoundaryState> {
state: PluginErrorBoundaryState = { hasError: false, error: null, traceId: null };
2026-07-23 19:01:18 +02:00
static getDerivedStateFromError(error: Error): PluginErrorBoundaryState {
return { hasError: true, error, traceId: generateTraceId() };
2026-07-23 19:01:18 +02:00
}
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 });
};
2026-07-23 19:01:18 +02:00
render() {
if (this.state.hasError) {
return (
<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>
2026-07-23 19:01:18 +02:00
</div>
);
}
return this.props.children;
}
}
// ── Lazy Component Cache ────────────────────────────────────────────────
const componentCache = new Map<string, React.LazyExoticComponent<React.ComponentType<any>>>();
function getLazyComponent(componentPath: string): React.LazyExoticComponent<React.ComponentType<any>> {
if (componentCache.has(componentPath)) {
return componentCache.get(componentPath)!;
}
// Convert @/pages/Mail to ../../pages/Mail for dynamic import
// PluginLoader.tsx is in src/components/plugins/, so ../../ resolves to src/
const importPath = componentPath.replace(/^@\//, '../../');
2026-07-23 19:01:18 +02:00
const LazyComp = lazy(() =>
import(/* @vite-ignore */ importPath).then((m) => ({
default: m.default || m[Object.keys(m)[0]],
}))
);
componentCache.set(componentPath, LazyComp);
return LazyComp;
}
// ── PluginPage Component ───────────────────────────────────────────────
interface PluginPageProps {
component: string;
pluginName: string;
}
/**
* PluginPage — dynamically loads a plugin page component using React.lazy().
* Wraps the lazy component in Suspense with a centered spinner fallback
* and an ErrorBoundary that shows a fallback message on failure.
*/
export function PluginPage({ component, pluginName }: PluginPageProps) {
const LazyComp = getLazyComponent(component);
return (
<PluginErrorBoundary pluginName={pluginName}>
<Suspense
fallback={
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 className="animate-spin h-8 w-8 text-primary-500" />
</div>
}
>
<LazyComp />
</Suspense>
</PluginErrorBoundary>
);
}