210 lines
9.1 KiB
TypeScript
210 lines
9.1 KiB
TypeScript
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>
|
||
);
|
||
}
|