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
+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;
}
if (registeredTypes.has(type)) return;
definition.component = component;
registeredTypes.add(type);
}
let searchWired = false;
export function wireSearchWidget(component: ComponentType<WidgetProps>) {
if (!searchWired) {
WIDGET_REGISTRY.search.component = component;
searchWired = 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 calendarWired = false;
export function wireCalendarWidget(component: ComponentType<WidgetProps>) {
if (!calendarWired) {
WIDGET_REGISTRY.calendar.component = component;
calendarWired = true;
}
}
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 {