From 56c07c3581bda685422e8e58385ae01dd53ea8b1 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 22 Sep 2026 16:03:58 +0200 Subject: [PATCH] refactor(quick-260922-m1h): Widget-Typen an einer Stelle, Katalog aus der Registry, Kachel kennt ihr Modul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein neuer Widget-Typ war an sieben Stellen einzutragen; vergass man eine, fehlte die Kachel im Katalog oder die API lehnte sie mit 400 ab. - WIDGET_TYPES/WidgetType/WIDGET_MODULE_SLUGS stehen jetzt einmal in packages/shared; Registry, Katalog und die @IsIn-Whitelist der API leiten davon ab - neun wireXWidget()-Funktionen durch ein generisches registerWidget() ersetzt (idempotent, unbekannter Typ wirft in der Entwicklung) - der Katalog fuehrt keine zweite Typliste mehr, sondern leitet sie aus der Registry ab und filtert nach Modulzugriff (fail-closed, wenn die Modulliste unbekannt ist); der Abruf von /modules/active liegt auf der Dashboard-Seite, nicht im Dialog - widget-module-map.ts liest die geteilte Tabelle statt einer Kopie, die oeffentliche Funktion bleibt unveraendert Der Katalogfilter ist Komfort (T-M1H-01) — verbindlich bleibt der serverseitige Filter in DashboardService.getWidgets. Abweichung vom Plan: apps/web hing entgegen der Planannahme noch nicht von @tessera/shared ab; die Abhaengigkeit wurde ergaenzt (Lockfile). Die Dockerfiles kopieren packages/shared bereits, der Produktionsbau von Next.js und der nest build laufen unveraendert. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/dashboard/dto/create-widget.dto.ts | 22 +-- .../src/dashboard/widget-module-map.spec.ts | 58 ++++++ apps/api/src/dashboard/widget-module-map.ts | 25 ++- apps/web/package.json | 1 + apps/web/src/app/(portal)/page.test.tsx | 56 +++++- apps/web/src/app/(portal)/page.tsx | 61 ++++-- .../dashboard/widget-catalog-modal.test.tsx | 52 +++++ .../dashboard/widget-catalog-modal.tsx | 32 +-- .../dashboard/widget-registry.test.tsx | 128 +++++++++++- .../components/dashboard/widget-registry.tsx | 187 +++++++++--------- apps/web/src/lib/app-version.ts | 8 +- apps/web/src/lib/desktop.ts | 5 +- packages/shared/src/index.ts | 43 ++++ pnpm-lock.yaml | 3 + 14 files changed, 535 insertions(+), 146 deletions(-) create mode 100644 apps/api/src/dashboard/widget-module-map.spec.ts diff --git a/apps/api/src/dashboard/dto/create-widget.dto.ts b/apps/api/src/dashboard/dto/create-widget.dto.ts index bed72c2..f24a6ee 100644 --- a/apps/api/src/dashboard/dto/create-widget.dto.ts +++ b/apps/api/src/dashboard/dto/create-widget.dto.ts @@ -1,24 +1,20 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator'; +import { WIDGET_TYPES } from '@tessera/shared'; /** * DTO for creating a new widget instance on a user's dashboard. - * widgetType must be one of the nine supported types - * ('picture-frame' seit quick-260921-pi9, 'xframe' seit quick-260921-qd3). + * + * quick-260922-m1h: `widgetType` wird gegen `WIDGET_TYPES` aus + * `@tessera/shared` geprüft — dieselbe Liste, aus der das Frontend seine + * Registry und seinen Katalog ableitet. Vorher stand die Liste hier ein + * zweites Mal; vergaß man einen Eintrag, lehnte die API eine im Katalog + * angebotene Kachel mit 400 ab. + * * config is optional and defaults to {} on the model. */ export class CreateWidgetDto { @IsString() - @IsIn([ - 'clock', - 'search', - 'calendar', - 'note', - 'calculator', - 'favorites', - 'stopwatch', - 'picture-frame', - 'xframe', - ]) + @IsIn([...WIDGET_TYPES]) widgetType!: string; @IsOptional() diff --git a/apps/api/src/dashboard/widget-module-map.spec.ts b/apps/api/src/dashboard/widget-module-map.spec.ts new file mode 100644 index 0000000..e59f387 --- /dev/null +++ b/apps/api/src/dashboard/widget-module-map.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { WIDGET_MODULE_SLUGS, WIDGET_TYPES } from '@tessera/shared'; +import { CreateWidgetDto } from './dto/create-widget.dto'; +import { WIDGET_MODULE_MAP, getModuleSlugForWidgetType } from './widget-module-map'; + +/** + * quick-260922-m1h: Web und API lesen dieselbe Tabelle. Liefen sie + * auseinander, wuerde der Katalog eine Kachel anbieten, die der Server + * danach wieder herausfiltert (oder umgekehrt). + */ +describe('widget-module-map (quick-260922-m1h)', () => { + it('WIDGET_MODULE_MAP ist die Tabelle aus @tessera/shared, keine zweite Kopie', () => { + expect(WIDGET_MODULE_MAP).toBe(WIDGET_MODULE_SLUGS); + }); + + it('jeder Schluessel der Tabelle ist ein bekannter Widget-Typ', () => { + for (const type of Object.keys(WIDGET_MODULE_MAP)) { + expect(WIDGET_TYPES).toContain(type); + } + }); + + it('die neun heutigen Kacheln sind Plattform-Kacheln ohne Modulbezug', () => { + for (const type of WIDGET_TYPES) { + expect(getModuleSlugForWidgetType(type)).toBeUndefined(); + } + }); + + it('ein unbekannter Typ liefert undefined statt zu werfen', () => { + expect(getModuleSlugForWidgetType('gibt-es-nicht')).toBeUndefined(); + }); +}); + +/** + * Der Kern des Umbaus: die `@IsIn`-Whitelist ist keine handgepflegte zweite + * Liste mehr. Vergisst kuenftig jemand einen Eintrag in `packages/shared`, + * schlaegt dieser Test fehl, statt die API eine gueltige Kachel mit 400 + * ablehnen zu lassen. + */ +describe('CreateWidgetDto-Whitelist (quick-260922-m1h)', () => { + async function validateType(widgetType: string) { + const dto = plainToInstance(CreateWidgetDto, { widgetType }); + return validate(dto); + } + + it.each([...WIDGET_TYPES])('akzeptiert den Typ "%s"', async (widgetType) => { + await expect(validateType(widgetType)).resolves.toEqual([]); + }); + + it('lehnt einen Typ ab, der nicht in WIDGET_TYPES steht', async () => { + const errors = await validateType('gibt-es-nicht'); + + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('widgetType'); + expect(errors[0].constraints).toHaveProperty('isIn'); + }); +}); diff --git a/apps/api/src/dashboard/widget-module-map.ts b/apps/api/src/dashboard/widget-module-map.ts index c930dc8..10ec52e 100644 --- a/apps/api/src/dashboard/widget-module-map.ts +++ b/apps/api/src/dashboard/widget-module-map.ts @@ -1,3 +1,5 @@ +import { WIDGET_MODULE_SLUGS } from '@tessera/shared'; + /** * Zuordnung Widget-Typ → Modul (D-22, PERM-07). * @@ -8,24 +10,29 @@ * Schlüssel sind Werte von `WidgetInstance.widgetType`, Werte sind * Modul-Slugs aus `Module.slug`. * + * quick-260922-m1h: Die Tabelle selbst steht seit diesem Umbau in + * `packages/shared/src/index.ts` als `WIDGET_MODULE_SLUGS` — EINE Tabelle + * für beide Seiten, damit der Katalogfilter im Web + * (`visibleWidgetTypes`, Komfort) und dieser Server-Filter (verbindlich) + * nicht auseinanderlaufen. Hier steht nur noch der Lesezugriff; die + * öffentliche Schnittstelle dieser Datei bleibt unverändert, weil + * dashboard.service.spec.ts sie gezielt mockt. + * * Bewusst eine TypeScript-Konstante statt einer Spalte auf * `WidgetInstance`: eine Migration auf einer bereits befüllten Tabelle * für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als * diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5). * - * Die Tabelle ist am Ende dieser Phase bewusst leer: alle sieben heute - * registrierten Widget-Typen (clock/search/calendar/note/calculator/ - * favorites/stopwatch, siehe apps/web/src/components/dashboard/ - * widget-registry.tsx) sind Plattform-Widgets ohne Modulbezug. Das - * einzige bislang geplante modulgebundene Widget steht in - * .planning/REQUIREMENTS.md unter "Future Requirements (deferred)" und - * wird in dieser Phase bewusst nicht registriert. + * Die Tabelle ist bewusst leer: alle neun registrierten Widget-Typen + * (clock/search/calendar/note/calculator/favorites/stopwatch/ + * picture-frame/xframe) sind Plattform-Widgets ohne Modulbezug. Die erste + * modulgebundene Kachel trägt ihren Slug in `WIDGET_MODULE_SLUGS` ein. */ -export const WIDGET_MODULE_MAP: Readonly> = {}; +export const WIDGET_MODULE_MAP: Readonly> = WIDGET_MODULE_SLUGS; /** * Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn - * der Typ kein Modul-Widget ist (der heutige Zustand für alle sieben + * der Typ kein Modul-Widget ist (der heutige Zustand für alle neun * bestehenden Typen). Einziger Lesezugriff auf die Zuordnungstabelle, * damit Tests sie gezielt mocken können. */ diff --git a/apps/web/package.json b/apps/web/package.json index dad5379..22d2553 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,7 @@ "lint": "biome lint ." }, "dependencies": { + "@tessera/shared": "workspace:*", "@uiw/react-md-editor": "4.1.1", "fflate": "^0.8.3", "html-to-image": "1.11.13", diff --git a/apps/web/src/app/(portal)/page.test.tsx b/apps/web/src/app/(portal)/page.test.tsx index 55ab51e..db98ae4 100644 --- a/apps/web/src/app/(portal)/page.test.tsx +++ b/apps/web/src/app/(portal)/page.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // quick-260916-dyv: Dashboard-Seite — feste Aktionsleiste unten rechts @@ -47,8 +47,16 @@ vi.mock('@/components/dashboard/dashboard-grid', () => ({ ), })); +// quick-260922-m1h: Der Katalog bekommt die zugaenglichen Modul-Slugs als +// Prop von dieser Seite — die Attrappe merkt sie sich, damit der Test sie +// pruefen kann, ohne den echten Dialog zu rendern. +const catalogProps: { accessibleModuleSlugs?: readonly string[] | null } = {}; + vi.mock('@/components/dashboard/widget-catalog-modal', () => ({ - WidgetCatalogModal: () => null, + WidgetCatalogModal: (p: { accessibleModuleSlugs: readonly string[] | null }) => { + catalogProps.accessibleModuleSlugs = p.accessibleModuleSlugs; + return null; + }, })); vi.mock('@/components/dashboard/widgets/clock-widget', () => ({ ClockWidget: () => null })); @@ -62,6 +70,17 @@ vi.mock('@/components/dashboard/widgets/picture-frame-widget', () => ({ PictureF vi.mock('@/components/dashboard/widgets/xframe-widget', () => ({ XframeWidget: () => null })); beforeEach(() => { + catalogProps.accessibleModuleSlugs = undefined; + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => [ + { id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'tools' }, + { id: 'm2', slug: 'tender-radar', name: 'Tender', category: 'tools' }, + ], + })), + ); mockStore.isEditMode = false; mockStore.isLoading = false; mockStore.error = null; @@ -71,6 +90,7 @@ beforeEach(() => { afterEach(() => { cleanup(); + vi.unstubAllGlobals(); }); describe('DashboardPage (quick-260916-dyv)', () => { @@ -117,3 +137,35 @@ describe('DashboardPage (quick-260916-dyv)', () => { expect(mockStore.setEditMode).toHaveBeenCalledWith(true); }); }); + +describe('DashboardPage: zugaengliche Module fuer den Katalog (quick-260922-m1h)', () => { + it('Test 4: holt GET /modules/active mit Sitzungs-Keks und reicht die Slugs an den Katalog durch', async () => { + const { default: DashboardPage } = await import('./page'); + render(); + + await waitFor(() => { + expect(catalogProps.accessibleModuleSlugs).toEqual(['domaincheck', 'tender-radar']); + }); + + const call = vi.mocked(fetch).mock.calls[0]; + expect(String(call[0])).toContain('/modules/active'); + expect(call[1]).toMatchObject({ credentials: 'include' }); + }); + + it('Test 5: fail-closed — schlaegt der Abruf fehl, bleibt die Liste unbekannt (null)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('Netzwerk weg'); + }), + ); + + const { default: DashboardPage } = await import('./page'); + render(); + + await waitFor(() => { + expect(vi.mocked(fetch)).toHaveBeenCalled(); + }); + expect(catalogProps.accessibleModuleSlugs).toBeNull(); + }); +}); diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index 397b01f..c9a459c 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -1,11 +1,11 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; -import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget, wirePictureFrameWidget, wireXframeWidget } from '@/components/dashboard/widget-registry'; +import { registerWidget } from '@/components/dashboard/widget-registry'; import { ClockWidget } from '@/components/dashboard/widgets/clock-widget'; import { SearchWidget } from '@/components/dashboard/widgets/search-widget'; import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget'; @@ -18,20 +18,34 @@ import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget'; import { useDashboardStore } from '@/lib/stores/dashboard-store'; import type { WidgetType } from '@/components/dashboard/widget-registry'; -// Wire widget components into the registry (deferred to avoid circular deps) -wireClockWidget(ClockWidget); -wireSearchWidget(SearchWidget); -wireCalendarWidget(CalendarWidget); -wireNoteWidget(NoteWidget); -wireCalculatorWidget(CalculatorWidget); -wireStopwatchWidget(StopwatchWidget); -wireFavoritesWidget(FavoritesWidget); -wirePictureFrameWidget(PictureFrameWidget); -wireXframeWidget(XframeWidget); +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +// Anmeldung der Kachel-Komponenten an der Registry. Steht hier und nicht in +// der Registry selbst, weil die Komponenten ueber den Wrapper wieder die +// Registry importieren — ein Import aus der Registry heraus waere ein +// Zirkelimport. Seit quick-260922-m1h EINE Funktion statt neun `wireXWidget`. +registerWidget('clock', ClockWidget); +registerWidget('search', SearchWidget); +registerWidget('calendar', CalendarWidget); +registerWidget('note', NoteWidget); +registerWidget('calculator', CalculatorWidget); +registerWidget('stopwatch', StopwatchWidget); +registerWidget('favorites', FavoritesWidget); +registerWidget('picture-frame', PictureFrameWidget); +registerWidget('xframe', XframeWidget); + +/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */ +interface ActiveModule { + slug: string; +} export default function DashboardPage() { const t = useTranslations('widgets'); const [catalogOpen, setCatalogOpen] = useState(false); + // quick-260922-m1h: Slugs der Module, die dieser Benutzer nutzen darf — + // der Katalog blendet Kacheln gesperrter Module damit aus. `null` heisst + // "noch unbekannt oder Abruf fehlgeschlagen" und ist fail-closed. + const [accessibleModuleSlugs, setAccessibleModuleSlugs] = useState(null); const { layouts, @@ -51,6 +65,28 @@ export default function DashboardPage() { loadDashboard(); }, [loadDashboard]); + // Zugaengliche Module holen — gleiches Muster wie die Seitenleiste + // (`components/layout/sidebar.tsx`): derselbe Endpunkt, derselbe + // Sitzungs-Keks, Fehler still. Der Abruf steht hier und nicht im Dialog, + // damit der Dialog ein reines Anzeige-Bauteil bleibt. + const fetchAccessibleModules = useCallback(async () => { + try { + const res = await fetch(`${API_URL}/modules/active`, { + credentials: 'include', + }); + if (!res.ok) return; + const modules: ActiveModule[] = await res.json(); + setAccessibleModuleSlugs(modules.map((m) => m.slug)); + } catch { + // still: die Liste bleibt null, der Katalog zeigt dann nur + // Plattform-Kacheln (fail-closed). + } + }, []); + + useEffect(() => { + fetchAccessibleModules(); + }, [fetchAccessibleModules]); + if (isLoading) { return (
@@ -117,6 +153,7 @@ export default function DashboardPage() { setCatalogOpen(false)} + accessibleModuleSlugs={accessibleModuleSlugs} onAddWidget={(type: WidgetType) => { addWidget(type); }} diff --git a/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx b/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx index b669d24..ac3d629 100644 --- a/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx +++ b/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx @@ -36,12 +36,17 @@ vi.mock('next-intl', () => ({ }, })); +import { WIDGET_TYPES } from '@tessera/shared'; +import { WIDGET_REGISTRY } from './widget-registry'; import { WidgetCatalogModal } from './widget-catalog-modal'; +// Alle neun Kacheln sind heute Plattform-Kacheln ohne moduleSlug, also zeigt +// der Katalog sie auch bei leerer Modulliste vollstaendig an. const baseProps = { isOpen: true, onClose: vi.fn(), onAddWidget: vi.fn(), + accessibleModuleSlugs: [] as string[], }; afterEach(() => { @@ -130,3 +135,50 @@ describe('WidgetCatalogModal', () => { expect(screen.getByRole('button', { name: 'Schließen' })).toBeInTheDocument(); }); }); + +/** + * quick-260922-m1h: Der Katalog fuehrt keine zweite Typliste mehr — er leitet + * sie aus der Registry ab und filtert nach Modulzugriff. + */ +describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)', () => { + it('zeigt alle neun Kacheln in der Reihenfolge der Registry', () => { + render(); + + const dialog = screen.getByRole('dialog', { name: 'Widget hinzufügen' }); + const cards = Array.from( + dialog.querySelectorAll('button[data-widget-type]'), + ); + + expect(cards.map((c) => c.getAttribute('data-widget-type'))).toEqual([ + ...WIDGET_TYPES, + ]); + expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]); + }); + + it('eine Kachel MIT moduleSlug fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => { + // Die Registry traegt heute keine Modul-Kachel — fuer den Nachweis am + // echten Bauteil wird clock voruebergehend zu einer gemacht. + WIDGET_REGISTRY.clock.moduleSlug = 'proxmox'; + try { + const { rerender } = render(); + expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull(); + + rerender(); + expect(screen.getByRole('button', { name: /Uhr/ })).toBeInTheDocument(); + } finally { + WIDGET_REGISTRY.clock.moduleSlug = undefined; + } + }); + + it('fail-closed: schlaegt der Modulabruf fehl (null), verschwinden Kacheln MIT moduleSlug, Plattform-Kacheln bleiben', () => { + WIDGET_REGISTRY.clock.moduleSlug = 'proxmox'; + try { + render(); + + expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull(); + expect(screen.getByRole('button', { name: /Notiz/ })).toBeInTheDocument(); + } finally { + WIDGET_REGISTRY.clock.moduleSlug = undefined; + } + }); +}); diff --git a/apps/web/src/components/dashboard/widget-catalog-modal.tsx b/apps/web/src/components/dashboard/widget-catalog-modal.tsx index c556738..cffffc1 100644 --- a/apps/web/src/components/dashboard/widget-catalog-modal.tsx +++ b/apps/web/src/components/dashboard/widget-catalog-modal.tsx @@ -2,28 +2,30 @@ import { useEffect, useRef } from 'react'; import { useTranslations } from 'next-intl'; -import { WIDGET_REGISTRY, type WidgetType } from './widget-registry'; +import { WIDGET_REGISTRY, type WidgetType, visibleWidgetTypes } from './widget-registry'; interface WidgetCatalogModalProps { isOpen: boolean; onClose: () => void; onAddWidget: (type: WidgetType) => void; + /** + * Slugs der Module, die der angemeldete Benutzer nutzen darf — geholt von + * der Dashboard-Seite ueber `GET /modules/active` (quick-260922-m1h). + * `null` heisst "noch unbekannt oder Abruf fehlgeschlagen": dann bleiben + * Kacheln MIT `moduleSlug` ausgeblendet (fail-closed). + * + * Der Abruf steht bewusst NICHT in diesem Dialog, damit er ein reines + * Anzeige-Bauteil bleibt und ohne Netzwerk-Attrappe testbar ist. + */ + accessibleModuleSlugs: readonly string[] | null; } -const WIDGET_TYPES: WidgetType[] = [ - 'clock', - 'search', - 'calendar', - 'note', - 'calculator', - 'favorites', - 'stopwatch', - 'picture-frame', - 'xframe', -]; - /** * Modal dialog showing available widget types as selectable cards. + * + * quick-260922-m1h: Die Liste kommt aus WIDGET_REGISTRY (Reihenfolge der + * Registry-Definition) statt aus einer zweiten, hier gepflegten Liste — eine + * neue Kachel musste sonst an zwei Stellen eingetragen werden. * Click on a card adds the widget to the dashboard and closes the modal. * Escape to close, click outside to close, focus trap (D-01 flow). */ @@ -31,6 +33,7 @@ export function WidgetCatalogModal({ isOpen, onClose, onAddWidget, + accessibleModuleSlugs, }: WidgetCatalogModalProps) { const t = useTranslations('widgets'); const tCommon = useTranslations('common'); @@ -107,13 +110,14 @@ export function WidgetCatalogModal({ {/* 2x2 grid of widget type cards */}
- {WIDGET_TYPES.map((type) => { + {visibleWidgetTypes(WIDGET_REGISTRY, accessibleModuleSlugs).map((type) => { const def = WIDGET_REGISTRY[type]; const Icon = def.icon; return (