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

210 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { Suspense, lazy, Component, ReactNode } from 'react';
import { Loader2 } from 'lucide-react';
import { logError } from '@/utils/errorLogger';
// ── 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);
}
class PluginErrorBoundary extends Component<PluginErrorBoundaryProps, PluginErrorBoundaryState> {
state: PluginErrorBoundaryState = { hasError: false, error: null, traceId: null };
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="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>
);
}
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),
// BUG (ghost page) fixed in Block I-D: page now exists.
'@/pages/AIAssistant': () => import('@/pages/AIAssistant').then(normalizeModule),
'@/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.
};
// ── 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(/^@\//, '../../');
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>
);
}