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

210 lines
9.1 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;
}
}
// ── Static Chunk Map (ARCH-019) ─────────────────────────────────────────
//
// Vite cannot statically analyze dynamic import paths built at runtime
// (`import(/* @vite-ignore */ path)`), so production builds would fail to
// load plugin pages. All plugin-contributed component paths are known at
// build time — they are declared in the backend manifests — so we register
// them here as explicit lazy imports. Vite chunks each one correctly.
//
// Unknown paths (e.g. third-party plugins added later) fall back to the
// runtime dynamic import, which works in dev mode.
type ComponentModule = Record<string, unknown> & { default?: React.ComponentType<any> };
type LazyComponentFactory = () => Promise<{ default: React.ComponentType<any> }>;
/**
* Normalize a module namespace to the { default } shape React.lazy expects:
* uses the default export when present, otherwise the first named export
* (same fallback as the runtime dynamic-import path below).
*/
function normalizeModule(m: ComponentModule): { default: React.ComponentType<any> } {
const Comp = (m.default ?? m[Object.keys(m)[0]]) as React.ComponentType<any>;
return { default: Comp };
}
const STATIC_COMPONENT_MAP: Record<string, LazyComponentFactory> = {
// Pages
'@/pages/AgentDashboard': () => import('@/pages/AgentDashboard').then(normalizeModule),
// NOTE: @/pages/AIAssistant is declared in the backend manifest but does
// not exist as a frontend page — ghost component, see PROGRESS.md.
'@/pages/AISettings': () => import('@/pages/AISettings').then(normalizeModule),
'@/pages/AutomationDashboard': () => import('@/pages/AutomationDashboard').then(normalizeModule),
'@/pages/AutomationSettings': () => import('@/pages/AutomationSettings').then(normalizeModule),
'@/pages/Calendar': () => import('@/pages/Calendar').then(normalizeModule),
'@/pages/Communication': () => import('@/pages/Communication').then(normalizeModule),
'@/pages/Dms': () => import('@/pages/Dms').then(normalizeModule),
'@/pages/DmsTrash': () => import('@/pages/DmsTrash').then(normalizeModule),
'@/pages/GlobalSearchResults': () => import('@/pages/GlobalSearchResults').then(normalizeModule),
'@/pages/ImportExport': () => import('@/pages/ImportExport').then(normalizeModule),
'@/pages/Mail': () => import('@/pages/Mail').then(normalizeModule),
'@/pages/MailSettings': () => import('@/pages/MailSettings').then(normalizeModule),
'@/pages/ProactiveAISettings': () => import('@/pages/ProactiveAISettings').then(normalizeModule),
'@/pages/Reports': () => import('@/pages/Reports').then(normalizeModule),
'@/pages/SettingsGroups': () => import('@/pages/SettingsGroups').then(normalizeModule),
'@/pages/SettingsNotifications': () => import('@/pages/SettingsNotifications').then(normalizeModule),
'@/pages/SettingsRoles': () => import('@/pages/SettingsRoles').then(normalizeModule),
'@/pages/SettingsUsers': () => import('@/pages/SettingsUsers').then(normalizeModule),
'@/pages/Tasks': () => import('@/pages/Tasks').then(normalizeModule),
'@/pages/Wiki': () => import('@/pages/Wiki').then(normalizeModule),
'@/pages/Workflows': () => import('@/pages/Workflows').then(normalizeModule),
// NOTE: The backend manifests also declare contact detail tabs
// (@/components/contact/Contact*Tab) whose components do not exist in the
// frontend yet — they are intentionally NOT registered here; they fall
// through to the runtime fallback and surface via the error boundary.
// See PROGRESS.md "Ghost components" finding.
};
2026-07-23 19:01:18 +02:00
// ── 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)!;
}
// Known plugin components: statically chunked at build time (ARCH-019)
const factory = STATIC_COMPONENT_MAP[componentPath];
if (factory) {
const LazyComp = lazy(factory);
componentCache.set(componentPath, LazyComp);
return LazyComp;
}
// Fallback for unknown paths (third-party plugins): runtime dynamic
// import. Works in dev; production needs the path registered above.
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>
);
}