feat(M3): Dashboard-Builder — Edit-Modus, Drag&Drop, Palette, Tabs (#361)

- DashboardBuilder: @dnd-kit 12-Spalten-Flow-Grid (seed-konsistent), View/Edit-Schalter, Resize, Tab-Verwaltung, Dashboard-CRUD + Set-Default, Dirty-Save
- MiniAppHost ersetzt DashboardWidgetLoader (lazy Registry + settings-Props); Palette nur renderbare Apps (component-Filter)
- WidgetSettingsForm generisch aus settings_schema; Bestands-Widgets settings-fähig (RecentContacts: limit)
- api/miniapps.ts + api/dashboards.ts (TanStack-Query-Hooks, documents.ts-Muster)
- Dashboard.tsx = Builder-Host (StatCards/SystemMetrics bleiben bis M4); Legacy-Grid/Loader gelöscht, Geister-Test ersetzt
- Tests: Builder 13/13, Page 11/11, i18n de/en, tsc clean, build OK
This commit is contained in:
Agent Zero
2026-08-30 21:28:40 +02:00
parent 74827156d0
commit 26948fdb51
16 changed files with 1257 additions and 221 deletions
@@ -5,11 +5,12 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
import { listEntries, type CalendarEntry } from '@/api/calendar';
import { formatDateTime } from '@/utils/date';
import { Calendar, MapPin } from 'lucide-react';
export function CalendarUpcomingWidget() {
export function CalendarUpcomingWidget({ settings }: WidgetComponentProps) {
const { t } = useTranslation();
const { data, isLoading, isError } = useQuery({
queryKey: ['calendarEntries', 'upcoming'],
@@ -0,0 +1,605 @@
/**
* DashboardBuilder — personal dashboard host + builder (Phase M3).
*
* View mode: renders the active tab's widgets in a 12-column flow grid.
* Edit mode: @dnd-kit sortable grid (reorder with flow re-positioning,
* same flow as the server-side seed), widget palette (MiniApps with a
* frontend component), resize (col/row span), per-instance settings from
* settings_schema, tab management, dashboard CRUD + set-default.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
rectSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
LayoutDashboard,
Pencil,
Check,
Plus,
Trash2,
GripVertical,
Settings2,
Star,
StarOff,
Minus,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { Skeleton } from '@/components/ui/Skeleton';
import {
useDashboards,
useCreateDashboard,
useUpdateDashboard,
useDeleteDashboard,
useSetDefaultDashboard,
type Dashboard,
type DashboardTab,
type DashboardWidget,
} from '@/api/dashboards';
import { useMiniapps, renderableDashboardApps, type MiniAppDef } from '@/api/miniapps';
import { MiniAppHost } from './MiniAppHost';
import { WidgetSettingsForm } from './WidgetSettingsForm';
let widgetIdCounter = 0;
function nextTabId(): string {
widgetIdCounter += 1;
return `tab_${Date.now().toString(36)}_${widgetIdCounter}`;
}
/** Assign col/row from a flow layout over 12 columns (seed-consistent). */
function reflow(widgets: DashboardWidget[]): DashboardWidget[] {
let col = 0;
let row = 0;
return widgets.map((w) => {
const span = Math.max(1, Math.min(12, w.col_span || 1));
if (col + span > 12) {
row += 1;
col = 0;
}
const placed = { ...w, col_span: span, col, row };
col += span;
return placed;
});
}
function normalizeTab(tab: DashboardTab): DashboardTab {
return { ...tab, widgets: reflow(tab.widgets) };
}
interface SortableWidgetProps {
widget: DashboardWidget;
def: MiniAppDef | undefined;
editMode: boolean;
onRemove: () => void;
onResize: (dCol: number, dRow: number) => void;
onOpenSettings: () => void;
}
function SortableWidget({ widget, def, editMode, onRemove, onResize, onOpenSettings }: SortableWidgetProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `${widget.col}-${widget.row}-${widget.app_id}-${widget.col_span}`,
disabled: !editMode,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
gridColumn: `span ${Math.min(12, Math.max(1, widget.col_span))} / span ${Math.min(12, Math.max(1, widget.col_span))}`,
gridRow: `span ${Math.max(1, widget.row_span)} / span ${Math.max(1, widget.row_span)}`,
};
return (
<div
ref={setNodeRef}
style={style}
className={`bg-white rounded-lg shadow-sm border p-4 h-full ${
editMode ? 'border-dashed border-primary-300' : 'border-secondary-200'
}`}
data-testid={`dashboard-widget-${widget.app_id}`}
>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-secondary-700 truncate flex items-center gap-1.5">
{editMode && (
<button
type="button"
className="cursor-move text-secondary-400 hover:text-secondary-600"
aria-label={t('dashboard.dragToReorder')}
{...attributes}
{...listeners}
>
<GripVertical className="w-4 h-4" aria-hidden="true" />
</button>
)}
{def?.name ?? widget.app_id}
</h3>
{editMode && (
<div className="flex items-center gap-1" data-testid="dashboard-widget-controls">
<button
type="button"
onClick={() => onResize(-1, 0)}
className="p-1.5 rounded hover:bg-secondary-100 text-secondary-500"
aria-label={t('dashboard.builder.narrower')}
>
<Minus className="w-3.5 h-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => onResize(1, 0)}
className="p-1.5 rounded hover:bg-secondary-100 text-secondary-500"
aria-label={t('dashboard.builder.wider')}
>
<Plus className="w-3.5 h-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => onResize(0, -1)}
className="p-1.5 rounded hover:bg-secondary-100 text-secondary-500"
aria-label={t('dashboard.builder.shorter')}
>
<Minus className="w-3.5 h-3.5 rotate-90" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => onResize(0, 1)}
className="p-1.5 rounded hover:bg-secondary-100 text-secondary-500"
aria-label={t('dashboard.builder.taller')}
>
<Plus className="w-3.5 h-3.5 rotate-90" aria-hidden="true" />
</button>
<button
type="button"
onClick={onOpenSettings}
className="p-1.5 rounded hover:bg-secondary-100 text-secondary-500"
aria-label={t('dashboard.builder.widgetSettings')}
data-testid="dashboard-widget-settings"
>
<Settings2 className="w-3.5 h-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={onRemove}
className="p-1.5 rounded hover:bg-danger-50 text-danger-500"
aria-label={t('dashboard.builder.removeWidget')}
data-testid="dashboard-widget-remove"
>
<Trash2 className="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
)}
</div>
<MiniAppHost appId={widget.app_id} settings={widget.settings} def={def} />
</div>
);
}
export function DashboardBuilder() {
const { t } = useTranslation();
const { data: dashboards, isLoading } = useDashboards();
const { data: miniappsData } = useMiniapps('dashboard');
const createDashboard = useCreateDashboard();
const updateDashboard = useUpdateDashboard();
const deleteDashboard = useDeleteDashboard();
const setDefaultDashboard = useSetDefaultDashboard();
const appsById = useMemo(() => {
const map = new Map<string, MiniAppDef>();
for (const a of miniappsData?.items ?? []) map.set(a.app_id, a);
return map;
}, [miniappsData]);
const [activeId, setActiveId] = useState<string | null>(null);
const [activeTabIndex, setActiveTabIndex] = useState(0);
const [editMode, setEditMode] = useState(false);
const [layout, setLayout] = useState<Dashboard['layout'] | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState('');
const [deleteOpen, setDeleteOpen] = useState(false);
const [settingsWidget, setSettingsWidget] = useState<DashboardWidget | null>(null);
const [renameTab, setRenameTab] = useState<string | null>(null);
const active = useMemo(
() => dashboards?.find((d) => d.id === activeId) ?? dashboards?.find((d) => d.is_default) ?? dashboards?.[0],
[dashboards, activeId]
);
useEffect(() => {
if (active && (!layout || (editMode === false && layout))) {
// Sync from server when not editing (or first load)
setLayout({ ...active.layout, tabs: active.layout.tabs.map(normalizeTab) });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active?.id, active?.updated_at]);
useEffect(() => {
setActiveTabIndex((i) => Math.min(i, Math.max(0, (layout?.tabs.length ?? 1) - 1)));
}, [layout?.tabs.length]);
const tabs = layout?.tabs ?? [];
const activeTab = tabs[Math.min(activeTabIndex, Math.max(0, tabs.length - 1))];
const dirty =
!!active && !!layout && JSON.stringify(normalizeLayout(layout)) !== JSON.stringify(normalizeLayout(active.layout));
function normalizeLayout(l: Dashboard['layout']): Dashboard['layout'] {
return { version: l.version ?? 1, tabs: l.tabs.map(normalizeTab) };
}
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
function mutateTab(fn: (tab: DashboardTab) => DashboardTab) {
setLayout((prev) => {
if (!prev) return prev;
const nextTabs = prev.tabs.map((tab, i) => (i === activeTabIndex ? fn(tab) : tab));
return { ...prev, tabs: nextTabs };
});
}
function handleDragEnd(event: DragEndEvent) {
const { active: a, over } = event;
if (!over || a.id === over.id || !activeTab) return;
const ids = activeTab.widgets.map((w) => sortableIdFor(w));
const from = ids.indexOf(String(a.id));
const to = ids.indexOf(String(over.id));
if (from < 0 || to < 0 || from === to) return;
mutateTab((tab) => {
const next = arrayMove(tab.widgets, from, to);
return { ...tab, widgets: reflow(next) };
});
}
function sortableIdFor(w: DashboardWidget): string {
return `${w.col}-${w.row}-${w.app_id}-${w.col_span}`;
}
function addWidget(app: MiniAppDef) {
mutateTab((tab) =>
normalizeTab({
...tab,
widgets: [...tab.widgets, {
app_id: app.app_id,
settings: {},
col: 0,
row: 0,
col_span: Math.min(4, Math.max(1, app.col_span || 1)),
row_span: Math.max(1, app.row_span || 1),
}],
})
);
}
function removeWidget(target: DashboardWidget) {
mutateTab((tab) => normalizeTab({ ...tab, widgets: tab.widgets.filter((w) => w !== target) }));
}
function resizeWidget(target: DashboardWidget, dCol: number, dRow: number) {
mutateTab((tab) =>
normalizeTab({
...tab,
widgets: tab.widgets.map((w) =>
w === target
? {
...w,
col_span: Math.max(1, Math.min(12, (w.col_span || 1) + dCol)),
row_span: Math.max(1, Math.min(12, (w.row_span || 1) + dRow)),
}
: w
),
})
);
}
function saveLayout() {
if (!active || !layout) return;
updateDashboard.mutate({ id: active.id, input: { layout: normalizeLayout(layout) } });
setEditMode(false);
}
function addTab() {
const id = nextTabId();
setLayout((prev) =>
prev ? { ...prev, tabs: [...prev.tabs, { id, name: t('dashboard.builder.newTab'), widgets: [] }] } : prev
);
setActiveTabIndex((layout?.tabs.length ?? 0));
}
function removeTab(index: number) {
setLayout((prev) => {
if (!prev || prev.tabs.length <= 1) return prev;
return { ...prev, tabs: prev.tabs.filter((_, i) => i !== index) };
});
setActiveTabIndex(0);
}
if (isLoading) {
return <Skeleton className="h-64" data-testid="dashboard-builder-loading" />;
}
if (!active) {
return (
<p className="text-sm text-secondary-500" data-testid="dashboard-builder-empty">
{t('dashboard.widgetsUnavailable')}
</p>
);
}
const palette = renderableDashboardApps(miniappsData?.items);
return (
<section data-testid="dashboard-builder" aria-label={t('dashboard.title')}>
{/* Dashboard header */}
<div className="flex flex-wrap items-center gap-2 mb-4">
<LayoutDashboard className="w-5 h-5 text-primary-600" aria-hidden="true" />
<select
className="rounded-md border border-secondary-200 px-2 py-1.5 text-sm bg-white"
value={active.id}
onChange={(e) => {
setActiveId(e.target.value);
setEditMode(false);
}}
aria-label={t('dashboard.builder.selectDashboard')}
data-testid="dashboard-select"
>
{(dashboards ?? []).map((d) => (
<option key={d.id} value={d.id}>
{d.name}
{d.is_default ? ' ★' : ''}
</option>
))}
</select>
<Button variant="ghost" size="sm" icon={<Plus className="w-4 h-4" />} onClick={() => setCreateOpen(true)} aria-label={t('dashboard.builder.create')}
data-testid="dashboard-create">
{t('dashboard.builder.create')}
</Button>
<Button
variant="ghost"
size="sm"
icon={active.is_default ? <Star className="w-4 h-4 text-warning-500" /> : <StarOff className="w-4 h-4" />}
onClick={() => setDefaultDashboard.mutate(active.id)}
aria-label={t('dashboard.builder.setDefault')}
data-testid="dashboard-set-default"
>
{t('dashboard.builder.setDefault')}
</Button>
<Button
variant="danger"
size="sm"
icon={<Trash2 className="w-4 h-4" />}
onClick={() => setDeleteOpen(true)}
aria-label={t('dashboard.builder.delete')}
data-testid="dashboard-delete"
>
{t('dashboard.builder.delete')}
</Button>
<div className="flex-1" />
{editMode ? (
<>
<Button variant="ghost" size="sm" onClick={() => { setLayout(normalizeLayout(active.layout)); setEditMode(false); }}
data-testid="dashboard-edit-cancel">
{t('common.cancel')}
</Button>
<Button variant="primary" size="sm" icon={<Check className="w-4 h-4" />} onClick={saveLayout}
disabled={!dirty || updateDashboard.isPending} data-testid="dashboard-save" aria-label={t('dashboard.builder.save')}>
{t('dashboard.builder.save')}
</Button>
</>
) : (
<Button variant="secondary" size="sm" icon={<Pencil className="w-4 h-4" />} onClick={() => setEditMode(true)}
aria-label={t('dashboard.builder.edit')} data-testid="dashboard-edit">
{t('dashboard.builder.edit')}
</Button>
)}
</div>
{/* Tabs */}
<div className="flex items-center gap-1 mb-4 flex-wrap" data-testid="dashboard-tabs" role="tablist">
{tabs.map((tab, i) => (
<div key={tab.id} className="flex items-center">
<button
type="button"
role="tab"
aria-selected={i === activeTabIndex}
onClick={() => setActiveTabIndex(i)}
onDoubleClick={() => editMode && setRenameTab(tab.id)}
className={`px-3 py-1.5 text-sm rounded-t-md border-b-2 ${
i === activeTabIndex
? 'border-primary-500 text-primary-700 font-medium'
: 'border-transparent text-secondary-500 hover:text-secondary-700'
}`}
data-testid={`dashboard-tab-${i}`}
>
{tab.name}
</button>
{editMode && (
<button
type="button"
onClick={() => removeTab(i)}
className="p-1 rounded text-secondary-400 hover:text-danger-500"
aria-label={t('dashboard.builder.removeTab')}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
</button>
)}
</div>
))}
{editMode && (
<button
type="button"
onClick={addTab}
className="p-1.5 rounded hover:bg-secondary-100 text-secondary-500"
aria-label={t('dashboard.builder.addTab')}
data-testid="dashboard-add-tab"
>
<Plus className="w-4 h-4" aria-hidden="true" />
</button>
)}
</div>
{/* Grid + palette */}
<div className={editMode ? 'grid grid-cols-1 xl:grid-cols-4 gap-4' : ''}>
{activeTab && activeTab.widgets.length > 0 ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={activeTab.widgets.map(sortableIdFor)}
strategy={rectSortingStrategy}
>
<div className="xl:col-span-3 grid grid-cols-12 gap-4 auto-rows-min" data-testid="dashboard-grid">
{activeTab.widgets.map((w) => (
<SortableWidget
key={`${w.col}-${w.row}-${w.app_id}-${w.col_span}`}
widget={w}
def={appsById.get(w.app_id)}
editMode={editMode}
onRemove={() => removeWidget(w)}
onResize={(dc, dr) => resizeWidget(w, dc, dr)}
onOpenSettings={() => setSettingsWidget(w)}
/>
))}
</div>
</SortableContext>
</DndContext>
) : (
<div className="xl:col-span-3 border-2 border-dashed border-secondary-200 rounded-lg p-8 text-center text-sm text-secondary-400"
data-testid="dashboard-grid-empty">
{t('dashboard.noWidgets')}
</div>
)}
{editMode && (
<aside className="xl:col-span-1 space-y-2" data-testid="dashboard-palette" aria-label={t('dashboard.builder.palette')}>
<h3 className="text-xs font-semibold text-secondary-500 uppercase">{t('dashboard.builder.palette')}</h3>
{palette.length === 0 && (
<p className="text-sm text-secondary-400">{t('dashboard.builder.paletteEmpty')}</p>
)}
{palette.map((app) => (
<button
key={app.app_id}
type="button"
onClick={() => addWidget(app)}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md border border-secondary-200 text-sm hover:bg-primary-50 hover:border-primary-300 transition-colors"
title={app.description}
data-testid={`dashboard-add-${app.app_id}`}
>
<Plus className="w-3.5 h-3.5 text-secondary-400" aria-hidden="true" />
<span className="truncate">{app.name}</span>
<span className="ml-auto text-[10px] text-secondary-400">{app.plugin_name}</span>
</button>
))}
</aside>
)}
</div>
{/* Create dashboard modal */}
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={t('dashboard.builder.create')}>
<div className="space-y-3">
<Input
label={t('dashboard.builder.name')}
value={newName}
onChange={(e) => setNewName(e.target.value)}
data-testid="dashboard-create-name"
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setCreateOpen(false)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
if (!newName.trim()) return;
createDashboard.mutate({ name: newName.trim() });
setNewName('');
setCreateOpen(false);
}}
data-testid="dashboard-create-submit"
>
{t('common.create')}
</Button>
</div>
</div>
</Modal>
{/* Delete dashboard confirm */}
<ConfirmDialog
open={deleteOpen}
title={t('dashboard.builder.delete')}
message={t('dashboard.builder.deleteConfirm', { name: active.name })}
confirmLabel={t('common.delete')}
onConfirm={() => {
deleteDashboard.mutate(active.id);
setDeleteOpen(false);
setActiveId(null);
}}
onCancel={() => setDeleteOpen(false)}
/>
{/* Widget settings modal */}
<Modal
open={settingsWidget !== null}
onClose={() => setSettingsWidget(null)}
title={t('dashboard.builder.widgetSettings')}
>
{settingsWidget && (
<WidgetSettingsForm
fields={appsById.get(settingsWidget.app_id)?.settings_schema?.fields ?? []}
values={settingsWidget.settings ?? {}}
onChange={(key, value) =>
mutateTab((tab) => ({
...tab,
widgets: tab.widgets.map((w) =>
w === settingsWidget ? { ...w, settings: { ...w.settings, [key]: value } } : w
),
}))
}
/>
)}
</Modal>
{/* Rename tab modal (inline prompt alternative) */}
<Modal open={renameTab !== null} onClose={() => setRenameTab(null)} title={t('dashboard.builder.renameTab')}>
{renameTab && (
<div className="space-y-3">
<Input
label={t('dashboard.builder.tabName')}
value={tabs.find((tb) => tb.id === renameTab)?.name ?? ''}
onChange={(e) =>
setLayout((prev) =>
prev
? {
...prev,
tabs: prev.tabs.map((tb) => (tb.id === renameTab ? { ...tb, name: e.target.value } : tb)),
}
: prev
)
}
data-testid="dashboard-rename-tab-input"
/>
<div className="flex justify-end">
<Button variant="primary" onClick={() => setRenameTab(null)} data-testid="dashboard-rename-tab-save">
{t('common.save')}
</Button>
</div>
</div>
)}
</Modal>
</section>
);
}
@@ -1,102 +0,0 @@
/**
* DashboardGrid — grid layout with drag-and-drop widget positioning (Task 5.25).
* Uses native HTML5 drag-and-drop with CSS Grid — no heavy DnD library.
*/
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { DashboardWidgetLoader } from './DashboardWidgetLoader';
import type { DashboardWidgetDef } from '@/api/dashboard';
interface DashboardGridProps {
widgets: DashboardWidgetDef[];
}
export function DashboardGrid({ widgets: initialWidgets }: DashboardGridProps) {
const { t } = useTranslation();
const [widgets, setWidgets] = useState(initialWidgets);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const handleDragStart = useCallback((index: number) => {
setDragIndex(index);
}, []);
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverIndex(index);
}, []);
const handleDrop = useCallback((index: number) => {
if (dragIndex === null || dragIndex === index) {
setDragIndex(null);
setDragOverIndex(null);
return;
}
setWidgets((prev) => {
const next = [...prev];
const [moved] = next.splice(dragIndex, 1);
next.splice(index, 0, moved);
return next;
});
setDragIndex(null);
setDragOverIndex(null);
}, [dragIndex]);
const handleDragEnd = useCallback(() => {
setDragIndex(null);
setDragOverIndex(null);
}, []);
if (widgets.length === 0) {
return (
<p className="text-sm text-secondary-500" data-testid="dashboard-grid-empty">
{t('dashboard.noWidgets')}
</p>
);
}
return (
<div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"
data-testid="dashboard-grid"
>
{widgets.map((widget, index) => {
const colSpan = Math.min(widget.col_span || 1, 4);
const colSpanClass = {
1: 'lg:col-span-1',
2: 'lg:col-span-2',
3: 'lg:col-span-3',
4: 'lg:col-span-4',
}[colSpan] || 'lg:col-span-1';
return (
<div
key={`${widget.plugin_name}-${widget.id}`}
className={`${colSpanClass} ${
dragIndex === index ? 'opacity-50' : ''
} ${
dragOverIndex === index ? 'ring-2 ring-primary-400 rounded-lg' : ''
}`}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={() => handleDrop(index)}
onDragEnd={handleDragEnd}
data-testid={`dashboard-grid-item-${widget.id}`}
>
<div className="bg-white rounded-lg shadow-sm border border-secondary-200 p-4 h-full">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-secondary-700">
{widget.label || t(widget.label_key)}
</h3>
<span className="text-xs text-secondary-400 cursor-move" title={t('dashboard.dragToReorder')}></span>
</div>
<DashboardWidgetLoader widget={widget} />
</div>
</div>
);
})}
</div>
);
}
@@ -1,49 +0,0 @@
// TODO: P2-F17 — Replace hardcoded widgetRegistry with dynamic manifest config
/**
* DashboardWidgetLoader — dynamically loads widget components (Task 5.25).
*/
import React, { lazy, Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import { Skeleton } from '@/components/ui/Skeleton';
import type { DashboardWidgetDef } from '@/api/dashboard';
// Widget component registry — maps component paths to lazy-loaded components
const widgetRegistry: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
'@/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 }))
),
};
interface DashboardWidgetLoaderProps {
widget: DashboardWidgetDef;
}
export function DashboardWidgetLoader({ widget }: DashboardWidgetLoaderProps) {
const { t } = useTranslation();
const WidgetComponent = widgetRegistry[widget.component];
if (!WidgetComponent) {
return (
<div className="p-4 border rounded-lg bg-secondary-50" data-testid={`widget-${widget.id}`}>
<p className="text-sm text-secondary-500">
{t('dashboard.widgetNotAvailable', { name: widget.label || widget.id })}
</p>
</div>
);
}
return (
<div data-testid={`widget-${widget.id}`}>
<Suspense fallback={<Skeleton className="h-32" />}>
<WidgetComponent />
</Suspense>
</div>
);
}
@@ -0,0 +1,91 @@
/**
* MiniAppHost — renders a MiniApp instance on a host (Phase M3).
*
* Resolves the frontend component via the MiniApp definition's component
* path (lazy registry, P2-F17 successor). Apps without a component (chat
* interaction apps) render a compact render_schema card (MiniAppBlock
* precedent). Widget components receive their per-instance settings.
*/
import React, { lazy, Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import { AppWindow } from 'lucide-react';
import { Skeleton } from '@/components/ui/Skeleton';
import { useMiniapps, type MiniAppDef } from '@/api/miniapps';
export interface WidgetComponentProps {
settings?: Record<string, unknown>;
}
const widgetRegistry: Record<string, React.LazyExoticComponent<React.ComponentType<WidgetComponentProps>>> = {
'@/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 }))
),
};
interface MiniAppHostProps {
appId: string;
settings: Record<string, unknown>;
/** Pre-resolved definition (avoids duplicate fetches inside lists). */
def?: MiniAppDef | undefined;
}
function SchemaCard({ def, settings }: { def: MiniAppDef; settings: Record<string, unknown> }) {
const fields = (def.render_schema as { fields?: { name: string; label?: string }[] }).fields ?? [];
const keys = fields.length > 0 ? fields.map((f) => f.name) : Object.keys(settings);
return (
<div className="space-y-2" data-testid={`miniapp-schema-${def.app_id}`}>
{keys.length === 0 && (
<p className="text-sm text-secondary-500">{def.description || def.name}</p>
)}
{keys.map((key) => (
<div key={key} className="flex items-center justify-between gap-2">
<span className="text-xs text-secondary-500">{key}</span>
<span className="text-sm font-medium text-secondary-900">
{settings[key] != null ? String(settings[key]) : '—'}
</span>
</div>
))}
</div>
);
}
export function MiniAppHost({ appId, settings, def }: MiniAppHostProps) {
const { t } = useTranslation();
const { data, isLoading } = useMiniapps('dashboard');
const resolved = def ?? data?.items.find((a) => a.app_id === appId);
if (resolved && resolved.component) {
const WidgetComponent = widgetRegistry[resolved.component];
if (WidgetComponent) {
return (
<div data-testid={`miniapp-${appId}`}>
<Suspense fallback={<Skeleton className="h-32" />}>
<WidgetComponent settings={settings} />
</Suspense>
</div>
);
}
}
if (isLoading) {
return <Skeleton className="h-24" data-testid="miniapp-loading" />;
}
if (resolved) {
return <SchemaCard def={resolved} settings={settings} />;
}
return (
<div className="flex items-center gap-2 p-3 text-sm text-secondary-500" data-testid={`miniapp-missing-${appId}`}>
<AppWindow className="w-4 h-4" aria-hidden="true" />
{t('dashboard.widgetNotAvailable', { name: appId })}
</div>
);
}
@@ -4,13 +4,15 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
import { useUnifiedContacts } from '@/api/hooks';
import { formatDateTime } from '@/utils/date';
import { Users } from 'lucide-react';
export function RecentContactsWidget() {
export function RecentContactsWidget({ settings }: WidgetComponentProps) {
const { t } = useTranslation();
const { data, isLoading, isError } = useUnifiedContacts(1, 5, undefined, undefined);
const limit = Math.max(1, Math.min(50, Number(settings?.limit ?? 5) || 5));
const { data, isLoading, isError } = useUnifiedContacts(1, limit, undefined, undefined);
if (isLoading) {
return <div className="animate-pulse space-y-2" data-testid="recent-contacts-loading">{[...Array(3)].map((_, i) => <div key={i} className="h-4 bg-secondary-100 rounded" />)}</div>;
@@ -4,10 +4,11 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { WidgetComponentProps } from '@/components/dashboard/MiniAppHost';
import { useTasks } from '@/api/tasks';
import { CheckSquare, AlertCircle, Clock } from 'lucide-react';
export function TasksSummaryWidget() {
export function TasksSummaryWidget({ settings }: WidgetComponentProps) {
const { t } = useTranslation();
const { data, isLoading, isError } = useTasks(1, 100);
@@ -0,0 +1,86 @@
/**
* WidgetSettingsForm — generic per-instance settings form (Phase M3).
*
* Renders fields from the MiniApp's settings_schema (same philosophy as
* the block config panels in the documents editor). Unknown schema
* shapes fall back to a key/value editor over existing settings.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import type { MiniAppField } from '@/api/miniapps';
interface WidgetSettingsFormProps {
fields: MiniAppField[];
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
}
export function WidgetSettingsForm({ fields, values, onChange }: WidgetSettingsFormProps) {
const { t } = useTranslation();
if (fields.length === 0) {
return (
<p className="text-sm text-secondary-500" data-testid="widget-settings-empty">
{t('dashboard.builder.noSettings')}
</p>
);
}
return (
<div className="space-y-3" data-testid="widget-settings-form">
{fields.map((field) => {
const key = field.name;
const raw = values[key] ?? field.default ?? '';
const type = (field.type ?? 'text').toLowerCase();
if (type === 'boolean') {
const checked = raw === true || raw === 'true';
return (
<label key={key} className="flex items-center gap-2 text-sm text-secondary-700">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(key, e.target.checked)}
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={field.label || key}
data-testid={`widget-setting-${key}`}
/>
{field.label || key}
</label>
);
}
if (type === 'select' && field.options && field.options.length > 0) {
return (
<div key={key}>
<Select
label={field.label || key}
value={String(raw)}
onChange={(e) => onChange(key, e.target.value)}
options={field.options.map((o) => ({ value: String(o.value), label: o.label }))}
data-testid={`widget-setting-${key}`}
/>
</div>
);
}
return (
<div key={key}>
<Input
label={field.label || key}
type={type === 'number' ? 'number' : 'text'}
value={String(raw)}
onChange={(e) =>
onChange(key, type === 'number' ? Number(e.target.value) : e.target.value)
}
data-testid={`widget-setting-${key}`}
/>
</div>
);
})}
</div>
);
}