diff --git a/app/plugins/builtins/contacts/plugin.py b/app/plugins/builtins/contacts/plugin.py index c014c6c..3e19729 100644 --- a/app/plugins/builtins/contacts/plugin.py +++ b/app/plugins/builtins/contacts/plugin.py @@ -100,7 +100,7 @@ class ContactsPlugin(BasePlugin): page_routes=[ FrontendPageRoute(path='/contacts', component='@/pages/ContactsList', protected=True, permission='contacts:read'), FrontendPageRoute(path='/contacts/:id', component='@/pages/ContactDetailPage', protected=True, permission='contacts:read'), - FrontendPageRoute(path='/contacts/dedup', component='@/pages/DedupMergePage', protected=True, permission='contacts:read'), + FrontendPageRoute(path='/contacts/dedup', component='@/pages/DedupMerge', protected=True, permission='contacts:read'), ], permissions=[ "contacts:read", diff --git a/frontend/src/components/dashboard/MiniAppHost.tsx b/frontend/src/components/dashboard/MiniAppHost.tsx index 10362e8..1dcbf17 100644 --- a/frontend/src/components/dashboard/MiniAppHost.tsx +++ b/frontend/src/components/dashboard/MiniAppHost.tsx @@ -12,46 +12,13 @@ import { useTranslation } from 'react-i18next'; import { AppWindow } from 'lucide-react'; import { Skeleton } from '@/components/ui/Skeleton'; import { useMiniapps, type MiniAppDef } from '@/api/miniapps'; +import { PLUGIN_COMPONENT_MAP } from '@/generated/pluginComponents.generated'; export interface WidgetComponentProps { settings?: Record; } -const widgetRegistry: Record>> = { - '@/components/dashboard/RecentContactsWidget': lazy(() => - import('@/components/dashboard/RecentContactsWidget').then((m) => ({ default: m.RecentContactsWidget })) - ), - '@/components/dashboard/TasksSummaryWidget': lazy(() => - import('@/components/dashboard/TasksSummaryWidget').then((m) => ({ default: m.TasksSummaryWidget })) - ), - '@/components/dashboard/CalendarUpcomingWidget': lazy(() => - import('@/components/dashboard/CalendarUpcomingWidget').then((m) => ({ default: m.CalendarUpcomingWidget })) - ), - '@/components/dashboard/ContactsStatsWidget': lazy(() => - import('@/components/dashboard/ContactsStatsWidget').then((m) => ({ default: m.ContactsStatsWidget })) - ), - '@/components/dashboard/AuditActivityWidget': lazy(() => - import('@/components/dashboard/AuditActivityWidget').then((m) => ({ default: m.AuditActivityWidget })) - ), - '@/components/dashboard/SystemMetricsWidget': lazy(() => - import('@/components/dashboard/SystemMetricsWidget').then((m) => ({ default: m.SystemMetricsWidget })) - ), - '@/components/dashboard/DmsFoldersWidget': lazy(() => - import('@/components/dashboard/DmsFoldersWidget').then((m) => ({ default: m.DmsFoldersWidget })) - ), - '@/components/dashboard/MailUnreadWidget': lazy(() => - import('@/components/dashboard/MailUnreadWidget').then((m) => ({ default: m.MailUnreadWidget })) - ), - '@/components/dashboard/WikiRecentWidget': lazy(() => - import('@/components/dashboard/WikiRecentWidget').then((m) => ({ default: m.WikiRecentWidget })) - ), - '@/components/dashboard/GraphOverviewWidget': lazy(() => - import('@/components/dashboard/GraphOverviewWidget').then((m) => ({ default: m.GraphOverviewWidget })) - ), - '@/components/dashboard/AutomationStatusWidget': lazy(() => - import('@/components/dashboard/AutomationStatusWidget').then((m) => ({ default: m.AutomationStatusWidget })) - ), -}; + interface MiniAppHostProps { appId: string; @@ -86,7 +53,10 @@ export function MiniAppHost({ appId, settings, def }: MiniAppHostProps) { const resolved = def ?? data?.items.find((a) => a.app_id === appId); if (resolved && resolved.component) { - const WidgetComponent = widgetRegistry[resolved.component]; + // Phase Q3: widgets resolve via the GENERATED component map — a plugin + // contributes dashboard widgets through its manifest, no central registry. + const widgetFactory = PLUGIN_COMPONENT_MAP[resolved.component]; + const WidgetComponent = widgetFactory ? lazy(widgetFactory) : undefined; if (WidgetComponent) { return (
diff --git a/frontend/src/components/plugins/PluginLoader.tsx b/frontend/src/components/plugins/PluginLoader.tsx index f1e0514..7734ea7 100644 --- a/frontend/src/components/plugins/PluginLoader.tsx +++ b/frontend/src/components/plugins/PluginLoader.tsx @@ -1,6 +1,7 @@ import React, { Suspense, lazy, Component, ReactNode } from 'react'; import { Loader2 } from 'lucide-react'; import { logError } from '@/utils/errorLogger'; +import { PLUGIN_COMPONENT_MAP } from '@/generated/pluginComponents.generated'; // ── Error Boundary ────────────────────────────────────────────────────── @@ -91,66 +92,18 @@ class PluginErrorBoundary extends Component & { default?: React.ComponentType }; type LazyComponentFactory = () => Promise<{ default: React.ComponentType }>; -/** - * 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 } { - const Comp = (m.default ?? m[Object.keys(m)[0]]) as React.ComponentType; - return { default: Comp }; -} - -const STATIC_COMPONENT_MAP: Record = { - // 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/ContactDetailPage': () => import('@/pages/ContactDetailPage').then((m) => ({ default: m.ContactDetailPage })), - '@/pages/ContactsList': () => import('@/pages/ContactsList').then((m) => ({ default: m.ContactsListPage })), - '@/pages/DedupMergePage': () => import('@/pages/DedupMerge').then((m) => ({ default: m.DedupMergePage })), - '@/pages/DocumentSettings': () => import('@/pages/DocumentSettings').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>>(); @@ -160,8 +113,9 @@ function getLazyComponent(componentPath: string): React.LazyExoticComponent)[componentPath]; if (factory) { const LazyComp = lazy(factory); componentCache.set(componentPath, LazyComp); diff --git a/frontend/src/generated/pluginComponents.generated.ts b/frontend/src/generated/pluginComponents.generated.ts new file mode 100644 index 0000000..4455d61 --- /dev/null +++ b/frontend/src/generated/pluginComponents.generated.ts @@ -0,0 +1,58 @@ +// AUTO-GENERATED by scripts/generate_component_map.py — DO NOT EDIT. +// Regenerate with: python3 scripts/generate_component_map.py +// Sources: app/plugins/builtins/*/plugin.py + app/core/system_miniapps.py +// Failure mode: the generator refuses ghost components (manifest path without +// a matching frontend file), so this map is always loadable. +/* eslint-disable */ +// NOTE: ComponentType — components take their own specific props and +// consumers (PluginLoader, MiniAppHost) pass arbitrary props; `unknown` +// would forbid all JSX attributes (Phase Q3). +import type { ComponentType } from 'react'; + +type ComponentModule = Record & { default?: ComponentType }; +export type LazyComponentFactory = () => Promise<{ default: ComponentType }>; + +function normalizeModule(m: ComponentModule): { default: ComponentType } { + const Comp = (m.default ?? m[Object.keys(m)[0]]) as ComponentType; + return { default: Comp }; +} + +export const PLUGIN_COMPONENT_MAP: Record = { + '@/components/dashboard/AuditActivityWidget': () => import('@/components/dashboard/AuditActivityWidget').then((m) => ({ default: m.AuditActivityWidget })), + '@/components/dashboard/AutomationStatusWidget': () => import('@/components/dashboard/AutomationStatusWidget').then((m) => ({ default: m.AutomationStatusWidget })), + '@/components/dashboard/CalendarUpcomingWidget': () => import('@/components/dashboard/CalendarUpcomingWidget').then((m) => ({ default: m.CalendarUpcomingWidget })), + '@/components/dashboard/ContactsStatsWidget': () => import('@/components/dashboard/ContactsStatsWidget').then((m) => ({ default: m.ContactsStatsWidget })), + '@/components/dashboard/DmsFoldersWidget': () => import('@/components/dashboard/DmsFoldersWidget').then((m) => ({ default: m.DmsFoldersWidget })), + '@/components/dashboard/GraphOverviewWidget': () => import('@/components/dashboard/GraphOverviewWidget').then((m) => ({ default: m.GraphOverviewWidget })), + '@/components/dashboard/MailUnreadWidget': () => import('@/components/dashboard/MailUnreadWidget').then((m) => ({ default: m.MailUnreadWidget })), + '@/components/dashboard/RecentContactsWidget': () => import('@/components/dashboard/RecentContactsWidget').then((m) => ({ default: m.RecentContactsWidget })), + '@/components/dashboard/SystemMetricsWidget': () => import('@/components/dashboard/SystemMetricsWidget').then((m) => ({ default: m.SystemMetricsWidget })), + '@/components/dashboard/TasksSummaryWidget': () => import('@/components/dashboard/TasksSummaryWidget').then((m) => ({ default: m.TasksSummaryWidget })), + '@/components/dashboard/WikiRecentWidget': () => import('@/components/dashboard/WikiRecentWidget').then((m) => ({ default: m.WikiRecentWidget })), + '@/pages/AIAssistant': () => import('@/pages/AIAssistant').then(normalizeModule), + '@/pages/AISettings': () => import('@/pages/AISettings').then((m) => ({ default: m.AISettingsPage })), + '@/pages/AgentDashboard': () => import('@/pages/AgentDashboard').then((m) => ({ default: m.AgentDashboardPage })), + '@/pages/AutomationDashboard': () => import('@/pages/AutomationDashboard').then((m) => ({ default: m.AutomationDashboardPage })), + '@/pages/AutomationSettings': () => import('@/pages/AutomationSettings').then((m) => ({ default: m.AutomationSettingsPage })), + '@/pages/Calendar': () => import('@/pages/Calendar').then(normalizeModule), + '@/pages/Communication': () => import('@/pages/Communication').then(normalizeModule), + '@/pages/ContactDetailPage': () => import('@/pages/ContactDetailPage').then((m) => ({ default: m.ContactDetailPage })), + '@/pages/ContactsList': () => import('@/pages/ContactsList').then((m) => ({ default: m.ContactsListPage })), + '@/pages/DedupMerge': () => import('@/pages/DedupMerge').then((m) => ({ default: m.DedupMergePage })), + '@/pages/Dms': () => import('@/pages/Dms').then((m) => ({ default: m.DmsPage })), + '@/pages/DmsTrash': () => import('@/pages/DmsTrash').then((m) => ({ default: m.DmsTrashPage })), + '@/pages/DocumentSettings': () => import('@/pages/DocumentSettings').then((m) => ({ default: m.DocumentSettingsPage })), + '@/pages/GlobalSearchResults': () => import('@/pages/GlobalSearchResults').then((m) => ({ default: m.GlobalSearchResultsPage })), + '@/pages/ImportExport': () => import('@/pages/ImportExport').then((m) => ({ default: m.ImportExportPage })), + '@/pages/Mail': () => import('@/pages/Mail').then((m) => ({ default: m.MailPage })), + '@/pages/MailSettings': () => import('@/pages/MailSettings').then((m) => ({ default: m.MailSettingsPage })), + '@/pages/ProactiveAISettings': () => import('@/pages/ProactiveAISettings').then((m) => ({ default: m.ProactiveAISettings })), + '@/pages/Reports': () => import('@/pages/Reports').then((m) => ({ default: m.ReportsPage })), + '@/pages/SettingsGroups': () => import('@/pages/SettingsGroups').then((m) => ({ default: m.SettingsGroupsPage })), + '@/pages/SettingsNotifications': () => import('@/pages/SettingsNotifications').then((m) => ({ default: m.SettingsNotificationsPage })), + '@/pages/SettingsRoles': () => import('@/pages/SettingsRoles').then((m) => ({ default: m.SettingsRolesPage })), + '@/pages/SettingsUsers': () => import('@/pages/SettingsUsers').then((m) => ({ default: m.SettingsUsersPage })), + '@/pages/Tasks': () => import('@/pages/Tasks').then((m) => ({ default: m.TasksPage })), + '@/pages/Wiki': () => import('@/pages/Wiki').then((m) => ({ default: m.WikiPage })), + '@/pages/Workflows': () => import('@/pages/Workflows').then((m) => ({ default: m.WorkflowsPage })), +}; diff --git a/scripts/generate_component_map.py b/scripts/generate_component_map.py new file mode 100644 index 0000000..05aa795 --- /dev/null +++ b/scripts/generate_component_map.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Generate the frontend plugin component import map (Phase Q3). + +Scans all builtin plugin manifests (app/plugins/builtins/*/plugin.py) and the +core miniapp definitions (app/core/system_miniapps.py) for frontend component +paths (``component="@/..."``) and generates +``frontend/src/generated/pluginComponents.generated.ts`` with one static +lazy-import per component. + +Why: Vite cannot statically analyze dynamic import() paths built at runtime, +so plugin pages must be registered as explicit lazy imports for production +builds. Previously this lived in two hand-maintained central lists +(PluginLoader STATIC_COMPONENT_MAP + MiniAppHost widgetRegistry) — a new +plugin had to touch core frontend files. Now the map is GENERATED from the +plugin declarations themselves: a plugin contributes its components via the +manifest and this script wires them. + +Guarantees: +- Fails (exit 1) on GHOST components: a manifest path whose file does not + exist aborts the generation instead of shipping a dead entry. +- Detects the export style per file (default export vs. named exports) and + wires the correct import. +- Deterministic output (sorted paths) so CI can diff-check freshness. + +Usage: + python3 scripts/generate_component_map.py # write the file + python3 scripts/generate_component_map.py --check # exit 1 if outdated +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +FRONTEND_SRC = REPO / "frontend" / "src" +OUT_FILE = FRONTEND_SRC / "generated" / "pluginComponents.generated.ts" + +# Sources that declare frontend component paths +PLUGIN_FILES = sorted((REPO / "app" / "plugins" / "builtins").glob("*/plugin.py")) +CORE_FILES = [REPO / "app" / "core" / "system_miniapps.py"] + +PATH_PATTERN = re.compile(r'["\'](@/[^"\']+)["\']') + + +HEADER = """// AUTO-GENERATED by scripts/generate_component_map.py — DO NOT EDIT. +// Regenerate with: python3 scripts/generate_component_map.py +// Sources: app/plugins/builtins/*/plugin.py + app/core/system_miniapps.py +// Failure mode: the generator refuses ghost components (manifest path without +// a matching frontend file), so this map is always loadable. +/* eslint-disable */ +// NOTE: ComponentType — components take their own specific props and +// consumers (PluginLoader, MiniAppHost) pass arbitrary props; `unknown` +// would forbid all JSX attributes (Phase Q3). +import type { ComponentType } from 'react'; + +type ComponentModule = Record & { default?: ComponentType }; +export type LazyComponentFactory = () => Promise<{ default: ComponentType }>; + +function normalizeModule(m: ComponentModule): { default: ComponentType } { + const Comp = (m.default ?? m[Object.keys(m)[0]]) as ComponentType; + return { default: Comp }; +} + +export const PLUGIN_COMPONENT_MAP: Record = { +""" + + +def collect_paths() -> set[str]: + """Extract all @/ component paths from plugin manifests and core files.""" + paths: set[str] = set() + sources = PLUGIN_FILES + CORE_FILES + if not sources: + raise SystemExit("No plugin manifests found — wrong working directory?") + for f in sources: + text = f.read_text(encoding="utf-8") + for line in text.splitlines(): + stripped = line.strip() + # Only lines that actually declare a component field, not comments + if stripped.startswith("#"): + continue + if "component" not in line: + continue + for match in PATH_PATTERN.findall(line): + paths.add(match) + return paths + + +def resolve_file(path_alias: str) -> Path | None: + """Resolve an @/ alias to an existing frontend file.""" + rel = path_alias.replace("@/", "", 1) + base = FRONTEND_SRC / rel + candidates = [ + base.with_suffix(".tsx"), + base.with_suffix(".ts"), + base.with_suffix(".jsx"), + base / "index.tsx", + base / "index.ts", + ] + for c in candidates: + if c.is_file(): + return c + return None + + +def detect_export_style(file: Path, expected_name: str) -> tuple[str, str | None]: + """Inspect the component file and decide how to import it. + + Returns (style, name): + - ("default", None): file has `export default` → use normalizeModule + - ("named", NAME): file has a named export matching the expected name + - ("single", NAME): exactly one named export → normalizeModule would + pick it, but we wire it explicitly for clarity + """ + text = file.read_text(encoding="utf-8") + has_default = bool(re.search(r"^export\s+default\b", text, re.M)) + if has_default: + return "default", None + + names: list[str] = [] + for m in re.finditer(r"^export\s+(?:default\s+)?(?:async\s+)?function\s+(\w+)", text, re.M): + if m.group(1): + names.append(m.group(1)) + for m in re.finditer(r"^export\s+const\s+(\w+)", text, re.M): + names.append(m.group(1)) + for m in re.finditer(r"^export\s+class\s+(\w+)", text, re.M): + names.append(m.group(1)) + + if expected_name in names: + return "named", expected_name + if len(names) == 1: + return "single", names[0] + raise SystemExit( + f"GHOST EXPORT: {file} has no default export and no named export " + f"matching '{expected_name}' (found: {names})" + ) + + +def expected_component_name(file: Path) -> str: + return file.with_suffix("").name + + +def build_map(paths: set[str]) -> str: + lines: list[str] = [] + ghosts: list[str] = [] + for alias in sorted(paths): + file = resolve_file(alias) + if file is None: + ghosts.append(alias) + continue + style, name = detect_export_style(file, expected_component_name(file)) + if style == "default": + lines.append( + f" '{alias}': () => import('{alias}').then(normalizeModule)," + ) + else: + assert name is not None + lines.append( + f" '{alias}': () => " + f"import('{alias}').then((m) => ({{ default: m.{name} }}))," + ) + if ghosts: + print("GHOST components — manifest paths without frontend file:", file=sys.stderr) + for g in ghosts: + print(f" {g}", file=sys.stderr) + raise SystemExit(1) + return HEADER + "\n".join(lines) + "\n};\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="verify the generated file is up to date") + args = parser.parse_args() + + paths = collect_paths() + content = build_map(paths) + + if args.check: + if not OUT_FILE.is_file(): + print(f"MISSING: {OUT_FILE} — run the generator", file=sys.stderr) + return 1 + current = OUT_FILE.read_text(encoding="utf-8") + if current != content: + print(f"OUTDATED: {OUT_FILE} — rerun the generator", file=sys.stderr) + return 1 + print(f"OK: {OUT_FILE.name} up to date ({len(paths)} components)") + return 0 + + OUT_FILE.parent.mkdir(parents=True, exist_ok=True) + OUT_FILE.write_text(content, encoding="utf-8") + print(f"Generated {OUT_FILE} with {len(paths)} components") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())