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:
+24
-3
@@ -7,7 +7,7 @@
|
||||
**Kürzlich abgeschlossen:** Phase L vollständig (L1 Block-System, L2 Drag&Drop-Editor, L3 Renderer, L4 KI-Steuerung, L5 XRechnung-Format-Layer) — Details siehe Phase-L-Sections unten.
|
||||
|
||||
**Offene Roadmap-Phasen (user-abgestimmt, startklar):**
|
||||
- **Phase M** — MiniApp-Plattform & Dashboard-Builder (M1-M6). **M1 ✓** (Universal-Registry, `/api/v1/miniapps`), **M2 ✓ erledigt** (persönliche Dashboards: Tabelle, CRUD, Seed, RLS — siehe Phase-M2-Section). **Nächster Schritt: M3 Dashboard-Builder-Frontend** (Edit-Modus, Drag&Drop, Widget-Palette aus /api/v1/miniapps, Tabs, generisches Settings-Form).
|
||||
- **Phase M** — MiniApp-Plattform & Dashboard-Builder (M1-M6). **M1 ✓** (Universal-Registry, `/api/v1/miniapps`), **M2 ✓** (persönliche Dashboards: Tabelle, CRUD, Seed, RLS), **M3 ✓ erledigt** (Dashboard-Builder: Edit-Modus, @dnd-kit-Drag&Drop, Palette, Tabs, Settings-Forms — siehe Phase-M3-Section). **Nächster Schritt: M4 System-Rückbau** (StatCards→contacts-MiniApp, ActivityFeed→audit-MiniApp, SystemMetrics→System-MiniApp, Bestands-Widgets migrieren).
|
||||
- **Phase N** — Workspace-Scopes (N1-N4). 0 Umbau — Fundament (config JSONB, X-Workspace-ID, /context, Sidebar-Consumer) existiert bereits.
|
||||
- **Phase O** — UI-Overhaul (umbenannt von Doppel-L, Bug-Verifikation steht im Roadmap-Eintrag: 5/7 Bugs bereits erledigt, offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog)
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
**User-Vision:** Universelle MiniApps (Chat + Dashboard + Windows + AI-Agenten), Dashboard-Builder mit Edit-Modus/Drag&Drop/Resize/Tabs/pro-Widget-Settings, System-Dashboard-Teile zurück in Plugins (Core = reiner Host), Permission-Integration fail-closed.
|
||||
|
||||
**Status:** in_progress — M1 ✓, M2 ✓ erledigt (siehe Sections unten). Offen: M3 (Builder-Frontend), M4 (System-Rückbau), M5 (Plugin-MiniApps), M6 (weitere Hosts). Details: PLATFORM_ROADMAP.md Phase M.
|
||||
**Status:** in_progress — M1 ✓, M2 ✓, M3 ✓ erledigt (siehe Sections unten). Offen: M4 (System-Rückbau), M5 (Plugin-MiniApps), M6 (weitere Hosts). Details: PLATFORM_ROADMAP.md Phase M.
|
||||
|
||||
**Live-Bestand analysiert (2026-08-29):** miniapp_registry (kommunikation, 92 Z.), MiniAppContribution (LÜCKE: kein permission-Feld), FrontendDashboardWidget (LÜCKE: kein settings_schema), MiniAppBlock.tsx (Chat-Host fertig), DashboardGrid + 4 Widgets, Dashboard.tsx (170 Z.) mit hardcodierten StatCards/ActivityFeed/System-Metrics (Rückbau-Bestand für M4), @dnd-kit vorhanden.
|
||||
|
||||
@@ -102,7 +102,28 @@
|
||||
- ✅ ruff clean (M1-Dateien); 2 Ruff-Funde in automation/knowledge = per Stash bewiesener Vorbestand
|
||||
- ✅ Doku: api-documentation.md (2 Endpoints), plugin-development-guide.md (MiniApp-Beitrag-Muster)
|
||||
|
||||
**Offen in Phase M:** M3 Builder-Frontend (konsumiert /api/v1/dashboards + /api/v1/miniapps), M4 System-Rückbau, M5 Plugin-MiniApps, M6 weitere Hosts.
|
||||
**Offen in Phase M:** M4 System-Rückbau, M5 Plugin-MiniApps, M6 weitere Hosts.
|
||||
|
||||
## Phase M3 — Dashboard-Builder-Frontend (2026-08-30) ✅
|
||||
|
||||
**Spec:** [#361](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/361) | **Roadmap:** Phase M, M3
|
||||
|
||||
**Umgesetzt:**
|
||||
- `api/miniapps.ts` + `api/dashboards.ts`: TanStack-Query-Hooks (M2-Backend + M1-Registry, Query-Keys + Invalidation nach documents.ts-Muster); `renderableDashboardApps()` filtert Apps ohne component (6 der 9 Bestands-Apps sind Chat-Interaktions-Apps ohne Frontend-Component).
|
||||
- `MiniAppHost.tsx`: ersetzt DashboardWidgetLoader (P2-F17-Erbe) — Lazy-Component-Registry + settings-Props an Widget-Komponenten; Apps ohne Component renderen render_schema-Karte (MiniAppBlock-Präzedenz).
|
||||
- `DashboardBuilder.tsx` (605 Z.): View/Edit-Modus-Schalter; @dnd-kit-Sortable-Grid (rectSortingStrategy) mit 12-Spalten-Flow-Repositionierung (identisch zum Server-Seed); Palette (permission-gefiltert via /api/v1/miniapps?host=dashboard, nur renderbare Apps); Resize (col/row ±, geclamped 1-12); Tab-Verwaltung (Add/Remove/Rename, min 1); Dashboard-CRUD + Set-Default; Dirty-Check (Save disabled bei unverändertem Layout); Settings-Modal pro Widget.
|
||||
- `WidgetSettingsForm.tsx`: generisches Form aus settings_schema (text/number/boolean/select, MiniAppField-Typ).
|
||||
- Bestands-Widgets auf settings-Props umgestellt (RecentContacts nutzt settings.limit, geclamped 1-50; Tasks/Calendar kompatibel optional).
|
||||
- `Dashboard.tsx`: Builder als Hauptinhalt (reiner Host-Pattern); StatCards/SystemMetrics/ActivityFeed bleiben sichtbar bis M4-Rückbau (kein Funktionsverlust).
|
||||
- Legacy `DashboardGrid.tsx` + `DashboardWidgetLoader.tsx` gelöscht; Geister-Test ersetzt (§10: UI-Änderung → Test-Nachzug).
|
||||
- i18n: dashboard.builder.* 22 Keys (de+en).
|
||||
|
||||
**Verifiziert (2026-08-30):**
|
||||
- ✅ Vitest: DashboardBuilder-Tests **13/13** (Render, Tabs, View/Edit-Schalter, Palette-Add, Save-Flow-Koordinaten-Beweis {col:3,row:0}, Dirty-Disabled, Resize→col_span 3, Tab-Add/Remove, Settings-Modal mit Schema-Feld, Dashboard-Wechsel, Create-Modal, Empty-State) — TDD-äquivalent: 2 anfängliche Test-Bugs (multiple elements) gefixt, dann grün
|
||||
- ✅ Page-Regression: dashboard/Dashboard.test.tsx **11/11** (Builder-Mocks ergänzt); i18n-Test grün (de/en)
|
||||
- ✅ `npx tsc --noEmit` clean; Production-Build **OK** (2.98s)
|
||||
- ⚠️ Sidebar-Test-Run: Worker-OOM (0 Tests ausgeführt) = PROGRESS.md Finding #3 Vorbestand (identisch bei AppShell-Runs vor M3)
|
||||
- Ausstehend: Frontend-Deploy + Produktions-Verifikation (wird nachgetragen)
|
||||
|
||||
## Phase M2 — Dashboard-Backend (2026-08-30) ✅
|
||||
|
||||
|
||||
@@ -1,74 +1,240 @@
|
||||
/**
|
||||
* Dashboard tests — Task 5.25.
|
||||
* DashboardBuilder tests (Phase M3).
|
||||
*
|
||||
* Replaces the old DashboardGrid tests (component deleted with M3 — the
|
||||
* builder now owns the grid). Covers: rendering from server layout,
|
||||
* edit mode with palette, add/remove widget, resize, tabs, dirty-state
|
||||
* save, dashboard selection and set-default.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { DashboardGrid } from '@/components/dashboard/DashboardGrid';
|
||||
import type { DashboardWidgetDef } from '@/api/dashboard';
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { DashboardBuilder } from '@/components/dashboard/DashboardBuilder';
|
||||
import type { Dashboard } from '@/api/dashboards';
|
||||
import type { MiniAppDef } from '@/api/miniapps';
|
||||
|
||||
// Mock i18n
|
||||
// i18n mock
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
useTranslation: () => ({ t: (key: string, opts?: Record<string, string>) =>
|
||||
key.includes('{{name}}') && opts ? key.replace('{{name}}', opts.name) : key }),
|
||||
}));
|
||||
|
||||
// Mock DashboardWidgetLoader to avoid lazy loading in tests
|
||||
vi.mock('@/components/dashboard/DashboardWidgetLoader', () => ({
|
||||
DashboardWidgetLoader: ({ widget }: { widget: DashboardWidgetDef }) => (
|
||||
<div data-testid={`widget-${widget.id}`}>Widget: {widget.label}</div>
|
||||
// ─── Shared mocks ───
|
||||
const mutateFns = { update: vi.fn(), create: vi.fn(), del: vi.fn(), setDefault: vi.fn() };
|
||||
|
||||
vi.mock('@/api/dashboards', async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>('@/api/dashboards');
|
||||
return {
|
||||
...actual,
|
||||
useDashboards: () => ({ data: mockDashboards, isLoading: false }),
|
||||
useCreateDashboard: () => ({ mutate: mutateFns.create, isPending: false }),
|
||||
useUpdateDashboard: () => ({ mutate: mutateFns.update, isPending: false }),
|
||||
useDeleteDashboard: () => ({ mutate: mutateFns.del }),
|
||||
useSetDefaultDashboard: () => ({ mutate: mutateFns.setDefault }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/api/miniapps', async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>('@/api/miniapps');
|
||||
return {
|
||||
...actual,
|
||||
useMiniapps: () => ({ data: { items: mockMiniapps, total: mockMiniapps.length }, isLoading: false }),
|
||||
};
|
||||
});
|
||||
|
||||
// MiniAppHost mock (no lazy loading in tests)
|
||||
vi.mock('@/components/dashboard/MiniAppHost', () => ({
|
||||
MiniAppHost: ({ appId }: { appId: string }) => (
|
||||
<div data-testid={`miniapp-${appId}`}>app:{appId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock Skeleton
|
||||
// UI primitives
|
||||
vi.mock('@/components/ui/Skeleton', () => ({
|
||||
Skeleton: ({ className }: { className?: string }) => (
|
||||
<div className={className} data-testid="skeleton">Loading...</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockWidgets: DashboardWidgetDef[] = [
|
||||
let mockDashboards: Dashboard[];
|
||||
const mockMiniapps: MiniAppDef[] = [
|
||||
{
|
||||
id: 'recent_contacts',
|
||||
label_key: 'dashboard.recentContacts',
|
||||
label: 'Recent Contacts',
|
||||
component: '@/components/dashboard/RecentContactsWidget',
|
||||
icon: 'Users',
|
||||
order: 10,
|
||||
col_span: 2,
|
||||
row_span: 1,
|
||||
permission: '',
|
||||
plugin_name: 'contacts',
|
||||
app_id: 'recent_contacts', name: 'Recent Contacts', icon: 'Users', description: '',
|
||||
plugin_name: 'contacts', render_schema: {}, permission: '',
|
||||
settings_schema: { fields: [{ name: 'limit', label: 'Limit', type: 'number', default: 5 }] },
|
||||
col_span: 2, row_span: 1, hosts: ['chat', 'dashboard', 'window'],
|
||||
component: '@/components/dashboard/RecentContactsWidget', order: 10, builtin: true,
|
||||
},
|
||||
{
|
||||
id: 'tasks_summary',
|
||||
label_key: 'dashboard.tasksSummary',
|
||||
label: 'Tasks Summary',
|
||||
component: '@/components/dashboard/TasksSummaryWidget',
|
||||
icon: 'CheckSquare',
|
||||
order: 20,
|
||||
col_span: 1,
|
||||
row_span: 1,
|
||||
permission: '',
|
||||
plugin_name: 'tasks',
|
||||
app_id: 'tasks_summary', name: 'Tasks Summary', icon: 'CheckSquare', description: '',
|
||||
plugin_name: 'tasks', render_schema: {}, permission: '',
|
||||
settings_schema: {},
|
||||
col_span: 1, row_span: 1, hosts: ['chat', 'dashboard', 'window'],
|
||||
component: '@/components/dashboard/TasksSummaryWidget', order: 20, builtin: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('DashboardGrid', () => {
|
||||
it('renders widgets in a grid', () => {
|
||||
render(<DashboardGrid widgets={mockWidgets} />);
|
||||
expect(screen.getByTestId('dashboard-grid')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-grid-item-recent_contacts')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-grid-item-tasks_summary')).toBeInTheDocument();
|
||||
function makeDashboard(overrides?: Partial<Dashboard>): Dashboard {
|
||||
return {
|
||||
id: 'd-1',
|
||||
name: 'Mein Dashboard',
|
||||
is_default: true,
|
||||
user_id: 'u-1',
|
||||
created_at: '2026-08-30T10:00:00Z',
|
||||
updated_at: '2026-08-30T10:00:00Z',
|
||||
layout: {
|
||||
version: 1,
|
||||
tabs: [
|
||||
{ id: 'start', name: 'Start', widgets: [
|
||||
{ app_id: 'recent_contacts', settings: { limit: 5 }, col: 0, row: 0, col_span: 2, row_span: 1 },
|
||||
{ app_id: 'tasks_summary', settings: {}, col: 2, row: 0, col_span: 1, row_span: 1 },
|
||||
] },
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderBuilder() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DashboardBuilder />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDashboards = [makeDashboard()];
|
||||
});
|
||||
|
||||
// ─── Tests ───
|
||||
|
||||
describe('DashboardBuilder', () => {
|
||||
it('renders the builder with widgets from the server layout', () => {
|
||||
renderBuilder();
|
||||
expect(screen.getByTestId('dashboard-builder')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-widget-recent_contacts')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-widget-tasks_summary')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('miniapp-recent_contacts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty message when no widgets', () => {
|
||||
render(<DashboardGrid widgets={[]} />);
|
||||
it('renders tab bar with tab names', () => {
|
||||
renderBuilder();
|
||||
expect(screen.getByTestId('dashboard-tabs')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dashboard-tab-0')).toHaveTextContent('Start');
|
||||
});
|
||||
|
||||
it('hides edit controls in view mode', () => {
|
||||
renderBuilder();
|
||||
expect(screen.getByTestId('dashboard-edit')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('dashboard-palette')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('dashboard-widget-remove')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows palette and widget controls in edit mode', () => {
|
||||
renderBuilder();
|
||||
fireEvent.click(screen.getByTestId('dashboard-edit'));
|
||||
expect(screen.getByTestId('dashboard-palette')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('dashboard-widget-remove').length).toBe(2);
|
||||
expect(screen.getAllByTestId('dashboard-widget-settings').length).toBe(2);
|
||||
});
|
||||
|
||||
it('adds a widget from the palette in edit mode', () => {
|
||||
renderBuilder();
|
||||
fireEvent.click(screen.getByTestId('dashboard-edit'));
|
||||
// remove both existing widgets, then add one back via palette
|
||||
fireEvent.click(screen.getAllByTestId('dashboard-widget-remove')[0]);
|
||||
fireEvent.click(screen.getAllByTestId('dashboard-widget-remove')[0]);
|
||||
expect(screen.getByTestId('dashboard-grid-empty')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('dashboard-add-tasks_summary'));
|
||||
expect(screen.getByTestId('dashboard-widget-tasks_summary')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls update with the changed layout on save', async () => {
|
||||
renderBuilder();
|
||||
fireEvent.click(screen.getByTestId('dashboard-edit'));
|
||||
fireEvent.click(screen.getByTestId('dashboard-add-tasks_summary'));
|
||||
fireEvent.click(screen.getByTestId('dashboard-save'));
|
||||
await waitFor(() => expect(mutateFns.update).toHaveBeenCalledTimes(1));
|
||||
const call = mutateFns.update.mock.calls[0][0];
|
||||
expect(call.id).toBe('d-1');
|
||||
const widgets = call.input.layout.tabs[0].widgets;
|
||||
expect(widgets).toHaveLength(3);
|
||||
// flow: 2+1+1 = 4 columns, all on row 0
|
||||
expect(widgets[2]).toMatchObject({ app_id: 'tasks_summary', col: 3, row: 0, col_span: 1 });
|
||||
});
|
||||
|
||||
it('does not call update when nothing changed (save disabled)', async () => {
|
||||
renderBuilder();
|
||||
fireEvent.click(screen.getByTestId('dashboard-edit'));
|
||||
expect(screen.getByTestId('dashboard-save')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('resizes a widget wider', () => {
|
||||
renderBuilder();
|
||||
fireEvent.click(screen.getByTestId('dashboard-edit'));
|
||||
const controls = screen.getAllByTestId('dashboard-widget-controls')[0];
|
||||
const widerBtn = controls.querySelectorAll('button')[1]; // [narrower, wider, shorter, taller, settings, remove]
|
||||
fireEvent.click(widerBtn);
|
||||
fireEvent.click(screen.getByTestId('dashboard-save'));
|
||||
expect(mutateFns.update).toHaveBeenCalled();
|
||||
const widgets = mutateFns.update.mock.calls[0][0].input.layout.tabs[0].widgets;
|
||||
expect(widgets[0]).toMatchObject({ app_id: 'recent_contacts', col_span: 3 });
|
||||
});
|
||||
|
||||
it('adds and removes tabs in edit mode', () => {
|
||||
renderBuilder();
|
||||
fireEvent.click(screen.getByTestId('dashboard-edit'));
|
||||
fireEvent.click(screen.getByTestId('dashboard-add-tab'));
|
||||
expect(screen.getByTestId('dashboard-tab-1')).toBeInTheDocument();
|
||||
// only one tab can be removed (min 1)
|
||||
fireEvent.click(screen.getByTestId('dashboard-tab-1'));
|
||||
const removeTabBtn = screen.getByTestId('dashboard-tabs').querySelectorAll('button[aria-label*="removeTab"], button[aria-label="dashboard.builder.removeTab"]');
|
||||
// remove second tab via its trash button
|
||||
const trashButtons = screen.getByTestId('dashboard-tabs').querySelectorAll('button');
|
||||
fireEvent.click(trashButtons[3]); // tab0-remove? tab1-remove
|
||||
expect(screen.queryByTestId('dashboard-tab-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens widget settings modal with schema fields', () => {
|
||||
renderBuilder();
|
||||
fireEvent.click(screen.getByTestId('dashboard-edit'));
|
||||
fireEvent.click(screen.getAllByTestId('dashboard-widget-settings')[0]);
|
||||
expect(screen.getByTestId('widget-settings-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('widget-setting-limit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects between multiple dashboards', () => {
|
||||
mockDashboards = [
|
||||
makeDashboard(),
|
||||
makeDashboard({ id: 'd-2', name: 'Zweites', is_default: false,
|
||||
layout: { version: 1, tabs: [{ id: 't', name: 'T', widgets: [] }] } }),
|
||||
];
|
||||
renderBuilder();
|
||||
const select = screen.getByTestId('dashboard-select') as HTMLSelectElement;
|
||||
expect(select.value).toBe('d-1');
|
||||
expect(screen.getByText('Zweites')).toBeInTheDocument();
|
||||
fireEvent.change(select, { target: { value: 'd-2' } });
|
||||
expect(screen.getByTestId('dashboard-grid-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders widget labels', () => {
|
||||
render(<DashboardGrid widgets={mockWidgets} />);
|
||||
expect(screen.getByText('Recent Contacts')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tasks Summary')).toBeInTheDocument();
|
||||
it('creates a new dashboard via modal', async () => {
|
||||
renderBuilder();
|
||||
fireEvent.click(screen.getByTestId('dashboard-create'));
|
||||
fireEvent.change(screen.getByTestId('dashboard-create-name'), { target: { value: 'Vertrieb' } });
|
||||
fireEvent.click(screen.getByTestId('dashboard-create-submit'));
|
||||
await waitFor(() => expect(mutateFns.create).toHaveBeenCalledWith({ name: 'Vertrieb' }));
|
||||
});
|
||||
|
||||
it('shows empty state when no dashboards exist', () => {
|
||||
mockDashboards = [];
|
||||
const { rerender } = renderBuilder();
|
||||
// useDashboards returns empty -> builder shows unavailable (lazy seed comes from server)
|
||||
expect(screen.getByTestId('dashboard-builder-empty')).toBeInTheDocument();
|
||||
void rerender;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,26 @@ vi.mock('@/api/dashboard', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// M3: DashboardPage renders the DashboardBuilder — mock its data layer
|
||||
vi.mock('@/api/dashboards', () => ({
|
||||
useDashboards: () => ({ data: [], isLoading: false }),
|
||||
useCreateDashboard: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useUpdateDashboard: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useDeleteDashboard: () => ({ mutate: vi.fn() }),
|
||||
useSetDefaultDashboard: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/miniapps', () => ({
|
||||
useMiniapps: () => ({ data: { items: [], total: 0 } }),
|
||||
renderableDashboardApps: (apps: unknown[]) => (apps as never[]),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/dashboard/MiniAppHost', () => ({
|
||||
MiniAppHost: ({ appId }: { appId: string }) => (
|
||||
<div data-testid={`miniapp-${appId}`}>app:{appId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Personal dashboards API hooks (Phase M2/M3).
|
||||
*
|
||||
* Layout shape (validated server-side, DashboardLayout):
|
||||
* { version: 1, tabs: [{ id, name, widgets: [{ app_id, settings, col, row, col_span, row_span }] }] }
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from './client';
|
||||
|
||||
export interface DashboardWidget {
|
||||
app_id: string;
|
||||
settings: Record<string, unknown>;
|
||||
col: number;
|
||||
row: number;
|
||||
col_span: number;
|
||||
row_span: number;
|
||||
}
|
||||
|
||||
export interface DashboardTab {
|
||||
id: string;
|
||||
name: string;
|
||||
widgets: DashboardWidget[];
|
||||
}
|
||||
|
||||
export interface DashboardLayout {
|
||||
version: number;
|
||||
tabs: DashboardTab[];
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
id: string;
|
||||
name: string;
|
||||
layout: DashboardLayout;
|
||||
is_default: boolean;
|
||||
user_id: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface DashboardInput {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DashboardUpdateInput {
|
||||
name?: string;
|
||||
layout?: DashboardLayout;
|
||||
}
|
||||
|
||||
export const DASHBOARD_QUERY_KEYS = {
|
||||
all: ['dashboards'] as const,
|
||||
one: (id: string) => ['dashboards', id] as const,
|
||||
};
|
||||
|
||||
export function useDashboards() {
|
||||
return useQuery({
|
||||
queryKey: DASHBOARD_QUERY_KEYS.all,
|
||||
queryFn: () => apiGet<Dashboard[]>('/dashboards'),
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateDashboard() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: DashboardInput) => apiPost<Dashboard>('/dashboards', input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_QUERY_KEYS.all }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateDashboard() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: DashboardUpdateInput }) =>
|
||||
apiPut<Dashboard>(`/dashboards/${id}`, input),
|
||||
onSuccess: (_data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: DASHBOARD_QUERY_KEYS.all });
|
||||
qc.invalidateQueries({ queryKey: DASHBOARD_QUERY_KEYS.one(vars.id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteDashboard() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiDelete<void>(`/dashboards/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_QUERY_KEYS.all }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetDefaultDashboard() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiPost<Dashboard>(`/dashboards/${id}/set-default`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_QUERY_KEYS.all }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* MiniApps API hooks (Phase M1/M3).
|
||||
*
|
||||
* The universal MiniApp registry listing is permission-filtered
|
||||
* server-side; hosts additionally filter by ?host= and by whether a
|
||||
* frontend component is available (chat-only interaction apps have no
|
||||
* component path and cannot be placed on a dashboard).
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiGet } from './client';
|
||||
|
||||
export interface MiniAppField {
|
||||
name: string;
|
||||
label?: string;
|
||||
type?: string; // text | number | boolean | select
|
||||
default?: unknown;
|
||||
options?: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export interface MiniAppDef {
|
||||
app_id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
plugin_name: string;
|
||||
render_schema: Record<string, unknown>;
|
||||
permission: string;
|
||||
settings_schema: { fields?: MiniAppField[] } & Record<string, unknown>;
|
||||
col_span: number;
|
||||
row_span: number;
|
||||
hosts: string[];
|
||||
component: string;
|
||||
order: number;
|
||||
builtin: boolean;
|
||||
}
|
||||
|
||||
export interface MiniAppsResponse {
|
||||
items: MiniAppDef[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const MINIAPP_QUERY_KEYS = {
|
||||
all: ['miniapps'] as const,
|
||||
host: (host: string) => ['miniapps', host] as const,
|
||||
};
|
||||
|
||||
export function useMiniapps(host?: string) {
|
||||
return useQuery({
|
||||
queryKey: MINIAPP_QUERY_KEYS.host(host ?? 'all'),
|
||||
queryFn: () =>
|
||||
apiGet<MiniAppsResponse>(host ? `/miniapps?host=${encodeURIComponent(host)}` : '/miniapps'),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Apps that can actually be rendered on a dashboard (component present). */
|
||||
export function renderableDashboardApps(apps: MiniAppDef[] | undefined): MiniAppDef[] {
|
||||
return (apps ?? []).filter((a) => Boolean(a.component));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -138,6 +138,30 @@
|
||||
"down": "Nicht verfügbar",
|
||||
"unknown": "Unbekannt"
|
||||
}
|
||||
},
|
||||
"builder": {
|
||||
"edit": "Bearbeiten",
|
||||
"save": "Speichern",
|
||||
"create": "Neues Dashboard",
|
||||
"delete": "Dashboard löschen",
|
||||
"deleteConfirm": "Dashboard \"{{name}}\" wirklich löschen?",
|
||||
"setDefault": "Als Standard",
|
||||
"selectDashboard": "Dashboard auswählen",
|
||||
"name": "Name",
|
||||
"palette": "Widgets",
|
||||
"paletteEmpty": "Keine Widgets verfügbar.",
|
||||
"newTab": "Neuer Tab",
|
||||
"addTab": "Tab hinzufügen",
|
||||
"removeTab": "Tab entfernen",
|
||||
"renameTab": "Tab umbenennen",
|
||||
"tabName": "Tab-Name",
|
||||
"widgetSettings": "Widget-Einstellungen",
|
||||
"noSettings": "Keine Einstellungen verfügbar.",
|
||||
"removeWidget": "Widget entfernen",
|
||||
"wider": "Breiter",
|
||||
"narrower": "Schmaler",
|
||||
"taller": "Höher",
|
||||
"shorter": "Niedriger"
|
||||
}
|
||||
},
|
||||
"companies": {
|
||||
|
||||
@@ -138,6 +138,30 @@
|
||||
"down": "Down",
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
},
|
||||
"builder": {
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"create": "New dashboard",
|
||||
"delete": "Delete dashboard",
|
||||
"deleteConfirm": "Really delete dashboard \"{{name}}\"?",
|
||||
"setDefault": "Set as default",
|
||||
"selectDashboard": "Select dashboard",
|
||||
"name": "Name",
|
||||
"palette": "Widgets",
|
||||
"paletteEmpty": "No widgets available.",
|
||||
"newTab": "New tab",
|
||||
"addTab": "Add tab",
|
||||
"removeTab": "Remove tab",
|
||||
"renameTab": "Rename tab",
|
||||
"tabName": "Tab name",
|
||||
"widgetSettings": "Widget settings",
|
||||
"noSettings": "No settings available.",
|
||||
"removeWidget": "Remove widget",
|
||||
"wider": "Wider",
|
||||
"narrower": "Narrower",
|
||||
"taller": "Taller",
|
||||
"shorter": "Shorter"
|
||||
}
|
||||
},
|
||||
"companies": {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatCard } from '@/components/shared/StatCard';
|
||||
import { ActivityFeed, ActivityItem } from '@/components/shared/ActivityFeed';
|
||||
import { DashboardGrid } from '@/components/dashboard/DashboardGrid';
|
||||
import { DashboardBuilder } from '@/components/dashboard/DashboardBuilder';
|
||||
import { useUnifiedContacts, useAuditLog } from '@/api/hooks';
|
||||
import { useDashboardWidgets } from '@/api/dashboard';
|
||||
import { useSystemDashboard } from '@/api/systemDashboard';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
@@ -14,7 +13,6 @@ export function DashboardPage() {
|
||||
const { data: companiesData } = useUnifiedContacts(1, 1, undefined, 'company');
|
||||
const { data: contactsData } = useUnifiedContacts(1, 1, undefined, 'person');
|
||||
const { data: auditData, isError: auditError } = useAuditLog(1, 5);
|
||||
const { data: widgetsData, isError: widgetsError } = useDashboardWidgets();
|
||||
const { data: systemData } = useSystemDashboard();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAdmin = user?.is_system_admin ?? false;
|
||||
@@ -49,12 +47,15 @@ export function DashboardPage() {
|
||||
avatarUrl: null,
|
||||
}));
|
||||
|
||||
const widgets = widgetsData?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="dashboard-page">
|
||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('dashboard.title')}</h1>
|
||||
|
||||
{/* Personal dashboard builder (Phase M3) — replaces the old plugin widget grid */}
|
||||
<div className="mb-10">
|
||||
<DashboardBuilder />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
label={t('dashboard.totalCompanies')}
|
||||
@@ -78,7 +79,7 @@ export function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* System Metrics (Admin only) — I-DASH, I-COST, I-USE */}
|
||||
{/* System Metrics (Admin only) — I-DASH, I-COST, I-USE (M4: becomes system MiniApp) */}
|
||||
{isAdmin && systemData && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-secondary-800 mb-4">{t('dashboard.systemMetrics', 'System Status')}</h2>
|
||||
@@ -144,18 +145,6 @@ export function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic Plugin Widgets */}
|
||||
{widgetsError ? (
|
||||
<p className="text-sm text-secondary-500 mb-6" data-testid="dashboard-widgets-unavailable">
|
||||
{t('dashboard.widgetsUnavailable')}
|
||||
</p>
|
||||
) : widgets.length > 0 ? (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-secondary-800 mb-4">{t('dashboard.widgets')}</h2>
|
||||
<DashboardGrid widgets={widgets} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{auditError ? (
|
||||
<p className="text-sm text-secondary-500" data-testid="activity-unavailable">
|
||||
{t('dashboard.activityUnavailable')}
|
||||
|
||||
Reference in New Issue
Block a user