feat(M5): Plugin-MiniApps — dms, mail, wiki, graph_rag, automation (#363)
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- 5 Manifest-Beiträge (MiniAppContribution): dms_folders (dms:read), mail_unread (mail:read), wiki_recent (wiki:read), graph_overview (graph:read), automation_status (automation:read) — je settings_schema max_items, order 60-100 - 5 Frontend-Widgets auf bestehenden API-Clients (keine neuen Backend-Endpoints): DmsFoldersWidget, MailUnreadWidget, WikiRecentWidget, GraphOverviewWidget, AutomationStatusWidget - MiniAppHost-Registry +5; tsc clean, build OK - Tests: M5 7/7 (TDD rot->gruen), Backend-Regression 53/53, Vitest 22/22
This commit is contained in:
@@ -19,6 +19,7 @@ from app.plugins.manifest import (
|
||||
FrontendMenuItem,
|
||||
FrontendPageRoute,
|
||||
FrontendSettingsPage,
|
||||
MiniAppContribution,
|
||||
PluginManifest,
|
||||
PluginRouteDef,
|
||||
)
|
||||
@@ -63,6 +64,25 @@ class AutomationPlugin(BasePlugin):
|
||||
"workflow.timeout",
|
||||
],
|
||||
migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.sql", "0004_run_steps_phase_f.sql"],
|
||||
miniapps=[
|
||||
MiniAppContribution(
|
||||
app_id="automation_status",
|
||||
name="Automationen",
|
||||
icon="Workflow",
|
||||
description="Aktive und inaktive Automations-Definitionen auf einen Blick.",
|
||||
permission="automation:read",
|
||||
settings_schema={
|
||||
"fields": [
|
||||
{"name": "max_items", "label": "Max. Einträge", "type": "number", "default": 6},
|
||||
]
|
||||
},
|
||||
col_span=2,
|
||||
row_span=1,
|
||||
hosts=["chat", "dashboard", "window"],
|
||||
component="@/components/dashboard/AutomationStatusWidget",
|
||||
order=100,
|
||||
),
|
||||
],
|
||||
permissions=[
|
||||
"automation:read",
|
||||
"automation:write",
|
||||
|
||||
@@ -6,6 +6,7 @@ from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import (
|
||||
FrontendMenuItem,
|
||||
FrontendPageRoute,
|
||||
MiniAppContribution,
|
||||
PluginManifest,
|
||||
PluginRouteDef,
|
||||
)
|
||||
@@ -29,6 +30,25 @@ class DmsPlugin(BasePlugin):
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
miniapps=[
|
||||
MiniAppContribution(
|
||||
app_id="dms_folders",
|
||||
name="DMS-Ordner",
|
||||
icon="FolderOpen",
|
||||
description="Ordnerübersicht des Dokumentenmanagements mit Dateizählern.",
|
||||
permission="dms:read",
|
||||
settings_schema={
|
||||
"fields": [
|
||||
{"name": "max_items", "label": "Max. Ordner", "type": "number", "default": 6},
|
||||
]
|
||||
},
|
||||
col_span=2,
|
||||
row_span=1,
|
||||
hosts=["chat", "dashboard", "window"],
|
||||
component="@/components/dashboard/DmsFoldersWidget",
|
||||
order=60,
|
||||
),
|
||||
],
|
||||
permissions=[
|
||||
"dms:read",
|
||||
"dms:write",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||
from app.plugins.manifest import MiniAppContribution, PluginManifest, PluginRouteDef
|
||||
|
||||
|
||||
class GraphRAGPlugin(BasePlugin):
|
||||
@@ -24,6 +24,25 @@ class GraphRAGPlugin(BasePlugin):
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
miniapps=[
|
||||
MiniAppContribution(
|
||||
app_id="graph_overview",
|
||||
name="Wissens-Graph",
|
||||
icon="Share2",
|
||||
description="Beziehungsübersicht des Knowledge-Graphs.",
|
||||
permission="graph:read",
|
||||
settings_schema={
|
||||
"fields": [
|
||||
{"name": "max_items", "label": "Max. Beziehungen", "type": "number", "default": 6},
|
||||
]
|
||||
},
|
||||
col_span=2,
|
||||
row_span=1,
|
||||
hosts=["chat", "dashboard", "window"],
|
||||
component="@/components/dashboard/GraphOverviewWidget",
|
||||
order=90,
|
||||
),
|
||||
],
|
||||
permissions=[
|
||||
"graph:read",
|
||||
"graph:write",
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.plugins.manifest import (
|
||||
FrontendMenuItem,
|
||||
FrontendPageRoute,
|
||||
FrontendSettingsPage,
|
||||
MiniAppContribution,
|
||||
PluginManifest,
|
||||
PluginRouteDef,
|
||||
)
|
||||
@@ -138,6 +139,25 @@ class MailPlugin(BasePlugin):
|
||||
],
|
||||
events=[],
|
||||
migrations=["0001_initial.sql", "0006_flag_type.sql", "0007_sync_queue.sql", "0008_sync_queue_deleted_at.sql", "0009_remove_mail_soft_delete.sql", "0010_add_deleted_at.sql"],
|
||||
miniapps=[
|
||||
MiniAppContribution(
|
||||
app_id="mail_unread",
|
||||
name="Postfach-Status",
|
||||
icon="Mail",
|
||||
description="Ungelesene E-Mails je Konto und Ordner.",
|
||||
permission="mail:read",
|
||||
settings_schema={
|
||||
"fields": [
|
||||
{"name": "max_items", "label": "Max. Ordner", "type": "number", "default": 6},
|
||||
]
|
||||
},
|
||||
col_span=2,
|
||||
row_span=1,
|
||||
hosts=["chat", "dashboard", "window"],
|
||||
component="@/components/dashboard/MailUnreadWidget",
|
||||
order=70,
|
||||
),
|
||||
],
|
||||
permissions=["mail:read", "mail:send", "mail:config", "mail:share", "mail:write", "mail:delete"],
|
||||
menu_items=[
|
||||
FrontendMenuItem(label_key='nav.mail', label='E-Mail', path='/mail', icon='Mail', order=30, permission='mail:read'),
|
||||
|
||||
@@ -4,7 +4,13 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef
|
||||
from app.plugins.manifest import (
|
||||
FrontendMenuItem,
|
||||
FrontendPageRoute,
|
||||
MiniAppContribution,
|
||||
PluginManifest,
|
||||
PluginRouteDef,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,6 +25,25 @@ class WikiPlugin(BasePlugin):
|
||||
routes=[
|
||||
PluginRouteDef(path="/api/v1/wiki", module="app.plugins.builtins.wiki.routes", router_attr="router"),
|
||||
],
|
||||
miniapps=[
|
||||
MiniAppContribution(
|
||||
app_id="wiki_recent",
|
||||
name="Wiki-Neuigkeiten",
|
||||
icon="BookOpen",
|
||||
description="Zuletzt aktualisierte Wiki-Artikel.",
|
||||
permission="wiki:read",
|
||||
settings_schema={
|
||||
"fields": [
|
||||
{"name": "max_items", "label": "Max. Artikel", "type": "number", "default": 5},
|
||||
]
|
||||
},
|
||||
col_span=2,
|
||||
row_span=1,
|
||||
hosts=["chat", "dashboard", "window"],
|
||||
component="@/components/dashboard/WikiRecentWidget",
|
||||
order=80,
|
||||
),
|
||||
],
|
||||
permissions=["wiki:read", "wiki:write", "wiki:delete", "wiki:admin"],
|
||||
menu_items=[FrontendMenuItem(label_key="wiki.menu.wiki", label="Wiki", path="/wiki", icon="BookOpen", permission="wiki:read")],
|
||||
page_routes=[FrontendPageRoute(path="/wiki", component="@/pages/Wiki", permission="wiki:read")],
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* AutomationStatusWidget — automation definitions overview (Phase M5).
|
||||
*
|
||||
* Plugin miniapp `automation_status` (automation plugin, automation:read).
|
||||
* Uses the existing automation API client (useAutomations hook).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Workflow, Play, Pause } from 'lucide-react';
|
||||
import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
|
||||
import { useAutomations } from '@/api/automation';
|
||||
|
||||
export function AutomationStatusWidget({ settings }: WidgetComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const maxItems = Math.max(1, Math.min(24, Number(settings?.max_items ?? 6) || 6));
|
||||
const { data, isLoading, isError } = useAutomations();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="animate-pulse h-24 bg-secondary-100 rounded" data-testid="automation-status-loading" />;
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="text-sm text-secondary-500" data-testid="automation-status-error">
|
||||
{t('dashboard.widgetError')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const automations = data ?? [];
|
||||
const active = automations.filter((a) => a.active).length;
|
||||
const inactive = automations.length - active;
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="automation-status-widget">
|
||||
<div className="flex items-center gap-2">
|
||||
<Workflow className="w-5 h-5 text-primary-600" aria-hidden="true" />
|
||||
<span className="text-sm text-secondary-700">
|
||||
{active} {t('automation.active', 'aktiv')} · {inactive} {t('automation.inactive', 'inaktiv')}
|
||||
</span>
|
||||
</div>
|
||||
{automations.length === 0 && (
|
||||
<p className="text-sm text-secondary-500">{t('automation.none', 'Keine Automationen definiert.')}</p>
|
||||
)}
|
||||
{automations.slice(0, maxItems).map((a) => (
|
||||
<div key={a.id} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="text-secondary-700 truncate" title={a.description || a.name}>
|
||||
{a.name}
|
||||
</span>
|
||||
{a.active ? (
|
||||
<Play className="w-3.5 h-3.5 text-success-600 shrink-0" aria-label={t('automation.active', 'aktiv')} />
|
||||
) : (
|
||||
<Pause className="w-3.5 h-3.5 text-secondary-400 shrink-0" aria-label={t('automation.inactive', 'inaktiv')} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* DmsFoldersWidget — DMS folder overview with file counts (Phase M5).
|
||||
*
|
||||
* Plugin miniapp `dms_folders` (dms plugin, dms:read). Uses the existing
|
||||
* DMS API client — no new backend endpoints.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
|
||||
import { fetchFolders } from '@/api/dms';
|
||||
|
||||
export function DmsFoldersWidget({ settings }: WidgetComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const maxItems = Math.max(1, Math.min(24, Number(settings?.max_items ?? 6) || 6));
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['dmsFolders'],
|
||||
queryFn: () => fetchFolders(),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="animate-pulse h-24 bg-secondary-100 rounded" data-testid="dms-folders-loading" />;
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="text-sm text-secondary-500" data-testid="dms-folders-error">
|
||||
{t('dashboard.widgetError')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// fetchFolders returns a tree — flatten top-level folders first
|
||||
const folders = (data ?? []).slice(0, maxItems);
|
||||
if (folders.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-secondary-500" data-testid="dms-folders-empty">
|
||||
{t('dms.noFolders', 'Keine Ordner vorhanden.')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="dms-folders-widget">
|
||||
{folders.map((f) => (
|
||||
<div key={f.id} className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 text-sm text-secondary-700 truncate">
|
||||
<FolderOpen className="w-4 h-4 text-primary-600 shrink-0" aria-hidden="true" />
|
||||
{f.name}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-secondary-900" data-testid={`dms-folder-count-${f.id}`}>
|
||||
{f.file_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* GraphOverviewWidget — knowledge graph relationship overview (Phase M5).
|
||||
*
|
||||
* Plugin miniapp `graph_overview` (graph_rag plugin, graph:read). Uses the
|
||||
* existing knowledge API client (graph relationships endpoint).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Share2 } from 'lucide-react';
|
||||
import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
|
||||
import { fetchGraphRelationships } from '@/api/knowledge';
|
||||
|
||||
export function GraphOverviewWidget({ settings }: WidgetComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const maxItems = Math.max(1, Math.min(24, Number(settings?.max_items ?? 6) || 6));
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['graphOverview', maxItems],
|
||||
queryFn: () => fetchGraphRelationships({ page: 1, page_size: maxItems }),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="animate-pulse h-24 bg-secondary-100 rounded" data-testid="graph-overview-loading" />;
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="text-sm text-secondary-500" data-testid="graph-overview-error">
|
||||
{t('dashboard.widgetError')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const items = data?.items ?? [];
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-secondary-500" data-testid="graph-overview-empty">
|
||||
{t('knowledge.noRelationships', 'Keine Beziehungen vorhanden.')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="graph-overview-widget">
|
||||
<div className="flex items-center gap-2 text-sm text-secondary-500">
|
||||
<Share2 className="w-4 h-4" aria-hidden="true" />
|
||||
{data?.total ?? items.length} {t('knowledge.relationships', 'Beziehungen')}
|
||||
</div>
|
||||
{items.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="text-secondary-700 truncate">{r.source_type}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-primary-50 text-primary-700 shrink-0">
|
||||
{r.relationship_type}
|
||||
</span>
|
||||
<span className="text-secondary-700 truncate">{r.target_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* MailUnreadWidget — unread mail counts per account/folder (Phase M5).
|
||||
*
|
||||
* Plugin miniapp `mail_unread` (mail plugin, mail:read). Uses the existing
|
||||
* mail API client (accounts + folders with unread_count).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Mail } from 'lucide-react';
|
||||
import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
|
||||
import { fetchAccounts, fetchFolders } from '@/api/mail';
|
||||
|
||||
export function MailUnreadWidget({ settings }: WidgetComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const maxItems = Math.max(1, Math.min(24, Number(settings?.max_items ?? 6) || 6));
|
||||
const { data: accounts } = useQuery({
|
||||
queryKey: ['mailAccounts'],
|
||||
queryFn: () => fetchAccounts(),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const activeAccounts = (accounts ?? []).filter((a) => a.is_active);
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['mailFolders', activeAccounts.map((a) => a.id).join(',')],
|
||||
queryFn: async () => {
|
||||
const results = await Promise.all(
|
||||
activeAccounts.map(async (account) => ({
|
||||
account,
|
||||
folders: await fetchFolders(account.id),
|
||||
}))
|
||||
);
|
||||
return results;
|
||||
},
|
||||
enabled: activeAccounts.length > 0,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="animate-pulse h-24 bg-secondary-100 rounded" data-testid="mail-unread-loading" />;
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="text-sm text-secondary-500" data-testid="mail-unread-error">
|
||||
{t('dashboard.widgetError')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = (data ?? []).flatMap(({ account, folders }) =>
|
||||
folders
|
||||
.filter((f) => f.unread_count > 0)
|
||||
.map((f) => ({ key: `${account.id}-${f.id}`, account: account.email, folder: f.name, unread: f.unread_count }))
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-secondary-500" data-testid="mail-unread-empty">
|
||||
<Mail className="w-4 h-4" aria-hidden="true" />
|
||||
{t('mail.noUnread', 'Keine ungelesenen E-Mails.')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="mail-unread-widget">
|
||||
{rows.slice(0, maxItems).map((r) => (
|
||||
<div key={r.key} className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-secondary-700 truncate" title={`${r.account} / ${r.folder}`}>
|
||||
{r.folder}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-primary-700" data-testid={`mail-unread-${r.key}`}>
|
||||
{r.unread}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,21 @@ const widgetRegistry: Record<string, React.LazyExoticComponent<React.ComponentTy
|
||||
'@/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 {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* WikiRecentWidget — recently updated wiki articles (Phase M5).
|
||||
*
|
||||
* Plugin miniapp `wiki_recent` (wiki plugin, wiki:read). Uses the existing
|
||||
* knowledge API client (wiki articles endpoint).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
|
||||
import { fetchWikiArticles } from '@/api/knowledge';
|
||||
|
||||
export function WikiRecentWidget({ settings }: WidgetComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const maxItems = Math.max(1, Math.min(20, Number(settings?.max_items ?? 5) || 5));
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['wikiRecent', maxItems],
|
||||
queryFn: () => fetchWikiArticles({ page: 1, page_size: maxItems }),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="animate-pulse h-24 bg-secondary-100 rounded" data-testid="wiki-recent-loading" />;
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="text-sm text-secondary-500" data-testid="wiki-recent-error">
|
||||
{t('dashboard.widgetError')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const articles = data?.items ?? [];
|
||||
if (articles.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-secondary-500" data-testid="wiki-recent-empty">
|
||||
{t('wiki.noArticles', 'Keine Artikel vorhanden.')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="wiki-recent-widget">
|
||||
{articles.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-primary-600 shrink-0" aria-hidden="true" />
|
||||
<span className="text-sm text-secondary-700 truncate flex-1" title={a.title}>
|
||||
{a.title}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400 shrink-0">v{a.version}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""M5 — Plugin-MiniApps tests.
|
||||
|
||||
Each business plugin contributes its MiniApps via the manifest
|
||||
contribution (same pattern as contacts_stats from M4): dms, mail, wiki,
|
||||
graph_rag and automation ship one dashboard app each, wired to the
|
||||
plugin's own permission and frontend component.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_registry():
|
||||
from app.plugins.miniapp_registry import reset_miniapp_registry
|
||||
|
||||
reset_miniapp_registry()
|
||||
yield
|
||||
reset_miniapp_registry()
|
||||
|
||||
|
||||
# (plugin_name, app_id, permission, component, order)
|
||||
EXPECTED_APPS: list[tuple[str, str, str, str, int]] = [
|
||||
("dms", "dms_folders", "dms:read", "@/components/dashboard/DmsFoldersWidget", 60),
|
||||
("mail", "mail_unread", "mail:read", "@/components/dashboard/MailUnreadWidget", 70),
|
||||
("wiki", "wiki_recent", "wiki:read", "@/components/dashboard/WikiRecentWidget", 80),
|
||||
("graph_rag", "graph_overview", "graph:read", "@/components/dashboard/GraphOverviewWidget", 90),
|
||||
("automation", "automation_status", "automation:read", "@/components/dashboard/AutomationStatusWidget", 100),
|
||||
]
|
||||
|
||||
|
||||
class TestPluginMiniAppContributions:
|
||||
@pytest.mark.parametrize("plugin_name,app_id,permission,component,order", EXPECTED_APPS)
|
||||
def test_manifest_contains_miniapp(
|
||||
self, plugin_name: str, app_id: str, permission: str, component: str, order: int
|
||||
):
|
||||
"""The plugin manifest declares the MiniApp with the right fields."""
|
||||
import importlib
|
||||
|
||||
module = importlib.import_module(f"app.plugins.builtins.{plugin_name}.plugin")
|
||||
plugin_cls = getattr(module, f"{plugin_name.replace('_', '').title().replace('Graphrag', 'GraphRAG')}Plugin")
|
||||
contributions = plugin_cls().manifest.miniapps
|
||||
found = [m for m in contributions if m.app_id == app_id]
|
||||
assert len(found) == 1, f"{plugin_name}: miniapp {app_id} missing"
|
||||
app = found[0]
|
||||
assert app.permission == permission
|
||||
assert app.component == component
|
||||
assert app.order == order
|
||||
assert "dashboard" in app.hosts
|
||||
|
||||
def test_lifecycle_registers_all_plugin_miniapps(self):
|
||||
"""BasePlugin._register_manifest_miniapps carries component (M4 fix)
|
||||
for every contributing plugin — all five apps land in the registry."""
|
||||
from app.plugins.miniapp_registry import get_miniapp_registry
|
||||
|
||||
registry = get_miniapp_registry()
|
||||
for plugin_name, app_id, _permission, component, _order in EXPECTED_APPS:
|
||||
import importlib
|
||||
|
||||
module = importlib.import_module(f"app.plugins.builtins.{plugin_name}.plugin")
|
||||
plugin_cls = getattr(
|
||||
module, f"{plugin_name.replace('_', '').title().replace('Graphrag', 'GraphRAG')}Plugin"
|
||||
)
|
||||
plugin_cls()._register_manifest_miniapps()
|
||||
|
||||
app = registry.get_app(app_id)
|
||||
assert app is not None, f"{app_id} not registered"
|
||||
assert app.component == component, f"{app_id}: component lost"
|
||||
|
||||
def test_settings_schemas_present(self):
|
||||
"""List-style widgets expose max_items in their settings_schema so the
|
||||
generic settings form (M3) can render it."""
|
||||
import importlib
|
||||
|
||||
for plugin_name, app_id, *_rest in EXPECTED_APPS:
|
||||
module = importlib.import_module(f"app.plugins.builtins.{plugin_name}.plugin")
|
||||
plugin_cls = getattr(
|
||||
module, f"{plugin_name.replace('_', '').title().replace('Graphrag', 'GraphRAG')}Plugin"
|
||||
)
|
||||
contributions = plugin_cls().manifest.miniapps
|
||||
app = next(m for m in contributions if m.app_id == app_id)
|
||||
fields = app.settings_schema.get("fields", [])
|
||||
assert any(f["name"] == "max_items" for f in fields), (
|
||||
f"{app_id}: max_items missing in settings_schema"
|
||||
)
|
||||
Reference in New Issue
Block a user