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:
@@ -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 } },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user