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