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,
|
FrontendMenuItem,
|
||||||
FrontendPageRoute,
|
FrontendPageRoute,
|
||||||
FrontendSettingsPage,
|
FrontendSettingsPage,
|
||||||
|
MiniAppContribution,
|
||||||
PluginManifest,
|
PluginManifest,
|
||||||
PluginRouteDef,
|
PluginRouteDef,
|
||||||
)
|
)
|
||||||
@@ -63,6 +64,25 @@ class AutomationPlugin(BasePlugin):
|
|||||||
"workflow.timeout",
|
"workflow.timeout",
|
||||||
],
|
],
|
||||||
migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.sql", "0004_run_steps_phase_f.sql"],
|
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=[
|
permissions=[
|
||||||
"automation:read",
|
"automation:read",
|
||||||
"automation:write",
|
"automation:write",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from app.plugins.base import BasePlugin
|
|||||||
from app.plugins.manifest import (
|
from app.plugins.manifest import (
|
||||||
FrontendMenuItem,
|
FrontendMenuItem,
|
||||||
FrontendPageRoute,
|
FrontendPageRoute,
|
||||||
|
MiniAppContribution,
|
||||||
PluginManifest,
|
PluginManifest,
|
||||||
PluginRouteDef,
|
PluginRouteDef,
|
||||||
)
|
)
|
||||||
@@ -29,6 +30,25 @@ class DmsPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
events=[],
|
events=[],
|
||||||
migrations=["0001_initial.sql"],
|
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=[
|
permissions=[
|
||||||
"dms:read",
|
"dms:read",
|
||||||
"dms:write",
|
"dms:write",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.plugins.base import BasePlugin
|
from app.plugins.base import BasePlugin
|
||||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
from app.plugins.manifest import MiniAppContribution, PluginManifest, PluginRouteDef
|
||||||
|
|
||||||
|
|
||||||
class GraphRAGPlugin(BasePlugin):
|
class GraphRAGPlugin(BasePlugin):
|
||||||
@@ -24,6 +24,25 @@ class GraphRAGPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
events=[],
|
events=[],
|
||||||
migrations=["0001_initial.sql"],
|
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=[
|
permissions=[
|
||||||
"graph:read",
|
"graph:read",
|
||||||
"graph:write",
|
"graph:write",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.plugins.manifest import (
|
|||||||
FrontendMenuItem,
|
FrontendMenuItem,
|
||||||
FrontendPageRoute,
|
FrontendPageRoute,
|
||||||
FrontendSettingsPage,
|
FrontendSettingsPage,
|
||||||
|
MiniAppContribution,
|
||||||
PluginManifest,
|
PluginManifest,
|
||||||
PluginRouteDef,
|
PluginRouteDef,
|
||||||
)
|
)
|
||||||
@@ -138,6 +139,25 @@ class MailPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
events=[],
|
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"],
|
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"],
|
permissions=["mail:read", "mail:send", "mail:config", "mail:share", "mail:write", "mail:delete"],
|
||||||
menu_items=[
|
menu_items=[
|
||||||
FrontendMenuItem(label_key='nav.mail', label='E-Mail', path='/mail', icon='Mail', order=30, permission='mail:read'),
|
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
|
import logging
|
||||||
|
|
||||||
from app.plugins.base import BasePlugin
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -19,6 +25,25 @@ class WikiPlugin(BasePlugin):
|
|||||||
routes=[
|
routes=[
|
||||||
PluginRouteDef(path="/api/v1/wiki", module="app.plugins.builtins.wiki.routes", router_attr="router"),
|
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"],
|
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")],
|
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")],
|
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(() =>
|
'@/components/dashboard/SystemMetricsWidget': lazy(() =>
|
||||||
import('@/components/dashboard/SystemMetricsWidget').then((m) => ({ default: m.SystemMetricsWidget }))
|
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 {
|
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