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