refactor(quick-260922-m1h): Widget-Typen an einer Stelle, Katalog aus der Registry, Kachel kennt ihr Modul

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 16:03:58 +02:00
parent ee2b0256b5
commit 56c07c3581
14 changed files with 535 additions and 146 deletions
@@ -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()
@@ -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');
});
});
+16 -9
View File
@@ -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<Record<string, string>> = {};
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = 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.
*/
+1
View File
@@ -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",
+54 -2
View File
@@ -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(<DashboardPage />);
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(<DashboardPage />);
await waitFor(() => {
expect(vi.mocked(fetch)).toHaveBeenCalled();
});
expect(catalogProps.accessibleModuleSlugs).toBeNull();
});
});
+49 -12
View File
@@ -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<string[] | null>(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 (
<div className="flex min-h-[60vh] items-center justify-center">
@@ -117,6 +153,7 @@ export default function DashboardPage() {
<WidgetCatalogModal
isOpen={catalogOpen}
onClose={() => setCatalogOpen(false)}
accessibleModuleSlugs={accessibleModuleSlugs}
onAddWidget={(type: WidgetType) => {
addWidget(type);
}}
@@ -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(<WidgetCatalogModal {...baseProps} />);
const dialog = screen.getByRole('dialog', { name: 'Widget hinzufügen' });
const cards = Array.from(
dialog.querySelectorAll<HTMLButtonElement>('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(<WidgetCatalogModal {...baseProps} />);
expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull();
rerender(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
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(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={null} />);
expect(screen.queryByRole('button', { name: /Uhr/ })).toBeNull();
expect(screen.getByRole('button', { name: /Notiz/ })).toBeInTheDocument();
} finally {
WIDGET_REGISTRY.clock.moduleSlug = undefined;
}
});
});
@@ -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 */}
<div className="grid grid-cols-2 gap-3">
{WIDGET_TYPES.map((type) => {
{visibleWidgetTypes(WIDGET_REGISTRY, accessibleModuleSlugs).map((type) => {
const def = WIDGET_REGISTRY[type];
const Icon = def.icon;
return (
<button
key={type}
type="button"
data-widget-type={type}
onClick={() => {
onAddWidget(type);
onClose();
@@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest';
import { WIDGET_CONSTRAINTS, type WidgetType } from './widget-registry';
import { describe, expect, it, vi } from 'vitest';
import { WIDGET_TYPES } from '@tessera/shared';
import {
WIDGET_CONSTRAINTS,
WIDGET_REGISTRY,
type WidgetDefinition,
type WidgetProps,
type WidgetType,
registerWidget,
visibleWidgetTypes,
} from './widget-registry';
/**
* DASH-11: Every WidgetType entry in WIDGET_CONSTRAINTS must have
@@ -82,3 +91,118 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(counted).toBe(36);
});
});
/**
* quick-260922-m1h: Deckungsgleichheit. Die Typliste steht seit diesem Umbau
* EINMAL in `packages/shared`; Registry, Constraints-Tabelle und die
* Erwartungsliste dieses Tests muessen dieselben Schluessel in derselben
* Reihenfolge tragen. Dieser Test faengt kuenftig jede vergessene Stelle.
*/
describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => {
it('WIDGET_TYPES aus @tessera/shared, Registry-Schluessel und Constraints-Schluessel sind deckungsgleich (gleiche Reihenfolge)', () => {
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
expect(Object.keys(WIDGET_CONSTRAINTS)).toEqual([...WIDGET_TYPES]);
});
it('die neun erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
expect([...WIDGET_TYPES]).toEqual(ALL_WIDGET_TYPES);
});
it('jeder Registry-Eintrag traegt seinen eigenen Typ als `type`', () => {
for (const type of WIDGET_TYPES) {
expect(WIDGET_REGISTRY[type].type).toBe(type);
}
});
it('heute traegt keine der neun Kacheln einen moduleSlug (alle sind Plattform-Kacheln)', () => {
for (const type of WIDGET_TYPES) {
expect(WIDGET_REGISTRY[type].moduleSlug).toBeUndefined();
}
});
});
describe('registerWidget (quick-260922-m1h)', () => {
function makeComponent(): (props: WidgetProps) => null {
return () => null;
}
it('meldet eine Komponente fuer ihren Typ an', () => {
const before = WIDGET_REGISTRY.clock.component;
const component = makeComponent();
registerWidget('clock', component);
expect(WIDGET_REGISTRY.clock.component).toBe(component);
WIDGET_REGISTRY.clock.component = before;
});
it('ist idempotent: eine zweite Anmeldung desselben Typs ist ein No-Op (wie die alten wireX-Flags)', () => {
const before = WIDGET_REGISTRY.note.component;
const first = makeComponent();
const second = makeComponent();
registerWidget('note', first);
registerWidget('note', second);
expect(WIDGET_REGISTRY.note.component).toBe(first);
WIDGET_REGISTRY.note.component = before;
});
it('ein unbekannter Typ wirft in der Entwicklung', () => {
expect(() =>
// Absichtlich ein Typ ausserhalb der Union — genau der Fall, den der
// Wurf melden soll (eine Kachel, die in WIDGET_TYPES vergessen wurde).
registerWidget('proxmox' as WidgetType, makeComponent()),
).toThrow(/proxmox/);
});
it('ein unbekannter Typ wird in der Produktion still ignoriert', () => {
const previous = process.env.NODE_ENV;
vi.stubEnv('NODE_ENV', 'production');
expect(() => registerWidget('proxmox' as WidgetType, makeComponent())).not.toThrow();
vi.stubEnv('NODE_ENV', previous ?? 'test');
vi.unstubAllEnvs();
});
});
/**
* quick-260922-m1h: Der Katalogfilter als reine Funktion — so testbar ohne
* eine echte Modul-Kachel zu erfinden (Muster picture-frame-config.ts).
* WICHTIG (T-M1H-01): Dieser Filter ist Komfort. Die verbindliche
* Durchsetzung bleibt serverseitig in DashboardService.getWidgets.
*/
describe('visibleWidgetTypes (quick-260922-m1h)', () => {
const testRegistry: Record<string, Pick<WidgetDefinition, 'moduleSlug'>> = {
clock: {},
proxmox: { moduleSlug: 'proxmox' },
note: {},
};
it('behaelt die Reihenfolge der Registry bei', () => {
expect(visibleWidgetTypes(WIDGET_REGISTRY, [])).toEqual([...WIDGET_TYPES]);
});
it('Kacheln ohne moduleSlug sind immer sichtbar', () => {
expect(visibleWidgetTypes(testRegistry, [])).toEqual(['clock', 'note']);
});
it('eine Kachel mit moduleSlug fehlt, wenn der Slug nicht in den zugaenglichen Modulen steht', () => {
expect(visibleWidgetTypes(testRegistry, ['domaincheck'])).toEqual(['clock', 'note']);
});
it('eine Kachel mit moduleSlug erscheint, wenn der Slug in den zugaenglichen Modulen steht', () => {
expect(visibleWidgetTypes(testRegistry, ['domaincheck', 'proxmox'])).toEqual([
'clock',
'proxmox',
'note',
]);
});
it('fail-closed: ist die Modulliste unbekannt (null, z. B. fehlgeschlagener Abruf), verschwinden alle Kacheln MIT moduleSlug', () => {
expect(visibleWidgetTypes(testRegistry, null)).toEqual(['clock', 'note']);
});
});
@@ -1,7 +1,17 @@
import type { ComponentType } from 'react';
import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
/**
* Supported widget types for the dashboard.
* Die Typliste der Kacheln steht seit quick-260922-m1h EINMAL, in
* `packages/shared/src/index.ts` — dieselbe Liste, gegen die die API in
* `create-widget.dto.ts` mit `@IsIn` validiert. Vorher stand sie an sieben
* Stellen; vergass man eine, fehlte die Kachel im Katalog oder die API
* lehnte sie mit 400 ab.
*
* Hier weiter-exportiert, weil ein knappes Dutzend Web-Dateien den Typ seit
* jeher von der Registry bezieht (dashboard-grid, widget-wrapper,
* dashboard-store, widget-settings-panel ...).
*
* clock/search/calendar/note: implemented in previous plans.
* calculator/favorites/stopwatch: Phase 8 additions (der fruehere
* Einzel-Schnellzugriffs-Typ wurde in quick-260916-iex entfernt — Favoriten
@@ -9,16 +19,7 @@ import type { ComponentType } from 'react';
* picture-frame: Bilderrahmen (quick-260921-pi9).
* xframe: Webseite als Rahmen (quick-260921-qd3).
*/
export type WidgetType =
| 'clock'
| 'search'
| 'calendar'
| 'note'
| 'calculator'
| 'favorites'
| 'stopwatch'
| 'picture-frame'
| 'xframe';
export type { WidgetType };
/**
* Props contract that every widget component must accept.
@@ -76,6 +77,12 @@ export interface WidgetDefinition {
type: WidgetType;
nameKey: string;
descriptionKey: string;
/**
* Modul, zu dem diese Kachel gehoert (quick-260922-m1h), aus
* `WIDGET_MODULE_SLUGS`. Fehlt der Eintrag, ist es eine Plattform-Kachel
* und immer sichtbar — der heutige Zustand fuer alle neun Kacheln.
*/
moduleSlug?: string;
/** Inline SVG icon as React component */
icon: ComponentType<{ className?: string }>;
minW: number;
@@ -318,7 +325,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'clock.description',
icon: ClockIcon,
...WIDGET_CONSTRAINTS.clock,
component: PlaceholderWidget, // Replaced via wireClockWidget()
moduleSlug: WIDGET_MODULE_SLUGS.clock,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
},
search: {
type: 'search',
@@ -326,7 +334,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'search.description',
icon: SearchIcon,
...WIDGET_CONSTRAINTS.search,
component: PlaceholderWidget, // Replaced via wireSearchWidget()
moduleSlug: WIDGET_MODULE_SLUGS.search,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
},
calendar: {
type: 'calendar',
@@ -334,7 +343,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'calendar.description',
icon: CalendarIcon,
...WIDGET_CONSTRAINTS.calendar,
component: PlaceholderWidget, // Replaced via wireCalendarWidget()
moduleSlug: WIDGET_MODULE_SLUGS.calendar,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
},
note: {
type: 'note',
@@ -342,7 +352,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'note.description',
icon: NoteIcon,
...WIDGET_CONSTRAINTS.note,
component: PlaceholderWidget, // Replaced via wireNoteWidget()
moduleSlug: WIDGET_MODULE_SLUGS.note,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
},
calculator: {
type: 'calculator',
@@ -350,7 +361,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'calculator.description',
icon: CalculatorIcon,
...WIDGET_CONSTRAINTS.calculator,
component: PlaceholderWidget, // Replaced via wireCalculatorWidget()
moduleSlug: WIDGET_MODULE_SLUGS.calculator,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
},
favorites: {
type: 'favorites',
@@ -358,7 +370,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'favorites.description',
icon: FavoritesIcon,
...WIDGET_CONSTRAINTS.favorites,
component: PlaceholderWidget, // Replaced via wireFavoritesWidget()
moduleSlug: WIDGET_MODULE_SLUGS.favorites,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
},
stopwatch: {
type: 'stopwatch',
@@ -366,7 +379,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'stopwatch.description',
icon: StopwatchIcon,
...WIDGET_CONSTRAINTS.stopwatch,
component: PlaceholderWidget, // Replaced via wireStopwatchWidget()
moduleSlug: WIDGET_MODULE_SLUGS.stopwatch,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
},
'picture-frame': {
type: 'picture-frame',
@@ -374,7 +388,8 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'pictureFrame.description',
icon: PictureFrameIcon,
...WIDGET_CONSTRAINTS['picture-frame'],
component: PlaceholderWidget, // Replaced via wirePictureFrameWidget()
moduleSlug: WIDGET_MODULE_SLUGS['picture-frame'],
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
},
xframe: {
type: 'xframe',
@@ -382,82 +397,76 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'xframe.description',
icon: XframeIcon,
...WIDGET_CONSTRAINTS.xframe,
component: PlaceholderWidget, // Replaced via wireXframeWidget()
moduleSlug: WIDGET_MODULE_SLUGS.xframe,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
},
};
// Wire actual widget components lazily to avoid circular deps
// (imports are deferred so widget-registry can be imported by tests without
// pulling in the entire React tree)
/**
* Meldet die Komponente einer Kachel an ihrem Registry-Eintrag an
* (quick-260922-m1h — ersetzt die vormals neun `wireXWidget()`-Funktionen
* mit je eigenem Bool-Flag).
*
* Aufgerufen wird sie in `apps/web/src/app/(portal)/page.tsx` und NICHT
* hier: die Komponenten duerfen nicht aus der Registry heraus importiert
* werden, sonst entsteht ein Zirkelimport (jede Kachel importiert ueber den
* Wrapper wieder die Registry). Die Seite ist die Stelle, an der beides
* zusammenkommt.
*
* Mehrfachanmeldung desselben Typs ist ein No-Op — die erste gewinnt, genau
* wie die alten Flags. Ein Typ, der nicht in `WIDGET_TYPES` steht, wirft in
* der Entwicklung (dann fehlt der Eintrag in `packages/shared`) und wird in
* der Produktion ignoriert, damit eine vergessene Kachel nicht das ganze
* Dashboard mitreisst.
*/
const registeredTypes = new Set<WidgetType>();
let clockWired = false;
export function wireClockWidget(component: ComponentType<WidgetProps>) {
if (!clockWired) {
WIDGET_REGISTRY.clock.component = component;
clockWired = true;
export function registerWidget(type: WidgetType, component: ComponentType<WidgetProps>) {
const definition: WidgetDefinition | undefined = WIDGET_REGISTRY[type];
if (!definition) {
if (process.env.NODE_ENV !== 'production') {
throw new Error(
`registerWidget: unbekannter Widget-Typ "${type}". Fehlt der Typ in WIDGET_TYPES (packages/shared/src/index.ts)?`,
);
}
return;
}
let searchWired = false;
export function wireSearchWidget(component: ComponentType<WidgetProps>) {
if (!searchWired) {
WIDGET_REGISTRY.search.component = component;
searchWired = true;
}
if (registeredTypes.has(type)) return;
definition.component = component;
registeredTypes.add(type);
}
let calendarWired = false;
export function wireCalendarWidget(component: ComponentType<WidgetProps>) {
if (!calendarWired) {
WIDGET_REGISTRY.calendar.component = component;
calendarWired = true;
}
}
/**
* Welche Kacheln der Katalog zeigen darf (quick-260922-m1h).
*
* Eine Kachel ohne `moduleSlug` ist immer sichtbar; eine Kachel MIT
* `moduleSlug` nur, wenn der Slug unter den zugaenglichen Modulen steht.
* `accessibleModuleSlugs === null` heisst "Modulliste unbekannt" (Abruf
* laeuft noch oder ist fehlgeschlagen) — dann sind Modul-Kacheln
* ausgeblendet, fail-closed wie serverseitig.
*
* ACHTUNG (T-M1H-01): Das hier ist reiner Komfort — es verhindert nur, dass
* jemand eine Kachel anlegt, die ihm danach kommentarlos wieder verschwindet.
* Die verbindliche Durchsetzung bleibt serverseitig in
* `DashboardService.getWidgets` (fail-closed) und im Modul-Guard der
* jeweiligen Daten-Endpunkte. Diesen Filter zu umgehen bringt nichts.
*
* Reine Funktion mit der Registry als Parameter, damit sie ohne eine echte
* Modul-Kachel testbar ist (Muster `picture-frame-config.ts`).
*/
export function visibleWidgetTypes<T extends string>(
registry: Record<T, Pick<WidgetDefinition, 'moduleSlug'>>,
accessibleModuleSlugs: readonly string[] | null,
): T[] {
const types = Object.keys(registry) as T[];
let noteWired = false;
export function wireNoteWidget(component: ComponentType<WidgetProps>) {
if (!noteWired) {
WIDGET_REGISTRY.note.component = component;
noteWired = true;
}
}
let calculatorWired = false;
export function wireCalculatorWidget(component: ComponentType<WidgetProps>) {
if (!calculatorWired) {
WIDGET_REGISTRY.calculator.component = component;
calculatorWired = true;
}
}
let favoritesWired = false;
export function wireFavoritesWidget(component: ComponentType<WidgetProps>) {
if (!favoritesWired) {
WIDGET_REGISTRY.favorites.component = component;
favoritesWired = true;
}
}
let stopwatchWired = false;
export function wireStopwatchWidget(component: ComponentType<WidgetProps>) {
if (!stopwatchWired) {
WIDGET_REGISTRY.stopwatch.component = component;
stopwatchWired = true;
}
}
let pictureFrameWired = false;
export function wirePictureFrameWidget(component: ComponentType<WidgetProps>) {
if (!pictureFrameWired) {
WIDGET_REGISTRY['picture-frame'].component = component;
pictureFrameWired = true;
}
}
let xframeWired = false;
export function wireXframeWidget(component: ComponentType<WidgetProps>) {
if (!xframeWired) {
WIDGET_REGISTRY.xframe.component = component;
xframeWired = true;
}
return types.filter((type) => {
const slug = registry[type].moduleSlug;
if (slug === undefined) return true;
if (accessibleModuleSlugs === null) return false;
return accessibleModuleSlugs.includes(slug);
});
}
+5 -3
View File
@@ -22,9 +22,11 @@ export interface AppVersionInfo {
}
/**
* Spiegel von `VersionResponse` aus `packages/shared`: `apps/web` haengt
* nicht von `@tessera/shared` ab, und ein neuer Import wuerde Lockfile und
* die deps-Stufe des Dockerfiles aendern. Die API-Wahrheit bleibt dort.
* Spiegel von `VersionResponse` aus `packages/shared`. Die Begruendung
* "apps/web haengt nicht von @tessera/shared ab" gilt seit quick-260922-m1h
* nicht mehr — die Kachel-Typliste wird von dort importiert. Dieser Spiegel
* bleibt trotzdem stehen: ihn aufzuloesen war nicht Teil jenes Umbaus und
* braucht einen eigenen Durchgang. Die API-Wahrheit bleibt in packages/shared.
*/
export interface ApiVersionInfo {
name: string;
+3 -2
View File
@@ -17,8 +17,9 @@ export type DesktopPlatform = 'windows' | 'linux';
/**
* Spiegel von `DesktopLatestFile`/`DesktopLatestResponse` aus
* `packages/shared`: `apps/web` haengt nicht von `@tessera/shared` ab
* (gleiche Begruendung wie in `app-version.ts`). Die API-Wahrheit bleibt in
* `packages/shared` (gleicher Stand wie in `app-version.ts`: seit
* quick-260922-m1h waere ein Import moeglich, der Spiegel wurde aber bewusst
* nicht mit aufgeloest). Die API-Wahrheit bleibt in
* `apps/api/src/desktop/desktop.service.ts`.
*/
export interface DesktopFileInfo {
+43
View File
@@ -76,3 +76,46 @@ export interface DesktopLatestResponse {
buildTime: string;
files: Partial<Record<DesktopPlatform, DesktopLatestFile>>;
}
/**
* Dashboard-Kacheln: EINE Typliste fuer Web und API (quick-260922-m1h).
*
* Vorher stand dieselbe Liste an sieben Stellen (Union-Typ, Constraints,
* Registry, Katalog-Liste, `@IsIn`-Whitelist ...). Vergass man eine, fehlte
* die Kachel im Katalog oder die API lehnte sie mit 400 ab. Seit m1h leiten
* beide Seiten von hier ab: `apps/web/src/components/dashboard/
* widget-registry.tsx` (Registry + Katalog) und
* `apps/api/src/dashboard/dto/create-widget.dto.ts` (`@IsIn`).
*
* ACHTUNG: Dies ist der erste LAUFZEIT-Import aus `@tessera/shared` (alle
* uebrigen sind `import type`). `packages/shared` liefert rohes TypeScript
* (`main: src/index.ts`, kein Bauschritt); die API laedt es im Betrieb ueber
* das native Type-Stripping von Node 24. Deshalb darf diese Datei nur
* loeschbare Syntax enthalten — keine `enum`, kein `namespace`, keine
* Parameter-Eigenschaften.
*/
export const WIDGET_TYPES = [
'clock',
'search',
'calendar',
'note',
'calculator',
'favorites',
'stopwatch',
'picture-frame',
'xframe',
] as const;
export type WidgetType = (typeof WIDGET_TYPES)[number];
/**
* Kachel → Modul-Slug. Eine Kachel ohne Eintrag ist immer sichtbar; eine
* Kachel MIT Eintrag erscheint nur fuer Benutzer, die das Modul nutzen
* duerfen — im Katalog (Komfort, `visibleWidgetTypes`) und verbindlich
* serverseitig in `DashboardService.getWidgets` (fail-closed).
*
* Heute bewusst leer: alle neun Kacheln sind Plattform-Kacheln ohne
* Modulbezug. Die erste modulgebundene Kachel (Proxmox) traegt hier ihren
* Slug ein.
*/
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {};
+3
View File
@@ -181,6 +181,9 @@ importers:
apps/web:
dependencies:
'@tessera/shared':
specifier: workspace:*
version: link:../../packages/shared
'@uiw/react-md-editor':
specifier: 4.1.1
version: 4.1.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)