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
@@ -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;
}
});
});