fix(dashboard): Rasterversion schuetzen, Kalender-Mindestbreite, Breiten je Breakpoint, Hintergrund

- Layout mit neuerer Rasterversion wird nie gespeichert, Bearbeiten gesperrt mit Hinweis
- Kalender minW 11 (~260 px bei lg), Breiten je Breakpoint auf Spaltenzahl begrenzt
- optimistische Ruecksetzung nur, wenn noch der gesetzte Wert steht
- Titel-Schalter mit fester Beschriftung + aria-pressed
- Hintergrund-Dialog: Fokus rein/zurueck, Tab bleibt im Dialog
- Loeschen eines Bildes setzt eine darauf zeigende Hintergrund-Wahl zurueck
- Uebersetzungen und CHANGELOG

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 03:20:16 +02:00
parent 071082983b
commit 12214a948e
21 changed files with 1194 additions and 205 deletions
+10
View File
@@ -10,10 +10,20 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Geändert
- Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt.
- Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird.
- Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts.
### Behoben
- Eigene Module: Lässt sich ein Eintrag nicht laden, sagt Tessera das jetzt, statt „nicht gefunden“ zu melden. Fehlende Berechtigung und ungültige Angaben werden beim Speichern und Löschen eigens genannt.
- Seitenleiste: Eingeklappt stehen eigene Module jetzt bei ihrer Kategorie, in derselben Reihenfolge wie ausgeklappt.
- Erinnerungen: Ohne Browser-Speicher (etwa im privaten Fenster) kam dieselbe Benachrichtigung alle 10 Sekunden – jetzt nur einmal.
- Erinnerungen: Nach einer Änderung von Datum oder Uhrzeit kommt die E-Mail zuverlässig zur neuen Zeit. Deaktivierte Benutzer bekommen keine Erinnerungs-E-Mails mehr.
- Erinnerungen: „Später erinnern“ zeigt „Heute um …“, wenn die Uhrzeit heute noch kommt, statt fälschlich „Morgen um …“. Speichern ohne Zeitänderung verschiebt die Fälligkeit nicht mehr um Sekunden.
- Dashboard: Wird ein Bild gelöscht, das als Hintergrund gewählt war, gilt wieder „kein Hintergrund“.
- Dashboard: Ein noch offener Tab mit älterer Tessera-Version kann die Anordnung nicht mehr verziehen; er bittet stattdessen, die Seite neu zu laden.
- Desktop-App: Während ein Update installiert wird, bietet das Menü kein zweites mehr an. Nach einer fehlgeschlagenen Update-Prüfung genügt wieder ein Klick zum Installieren.
- Desktop-App: „Auf Version … aktualisieren“ im Menü des Tessera-Symbols scheiterte mit „Signaturprüfung fehlgeschlagen“ und öffnete stattdessen die Download-Seite, wenn der Server seit der letzten Update-Prüfung der App eine neuere Version bekommen hatte. Die App fragt jetzt beim Klick zuerst frisch nach und installiert genau die Version, die der Server in diesem Moment anbietet.
- Dashboard, Favoriten: Eine neu eingetragene Logo-Adresse wird jetzt sofort angezeigt. Bisher blieb ein früher hochgeladenes eigenes Symbol stehen und verdeckte die neue Adresse; jetzt ersetzt die neue Adresse es.
- Dashboard, Favoriten: Eine Logo-Adresse lässt sich jetzt auch speichern, wenn Tessera das Bild selbst nicht laden kann – etwa bei Seiten im internen Netz, die nur Ihrem Browser das Symbol geben. Die Kachel lädt das Bild dann direkt in Ihrem Browser. Auch die automatische Erkennung findet das Symbol solcher Seiten jetzt eher, statt auf ein Ersatzsymbol zurückzufallen.
@@ -21,7 +21,11 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
}),
}));
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { DashboardImagesService } from './dashboard-images.service';
@@ -73,11 +77,21 @@ interface BoundCall {
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
interface UserRow {
id: string;
dashboardBackground: unknown;
}
interface FakePrisma {
dashboardImage: ModelMethods;
user: ModelMethods;
__rows: ImageRow[];
__users: UserRow[];
__boundCallLog: BoundCall[];
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
__makeBoundClient(
tenantId: string,
userId?: string,
): { dashboardImage: ModelMethods; user: ModelMethods };
}
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
@@ -147,8 +161,28 @@ function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
return out;
}
function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
function makeFakePrisma(rows: ImageRow[] = [], users: UserRow[] = []): FakePrisma {
const boundCallLog: BoundCall[] = [];
// quick-260930: Hintergrund-Wahl (`User.dashboardBackground`) — bedingtes
// updateMany ueber den JSON-Pfad `imageId`, wie Prisma es auf PostgreSQL filtert.
const user: ModelMethods = {
updateMany: vi.fn(async (raw: unknown) => {
const args = raw as {
where: { id: string; dashboardBackground: { path: string[]; equals: unknown } };
data: { dashboardBackground: unknown };
};
const [key] = args.where.dashboardBackground.path;
let count = 0;
for (const u of users) {
const bg = u.dashboardBackground as Record<string, unknown> | null;
if (u.id !== args.where.id || !bg || bg[key] !== args.where.dashboardBackground.equals)
continue;
u.dashboardBackground = args.data.dashboardBackground;
count++;
}
return { count };
}),
};
const dashboardImage: ModelMethods = {
findMany: vi.fn(async (raw: unknown) => {
const args = raw as {
@@ -168,11 +202,17 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
}),
count: vi.fn(async (raw: unknown) => {
const args = raw as { where: { tenantId: string; userId: string } };
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
return rows.filter(
(r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId,
).length;
}),
create: vi.fn(async (raw: unknown) => {
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
const created = makeRow({
id: `new-${rows.length + 1}`,
...args.data,
createdAt: new Date('2026-02-02'),
});
rows.push(created);
return pick(created, args.select);
}),
@@ -196,20 +236,29 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
}),
};
function wrap(tenantId: string, userId?: string) {
function wrapModel(model: string, methods: ModelMethods, tenantId: string, userId?: string) {
const wrapped: ModelMethods = {};
for (const method of Object.keys(dashboardImage)) {
for (const method of Object.keys(methods)) {
wrapped[method] = async (...args: unknown[]) => {
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
return dashboardImage[method](...args);
boundCallLog.push({ tenantId, userId, model, method });
return methods[method](...args);
};
}
return { dashboardImage: wrapped };
return wrapped;
}
function wrap(tenantId: string, userId?: string) {
return {
dashboardImage: wrapModel('dashboardImage', dashboardImage, tenantId, userId),
user: wrapModel('user', user, tenantId, userId),
};
}
const fake: FakePrisma = {
dashboardImage,
user,
__rows: rows,
__users: users,
__boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string, userId?: string) {
return wrap(tenantId, userId);
@@ -253,9 +302,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
const result = await makeService(prisma).list('user-1', 'tenant-1');
expect(result.map((r) => r.id)).toEqual(['a', 'b']);
for (const r of result) {
expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
expect(Object.keys(r).sort()).toEqual([
'createdAt',
'id',
'mimeType',
'originalName',
'size',
]);
}
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
select: Record<string, boolean>;
};
expect(call.select.data).toBeUndefined();
expect(call.select.storagePath).toBeUndefined();
});
@@ -274,14 +331,22 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
expect(result.mimeType).toBe('image/png');
expect(result.originalName).toBe('irgendwas.txt');
expect(result.size).toBe(PNG.length);
expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
expect(Object.keys(result).sort()).toEqual([
'createdAt',
'id',
'mimeType',
'originalName',
'size',
]);
expect(prisma.__rows[0].userId).toBe('user-1');
expect(prisma.__rows[0].tenantId).toBe('tenant-1');
});
it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
const prisma = makeFakePrisma();
await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow(
await expect(
makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png')),
).rejects.toThrow(
new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
);
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
@@ -304,7 +369,9 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
});
it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' }));
const foreign = Array.from({ length: 30 }, (_, i) =>
makeRow({ id: `f${i}`, userId: 'user-2' }),
);
const prisma = makeFakePrisma(foreign);
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
mimeType: 'image/png',
@@ -321,14 +388,20 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
});
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
const service = makeService(prisma);
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
});
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
@@ -347,11 +420,52 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
const service = makeService(prisma);
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']);
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
expect(prisma.__rows).toHaveLength(2);
});
it('Test 11b (quick-260930): war das geloeschte Bild der Dashboard-Hintergrund, steht die Wahl danach auf „kein Hintergrund“ — andere Wahlen und andere Benutzer bleiben', async () => {
const users: UserRow[] = [
{ id: 'user-1', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
{ id: 'user-2', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
];
const prisma = makeFakePrisma(
[makeStoredRow({ id: 'eigen' }), makeStoredRow({ id: 'zweites' })],
users,
);
const service = makeService(prisma);
await service.remove('eigen', 'user-1', 'tenant-1');
expect(users[0].dashboardBackground).toEqual({ kind: 'none' });
// nur die eigene Zeile
expect(users[1].dashboardBackground).toEqual({ kind: 'image', imageId: 'eigen' });
const call = vi.mocked(prisma.user.updateMany).mock.calls[0][0];
expect(call).toEqual({
where: { id: 'user-1', dashboardBackground: { path: ['imageId'], equals: 'eigen' } },
data: { dashboardBackground: { kind: 'none' } },
});
// Ein anderes Bild loeschen laesst eine andere Wahl stehen.
users[0].dashboardBackground = { kind: 'preset', id: 'mist' };
await service.remove('zweites', 'user-1', 'tenant-1');
expect(users[0].dashboardBackground).toEqual({ kind: 'preset', id: 'mist' });
});
it('Test 11c (quick-260930): scheitert das Zuruecksetzen der Wahl, ist das Bild trotzdem geloescht (kein Fehler nach aussen)', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'eigen' })]);
vi.mocked(prisma.user.updateMany).mockRejectedValueOnce(new Error('db weg'));
await expect(makeService(prisma).remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({
id: 'eigen',
});
expect(prisma.__rows).toHaveLength(0);
});
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
const prisma = makeFakePrisma([]);
const service = makeService(prisma);
@@ -370,7 +484,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
// Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
const methods = prisma.__boundCallLog.map((c) => c.method);
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
// quick-260930: `remove` setzt zusaetzlich die Hintergrund-Wahl zurueck (user.updateMany).
expect(methods).toEqual([
'findMany',
'count',
'create',
'findUnique',
'findUnique',
'delete',
'updateMany',
]);
expect(prisma.__boundCallLog.at(-1)?.model).toBe('user');
expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
for (const c of prisma.__boundCallLog) {
expect(c.tenantId).toBe('tenant-1');
@@ -387,14 +511,20 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
const onDisk = storedFile('user-1', result.id);
expect(fs.existsSync(onDisk)).toBe(true);
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
expect(prisma.__rows[0].storagePath).toBe(
`user-files/dashboard-images/user-1/${result.id}.png`,
);
// Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
// die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
// die Zeile.
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as {
data: Record<string, unknown>;
};
expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
expect(createArgs.data.id).toBe(result.id);
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(result.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(createArgs.data).not.toHaveProperty('data');
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
});
@@ -444,7 +574,10 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
const prisma = makeFakePrisma([
makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }),
makeRow({
id: 'datei-fehlt',
storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png',
}),
]);
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
@@ -456,7 +589,9 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
const onDisk = storedFile('user-1', 'weg');
expect(fs.existsSync(onDisk)).toBe(true);
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' });
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({
id: 'weg',
});
expect(prisma.__rows).toHaveLength(0);
expect(fs.existsSync(onDisk)).toBe(false);
});
@@ -1,3 +1,6 @@
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import {
BadRequestException,
Injectable,
@@ -5,12 +8,9 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import {
DASHBOARD_IMAGE_MAX_COUNT,
type DashboardImageMime,
@@ -314,6 +314,15 @@ export class DashboardImagesService {
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
* protokolliert und geschluckt (T-HK4-04).
*
* quick-260930: War das Bild der Dashboard-Hintergrund des Benutzers
* (`User.dashboardBackground` = `{ kind: 'image', imageId: <diese UUID> }`),
* wird die Wahl im selben Vorgang auf „kein Hintergrund“ gesetzt — sonst
* zeigte sie auf ein Bild, das es nicht mehr gibt. Bedingtes `updateMany`
* (JSON-Pfad `imageId`), damit jede andere Wahl unberuehrt bleibt; nur die
* eigene Zeile (`id: userId`). Ein Fehler dabei wird wie beim Entfernen der
* Datei protokolliert und geschluckt: das Bild ist schon weg, und das Web
* zeigt eine Wahl mit nicht ladbarem Bild ohnehin als „kein Hintergrund“.
*/
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -323,6 +332,19 @@ export class DashboardImagesService {
}
await tenantPrisma.dashboardImage.delete({ where: { id } });
try {
await tenantPrisma.user.updateMany({
where: { id: userId, dashboardBackground: { path: ['imageId'], equals: id } },
data: { dashboardBackground: { kind: 'none' } },
});
} catch (error) {
this.logger.warn(
`Hintergrund-Wahl zum geloeschten Bilderrahmen-Bild ${id} konnte nicht zurueckgesetzt werden: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
const absolute = absoluteImagePath(row.storagePath);
if (absolute !== null) {
try {
@@ -1,4 +1,4 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type AuthUser, useAuthStore } from '@/lib/stores/auth-store';
@@ -13,6 +13,23 @@ vi.mock('@/lib/auth-actions', () => ({
updateDashboardBackgroundAction: (...args: unknown[]) => updateDashboardBackgroundAction(...args),
}));
// quick-260930: Auswahlfenster (Fokus, geloeschtes Bild) braucht Uebersetzung,
// Erscheinungsbild und die Bilderliste.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock('next-themes', () => ({
useTheme: () => ({ resolvedTheme: 'light' }),
}));
const fetchDashboardImages = vi.fn();
vi.mock('@/lib/dashboard-images-api', () => ({
fetchDashboardImages: (...args: unknown[]) => fetchDashboardImages(...args),
uploadDashboardImage: vi.fn(),
dashboardImageSrc: (id: string) => `/api-proxy/dashboard/images/${id}`,
}));
const IMAGE_ID = '11111111-2222-3333-4444-555555555555';
const LEGACY_KEY = 'tessera.dashboardBackground.u1';
function makeUser(overrides: Partial<AuthUser> = {}): AuthUser {
@@ -141,3 +158,147 @@ describe('useDashboardBackground (quick-260928-ujj)', () => {
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
});
});
describe('useDashboardBackground — Rueckfall nur fuer den eigenen Wert (quick-260930)', () => {
it('scheitert der erste von zwei schnellen Klicks, bleibt die zweite Wahl stehen', async () => {
let rejectFirst: (e: Error) => void = () => {};
updateDashboardBackgroundAction
.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectFirst = reject;
}),
)
.mockResolvedValueOnce({ success: true });
useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'none' } }) });
const { result } = await renderBackgroundHook();
act(() => result.current.choose({ kind: 'preset', id: 'mist' }));
act(() => result.current.choose({ kind: 'preset', id: 'dunes' }));
await act(async () => {
rejectFirst(new Error('offline'));
await Promise.resolve();
});
expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' });
expect(useAuthStore.getState().user?.dashboardBackground).toEqual({
kind: 'preset',
id: 'dunes',
});
});
});
describe('useDashboardBackground — Bild nicht mehr vorhanden (quick-260930)', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('laesst sich das gewaehlte Bild nicht laden, gilt die Wahl als „kein Hintergrund“ (ohne zu speichern)', async () => {
class FailingImage {
onerror: (() => void) | null = null;
set src(_value: string) {
setTimeout(() => this.onerror?.(), 0);
}
}
vi.stubGlobal('Image', FailingImage);
useAuthStore.setState({
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
});
const { result } = await renderBackgroundHook();
await waitFor(() => expect(result.current.background).toEqual({ kind: 'none' }));
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
});
it('ein ladbares Bild bleibt gewaehlt', async () => {
class LoadingImage {
onerror: (() => void) | null = null;
onload: (() => void) | null = null;
set src(_value: string) {
setTimeout(() => this.onload?.(), 0);
}
}
vi.stubGlobal('Image', LoadingImage);
useAuthStore.setState({
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
});
const { result } = await renderBackgroundHook();
await act(async () => {
await new Promise((r) => setTimeout(r, 5));
});
expect(result.current.background).toEqual({ kind: 'image', imageId: IMAGE_ID });
});
});
describe('BackgroundPicker (quick-260930)', () => {
async function renderPicker(value: import('@/lib/dashboard-background').DashboardBackground) {
const { BackgroundPicker } = await import('./dashboard-background');
const onChange = vi.fn();
render(<BackgroundPicker value={value} onChange={onChange} />);
return { onChange, trigger: screen.getByRole('button', { name: 'button' }) };
}
beforeEach(() => {
fetchDashboardImages.mockReset();
fetchDashboardImages.mockResolvedValue([]);
});
it('Oeffnen setzt den Fokus auf die erste Auswahl, Escape schliesst und gibt ihn an den Knopf zurueck', async () => {
const { trigger } = await renderPicker({ kind: 'none' });
trigger.focus();
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog');
const first = screen.getByRole('button', { name: 'none' });
expect(dialog).toContainElement(first);
expect(first).toHaveFocus();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog')).toBeNull();
await waitFor(() => expect(trigger).toHaveFocus());
});
it('Klick nach draussen schliesst und gibt den Fokus an den Knopf zurueck', async () => {
const { trigger } = await renderPicker({ kind: 'none' });
fireEvent.click(trigger);
await screen.findByRole('dialog');
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('dialog')).toBeNull();
await waitFor(() => expect(trigger).toHaveFocus());
});
it('Tab bleibt im Fenster: vom letzten Knopf zum ersten, Umschalt+Tab vom ersten zum letzten', async () => {
const { trigger } = await renderPicker({ kind: 'none' });
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog');
const buttons = Array.from(dialog.querySelectorAll('button'));
const first = buttons[0];
const last = buttons[buttons.length - 1];
last.focus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(first).toHaveFocus();
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
expect(last).toHaveFocus();
});
it('gewaehltes Bild fehlt in der Bilderliste -> „Keiner“ ist markiert', async () => {
fetchDashboardImages.mockResolvedValue([
{ id: 'anderes', originalName: 'urlaub.jpg', mimeType: 'image/jpeg', size: 1, createdAt: '' },
]);
const { trigger } = await renderPicker({ kind: 'image', imageId: IMAGE_ID });
fireEvent.click(trigger);
await screen.findByRole('button', { name: 'urlaub.jpg' });
expect(screen.getByRole('button', { name: 'none' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'urlaub.jpg' })).toHaveAttribute(
'aria-pressed',
'false',
);
});
});
@@ -26,6 +26,23 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
useAuthStore.getState().setUser({ ...current, dashboardBackground: value });
}
/**
* Nimmt eine fehlgeschlagene Wahl zurueck — aber nur, wenn im Store noch
* GENAU dieser optimistisch gesetzte Wert steht (quick-260930). Hat der
* Benutzer inzwischen etwas anderes gewaehlt, wuerde der Fehler des ersten
* Klicks sonst die zweite Wahl ueberschreiben.
*/
function revertStoredBackground(
userId: string,
optimistic: DashboardBackground,
previous: DashboardBackground | null,
) {
const current = useAuthStore.getState().user;
if (!current || current.id !== userId) return;
if (current.dashboardBackground !== optimistic) return;
setStoredBackground(userId, previous);
}
/**
* Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj).
*
@@ -34,18 +51,43 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
* die Server-Aktion; schlaegt das fehl, kommt der vorige Wert zurueck.
* Ist der Server-Wert `null` und liegt noch eine alte localStorage-Wahl vor,
* wird sie genau einmal je Benutzerkennung uebernommen.
*
* quick-260930: Zeigt die Wahl auf ein eigenes Bild, das nicht (mehr) zu
* laden ist — etwa weil es im Bilderrahmen geloescht wurde (die API setzt die
* gespeicherte Wahl dabei auf „kein Hintergrund“, eine schon geladene Sitzung
* kennt aber noch den alten Wert) —, gilt sie als „kein Hintergrund“. Das
* ist nur Anzeige; gespeichert wird dabei nichts.
*/
export function useDashboardBackground() {
const user = useAuthStore((s) => s.user);
const userId = user?.id ?? null;
const stored = user?.dashboardBackground ?? null;
const background = stored ?? NO_BACKGROUND;
const imageId = stored?.kind === 'image' ? stored.imageId : null;
const [brokenImageId, setBrokenImageId] = useState<string | null>(null);
const background =
stored === null || (imageId !== null && imageId === brokenImageId) ? NO_BACKGROUND : stored;
const migratedFor = useRef<string | null>(null);
// Laedt das gewaehlte Bild einmal zur Probe; schlaegt das fehl, wird die
// Wahl als „kein Hintergrund“ angezeigt.
useEffect(() => {
if (imageId === null || typeof Image === 'undefined') return;
let active = true;
const probe = new Image();
probe.onerror = () => {
if (active) setBrokenImageId(imageId);
};
probe.src = dashboardImageSrc(imageId);
return () => {
active = false;
probe.onerror = null;
};
}, [imageId]);
const save = useCallback(
(id: string, value: DashboardBackground, previous: DashboardBackground | null) => {
setStoredBackground(id, value);
const revert = () => setStoredBackground(id, previous);
const revert = () => revertStoredBackground(id, value, previous);
updateDashboardBackgroundAction(value)
.then((result) => {
if (!result.success) revert();
@@ -141,23 +183,88 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
const [images, setImages] = useState<DashboardImageMeta[] | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Liste erfolgreich geladen? Nur dann laesst sich sagen, dass ein gewaehltes
// Bild fehlt (nach einem Abruffehler ist die Liste nur ersatzweise leer).
const [imagesListed, setImagesListed] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const wasOpen = useRef(false);
// Gewaehltes eigenes Bild, das es nicht mehr gibt -> „Keiner“ markieren
// (quick-260930).
const shown: DashboardBackground =
value.kind === 'image' &&
imagesListed &&
images !== null &&
!images.some((image) => image.id === value.imageId)
? NO_BACKGROUND
: value;
useEffect(() => {
if (!open || images !== null) return;
fetchDashboardImages()
.then(setImages)
.then((list) => {
setImages(list);
setImagesListed(true);
})
.catch(() => setImages([]));
}, [open, images]);
// Fokus (quick-260930): beim Oeffnen auf die erste Auswahl („Keiner“),
// nach dem Schliessen zurueck auf den Knopf „Hintergrund“ — ausser der
// Benutzer hat zum Schliessen etwas anderes Fokussierbares angeklickt.
useEffect(() => {
if (open) {
wasOpen.current = true;
dialogRef.current?.querySelector<HTMLButtonElement>('button:not([disabled])')?.focus();
return;
}
if (!wasOpen.current) return;
wasOpen.current = false;
// Einen Takt spaeter pruefen: beim Klick nach draussen setzt der Browser
// den Fokus erst NACH dem mousedown-Horcher (auf das Ziel oder den body).
const timer = window.setTimeout(() => {
const active = document.activeElement;
if (!active || active === document.body || rootRef.current?.contains(active)) {
triggerRef.current?.focus();
}
}, 0);
return () => window.clearTimeout(timer);
}, [open]);
useEffect(() => {
if (!open) return;
function onPointer(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
if (e.key === 'Escape') {
setOpen(false);
return;
}
// Tab bleibt im Fenster (Muster ActivationDialog im Marktplatz):
// vom letzten Knopf zum ersten und mit Umschalt+Tab umgekehrt.
if (e.key === 'Tab' && dialogRef.current) {
const items = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled])'),
);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (!active || !dialogRef.current.contains(active)) {
e.preventDefault();
first.focus();
} else if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
}
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
@@ -190,7 +297,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
preview: CSSProperties,
content?: React.ReactNode,
) => {
const selected = sameChoice(value, choice);
const selected = sameChoice(shown, choice);
return (
<li key={key}>
<button
@@ -223,6 +330,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
return (
<div ref={rootRef} className="relative">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
@@ -253,6 +361,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
{open && (
<div
ref={dialogRef}
role="dialog"
aria-label={t('title')}
className="absolute right-0 top-full z-30 mt-2 w-[min(22rem,calc(100vw-2rem))] rounded-lg bg-card p-4 text-card-foreground shadow-xl dark:border dark:border-border"
@@ -1,5 +1,5 @@
import { Children, isValidElement } from 'react';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { Children, isValidElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { stubResizeObserver } from '@/test/fake-resize-observer';
import { RESIZE_AXIS_FALLBACK } from './dashboard-grid';
@@ -137,9 +137,7 @@ describe('DashboardGrid', () => {
});
it('renders widget instance when widgets are provided', async () => {
const widgets = [
{ id: 'inst-1', widgetType: 'clock', config: { timezone: 'Europe/Berlin' } },
];
const widgets = [{ id: 'inst-1', widgetType: 'clock', config: { timezone: 'Europe/Berlin' } }];
const layouts = {
lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }],
md: [],
@@ -167,9 +165,7 @@ describe('DashboardGrid', () => {
});
it('shows edit affordances (delete button) when isEditMode is true', async () => {
const widgets = [
{ id: 'inst-2', widgetType: 'clock', config: {} },
];
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
const layouts = {
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
md: [],
@@ -312,7 +308,7 @@ describe('DashboardGrid', () => {
expect(layouts.lg[0].minH).toBe(8);
});
it('quick-260929-dmx Test 9c: bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 8 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 8 angehoben', async () => {
it('quick-260929-dmx Test 9c (quick-260930: Kalender-minW 11): bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 11 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 11 angehoben', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
// So sieht ein aus v2 (minW 6 x2) migrierter Kalender im Zustand aus.
@@ -341,25 +337,108 @@ describe('DashboardGrid', () => {
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
// Breite bleibt (Bildschirmgroesse unveraendert), nur das Minimum sinkt.
expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 8, minH: 8 });
expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 8, minH: 8 });
expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 8, h: 16, minW: 8, minH: 8 });
expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 11, minH: 8 });
expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 11, minH: 8 });
expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 11, h: 16, minW: 11, minH: 8 });
// Auch der data-grid-Wert der Kinder traegt das neue Minimum.
const children = Children.toArray(captured.props?.children as React.ReactNode);
const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props['data-grid'] as Record<string, unknown>;
expect(grid.minW).toBe(8);
const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props[
'data-grid'
] as Record<string, unknown>;
expect(grid.minW).toBe(11);
});
const CANCEL = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
it('quick-260930 Test 9d: ein schmaler Kalender mit direktem Nachbarn wird nur bis zum Nachbarn angehoben (keine neue Ueberlappung), am Rasterrand nur bis zum Rand', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const layouts = {
lg: [
// Kalender w 8 (vor quick-260930 erlaubt), Uhr direkt rechts ab x 18.
{ i: 'cal-1', x: 10, y: 0, w: 8, h: 16 },
{ i: 'clk-1', x: 18, y: 4, w: 8, h: 4 },
// Kalender am rechten Rand: x 40 + 11 > 48.
{ i: 'cal-2', x: 40, y: 20, w: 8, h: 16 },
// Nachbar liegt tiefer (keine Zeilenueberschneidung) -> kein Hindernis.
{ i: 'cal-3', x: 0, y: 40, w: 8, h: 8 },
{ i: 'clk-2', x: 8, y: 48, w: 8, h: 4 },
],
};
render(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'cal-1', widgetType: 'calendar', config: {} },
{ id: 'clk-1', widgetType: 'clock', config: {} },
{ id: 'cal-2', widgetType: 'calendar', config: {} },
{ id: 'cal-3', widgetType: 'calendar', config: {} },
{ id: 'clk-2', widgetType: 'clock', config: {} },
]}
isEditMode
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(passed.lg[0]).toMatchObject({ i: 'cal-1', x: 10, w: 8, minW: 11 });
expect(passed.lg[1]).toMatchObject({ i: 'clk-1', x: 18, w: 8 });
expect(passed.lg[2]).toMatchObject({ i: 'cal-2', x: 40, w: 8, minW: 11 });
expect(passed.lg[3]).toMatchObject({ i: 'cal-3', x: 0, w: 11, minW: 11 });
});
it('quick-260930 Test 9e: minW und w werden auf die Spaltenzahl des Breakpoints begrenzt (xxs 4, xs 16)', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const layouts = {
xs: [{ i: 'srch-1', x: 0, y: 0, w: 24, h: 4 }],
xxs: [
{ i: 'srch-1', x: 0, y: 0, w: 24, h: 4, minW: 12 },
{ i: 'cal-1', x: 0, y: 4, w: 2, h: 16 },
],
};
render(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'srch-1', widgetType: 'search', config: {} },
{ id: 'cal-1', widgetType: 'calendar', config: {} },
]}
isEditMode
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(passed.xs[0]).toMatchObject({ w: 16, minW: 12 });
expect(passed.xxs[0]).toMatchObject({ w: 4, minW: 4 });
// Kalender (minW 11) am xxs-Breakpoint: Minimum 4, Breite auf 4 angehoben.
expect(passed.xxs[1]).toMatchObject({ w: 4, minW: 4 });
});
const CANCEL =
'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => {
const { DashboardGrid } = await import('./dashboard-grid');
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] };
const layouts = {
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
};
captured.props = null;
render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={true}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
expect(captured.props?.dragConfig).toEqual({
enabled: true,
@@ -372,7 +451,13 @@ describe('DashboardGrid', () => {
captured.props = null;
render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
expect(captured.props?.dragConfig).toEqual({
enabled: false,
@@ -400,7 +485,10 @@ describe('DashboardGrid', () => {
type: unknown;
allowOverlap: boolean;
preventCollision?: boolean;
compact: (layout: Array<Record<string, unknown>>, cols: number) => Array<Record<string, unknown>>;
compact: (
layout: Array<Record<string, unknown>>,
cols: number,
) => Array<Record<string, unknown>>;
};
expect(compactor).toMatchObject({ type: null, allowOverlap: false, preventCollision: true });
expect(typeof compactor.compact).toBe('function');
@@ -423,10 +511,22 @@ describe('DashboardGrid', () => {
expect(WIDGET_DRAG_CANCEL_SELECTOR).toBe(CANCEL);
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] };
const layouts = {
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
};
render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={true}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const card = document.querySelector('[data-widget-id="inst-2"]') as HTMLElement;
@@ -455,7 +555,13 @@ describe('DashboardGrid', () => {
cleanup();
render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
<DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
expect(screen.queryByTitle('Drag the tile to move it')).toBeNull();
expect(document.querySelector('.widget-drag-handle')).toBeNull();
@@ -512,7 +618,9 @@ describe('DashboardGrid', () => {
expect(captured.props?.width).toBe(1000);
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 1600, 800));
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(
new DOMRect(0, 0, 1600, 800),
);
act(() => {
window.dispatchEvent(new Event('resize'));
});
@@ -528,11 +636,16 @@ describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
{ i: 'fav', x: 0, y: 0, w: 6, h: 10 },
{ i: 'below', x: 0, y: 10, w: 6, h: 8 },
];
const context = { layout } as unknown as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[4];
const context = { layout } as unknown as Parameters<
NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>
>[4];
const item = layout[0] as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[0];
it('uebernimmt die Breite, wenn die Maus beim Schmalerziehen eine Zeile nach unten wackelt', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({ w: 2, h: 10 });
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({
w: 2,
h: 10,
});
});
it('laesst eine freie Groesse unveraendert', () => {
@@ -1,13 +1,19 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Responsive, noCompactor } from 'react-grid-layout';
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
import { noCompactor, Responsive } from 'react-grid-layout';
import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl';
import { WIDGET_CONSTRAINTS, WIDGET_REGISTRY, type WidgetType } from './widget-registry';
import {
clampWidthToCols,
GRID_COLS,
WIDGET_CONSTRAINTS,
WIDGET_REGISTRY,
type WidgetType,
} from './widget-registry';
import { WidgetIcon } from './widgets/widget-icon';
import { WidgetWrapper } from './widgets/widget-wrapper';
@@ -23,7 +29,9 @@ import { WidgetWrapper } from './widgets/widget-wrapper';
// damit in 48stel-Einheiten; gespeicherte Anordnungen rechnet die Migration
// einmalig um, jedes Widget behaelt seine Bildschirmgroesse und -position.
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
const COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 };
// Spaltenzahlen stehen seit quick-260930 in widget-registry.tsx (GRID_COLS),
// weil auch der Store sie fuer neue Kacheln braucht.
const COLS = GRID_COLS;
// quick-260916-dyv: Ziehen zuverlaessig.
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
@@ -138,6 +146,17 @@ const QUICK_ADD_TYPES: WidgetType[] = ['clock', 'calendar', 'note'];
* chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich —
* der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten.
* Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert.
*
* quick-260930: minW je Breakpoint auf dessen Spaltenzahl begrenzt (Suche
* minW 12 am xxs-Breakpoint mit 4 Spalten), w ebenso. Die BREITE wird nur so
* weit angehoben, wie rechts Platz ist — innerhalb des Rasters und ohne eine
* NEUE Ueberlappung mit einem Nachbarn (freie Platzierung ohne Kompaktierung
* wuerde sie sonst stehen lassen; Anlass: Kalender-minW 8 -> 11, schmal
* gezogene Kalender mit direktem Nachbarn). Bleibt die Kachel dadurch unter
* dem Minimum, ist das harmlos: react-grid-layout zeichnet sie woertlich und
* klemmt erst beim naechsten Groessenziehen; stoesst das an den Nachbarn,
* greift RESIZE_AXIS_FALLBACK (nur die Hoehe aendert sich), bis der Nachbar
* verschoben ist.
*/
function applyConstraintMinima(
layouts: Record<string, Array<LayoutItemShape>>,
@@ -150,18 +169,42 @@ function applyConstraintMinima(
for (const key of Object.keys(layouts)) {
const entries = layouts[key];
if (!Array.isArray(entries)) continue;
result[key] = entries.map((entry) => {
const cols = GRID_COLS[key] ?? Number.POSITIVE_INFINITY;
// Zielbreite je Eintrag merken; angehoben wird im zweiten Durchgang,
// wenn alle Hoehen feststehen (die Kollisionspruefung braucht sie).
const targetW = new Map<string, number>();
const next = entries.map((entry) => {
const type = typeById.get(entry.i);
const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined;
if (!constraints) return { ...entry };
const minW = clampWidthToCols(constraints.minW, key);
const w = Math.min(entry.w, cols);
if (w < minW) targetW.set(entry.i, minW);
return {
...entry,
w: Math.max(entry.w, constraints.minW),
w,
h: Math.max(entry.h, constraints.minH),
minW: constraints.minW,
minW,
minH: constraints.minH,
};
});
for (const item of next) {
const target = targetW.get(item.i);
if (target === undefined) continue;
const others = next.filter((o) => o !== item);
// Schon bestehende Ueberlappungen zaehlen nicht als Hindernis — sie
// entstehen nicht durch das Anheben.
const blockers = others.filter((o) => !overlaps(item, o));
while (
item.w < target &&
item.x + item.w + 1 <= cols &&
!blockers.some((o) => overlaps({ ...item, w: item.w + 1 }, o))
) {
item.w += 1;
}
}
result[key] = next;
}
return result;
}
@@ -259,7 +302,10 @@ export function DashboardGrid({
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
// stabil bleibt.
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]);
const effectiveLayouts = useMemo(
() => applyConstraintMinima(layouts, widgets),
[layouts, widgets],
);
// Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
@@ -269,7 +315,18 @@ export function DashboardGrid({
<div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center">
<div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8">
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="9" rx="1.5" />
<rect x="14" y="3" width="7" height="5" rx="1.5" />
<rect x="14" y="12" width="7" height="9" rx="1.5" />
@@ -296,7 +353,19 @@ export function DashboardGrid({
)}
{onOpenCatalog && (
<button type="button" onClick={onOpenCatalog} className="btn btn-primary mt-5">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
{t('addWidget')}
</button>
)}
@@ -0,0 +1,42 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// quick-260930: Stammt die Anordnung von einem neueren Programmstand, ist der
// Stift gesperrt und bittet ums Neuladen.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
const storeState = vi.hoisted(() => ({ layoutFromNewerVersion: false }));
vi.mock('@/lib/stores/dashboard-store', () => ({
useDashboardStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
import { EditModeToggle } from './edit-mode-toggle';
beforeEach(() => {
storeState.layoutFromNewerVersion = false;
});
describe('EditModeToggle (quick-260930)', () => {
it('normal: Stift ist bedienbar', () => {
const onToggle = vi.fn();
render(<EditModeToggle isEditMode={false} onToggle={onToggle} />);
const button = screen.getByRole('button', { name: 'editShort' });
expect(button).toBeEnabled();
fireEvent.click(button);
expect(onToggle).toHaveBeenCalledTimes(1);
});
it('Anordnung eines neueren Programmstands: gesperrt, Hinweis „neu laden“ als Beschreibung und Tooltip', () => {
storeState.layoutFromNewerVersion = true;
const onToggle = vi.fn();
render(<EditModeToggle isEditMode={false} onToggle={onToggle} />);
const button = screen.getByRole('button', { name: 'editShort' });
expect(button).toBeDisabled();
expect(button).toHaveAttribute('title', 'reloadRequired');
expect(button).toHaveAccessibleDescription('reloadRequired');
fireEvent.click(button);
expect(onToggle).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,8 @@
'use client';
import { useTranslations } from 'next-intl';
import { useId } from 'react';
import { useDashboardStore } from '@/lib/stores/dashboard-store';
interface EditModeToggleProps {
isEditMode: boolean;
@@ -13,55 +15,72 @@ interface EditModeToggleProps {
* Sitzt seit quick-260928-vxe rechts in der dunklen App-Leiste: in der
* Ansicht nur der Stift (Name per aria-label/Tooltip), im Bearbeitungsmodus
* der gelbe Knopf „Fertig“.
*
* quick-260930: Stammt die geladene Anordnung von einem neueren
* Programmstand (`layoutFromNewerVersion` im Store), ist der Stift gesperrt
* und nennt als Beschreibung den Grund („Bitte laden Sie die Seite neu“) —
* Speichern wuerde die Anordnung sonst mit der alten Rastermarke
* ueberschreiben.
*/
export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
const t = useTranslations('widgets');
const locked = useDashboardStore((state) => state.layoutFromNewerVersion) && !isEditMode;
const hintId = useId();
return (
<button
type="button"
onClick={onToggle}
className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'}`}
aria-pressed={isEditMode}
aria-label={isEditMode ? undefined : t('editShort')}
title={isEditMode ? t('saveChanges') : t('editMode')}
>
{isEditMode ? (
// Checkmark icon
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : null}
{isEditMode ? null : (
// Pencil icon
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
<>
<button
type="button"
onClick={onToggle}
disabled={locked}
className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'} ${locked ? 'cursor-not-allowed opacity-60' : ''}`}
aria-pressed={isEditMode}
aria-label={isEditMode ? undefined : t('editShort')}
aria-describedby={locked ? hintId : undefined}
title={locked ? t('reloadRequired') : isEditMode ? t('saveChanges') : t('editMode')}
>
{isEditMode ? (
// Checkmark icon
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : null}
{isEditMode ? null : (
// Pencil icon
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)}
{isEditMode ? t('done') : null}
</button>
{locked && (
<span id={hintId} className="sr-only">
{t('reloadRequired')}
</span>
)}
{isEditMode ? t('done') : null}
</button>
</>
);
}
@@ -1,13 +1,15 @@
import { describe, expect, it, vi } from 'vitest';
import { WIDGET_TYPES } from '@tessera/shared';
import { describe, expect, it, vi } from 'vitest';
import {
clampWidthToCols,
GRID_COLS,
registerWidget,
visibleWidgetTypes,
WIDGET_CONSTRAINTS,
WIDGET_REGISTRY,
type WidgetDefinition,
type WidgetProps,
type WidgetType,
registerWidget,
visibleWidgetTypes,
} from './widget-registry';
/**
@@ -38,29 +40,31 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
it('contains entries for all expected widget types', () => {
for (const type of ALL_WIDGET_TYPES) {
expect(
WIDGET_CONSTRAINTS,
`WIDGET_CONSTRAINTS must contain key "${type}"`,
).toHaveProperty(type);
expect(WIDGET_CONSTRAINTS, `WIDGET_CONSTRAINTS must contain key "${type}"`).toHaveProperty(
type,
);
}
});
it.each(ALL_WIDGET_TYPES)(
'WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH',
(type) => {
const constraints = WIDGET_CONSTRAINTS[type];
it.each(
ALL_WIDGET_TYPES,
)('WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH', (type) => {
const constraints = WIDGET_CONSTRAINTS[type];
expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number');
expect(typeof constraints.minH, `${type}.minH must be a number`).toBe('number');
expect(typeof constraints.defaultW, `${type}.defaultW must be a number`).toBe('number');
expect(typeof constraints.defaultH, `${type}.defaultH must be a number`).toBe('number');
expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number');
expect(typeof constraints.minH, `${type}.minH must be a number`).toBe('number');
expect(typeof constraints.defaultW, `${type}.defaultW must be a number`).toBe('number');
expect(typeof constraints.defaultH, `${type}.defaultH must be a number`).toBe('number');
expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1);
expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1);
expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(constraints.minW);
expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(constraints.minH);
},
);
expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1);
expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1);
expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(
constraints.minW,
);
expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(
constraints.minH,
);
});
it('WIDGET_CONSTRAINTS contains new Phase-8 keys', () => {
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('calculator');
@@ -71,12 +75,13 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf Typen)', () => {
// Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle
// Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW 8
// (rund 250 px, gemessene kleinste benutzbare Breite; vorher 6 von 24).
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW
// (quick-260930: 11 = rund 260 px an der schmalsten lg-Breite; das
// vorige 8 ergab nur rund 186 px; vorher 6 von 24).
expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 4, minH: 2, defaultW: 8, defaultH: 4 },
search: { minW: 12, minH: 2, defaultW: 24, defaultH: 4 },
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 },
calendar: { minW: 11, minH: 8, defaultW: 16, defaultH: 16 },
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
calculator: { minW: 6, minH: 10, defaultW: 12, defaultH: 10 },
favorites: { minW: 2, minH: 3, defaultW: 12, defaultH: 10 },
@@ -98,6 +103,38 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
});
});
describe('Kalender-minW (quick-260930)', () => {
it('ergibt an der schmalsten lg-Breite (1200 px, 48 Spalten, 12 px Abstand) mindestens 250 px', () => {
const margin = 12;
const cols = GRID_COLS.lg;
const colWidth = (1200 - 2 * margin - (cols - 1) * margin) / cols;
const px = (w: number) => w * colWidth + (w - 1) * margin;
const { minW } = WIDGET_CONSTRAINTS.calendar;
expect(px(minW)).toBeGreaterThanOrEqual(250);
// und nicht unnoetig breit: eine Spalte weniger laege unter 250 px
expect(px(minW - 1)).toBeLessThan(250);
});
});
describe('clampWidthToCols (quick-260930)', () => {
it('begrenzt Breiten auf die Spaltenzahl des Breakpoints', () => {
expect(clampWidthToCols(24, 'xxs')).toBe(4);
expect(clampWidthToCols(24, 'xs')).toBe(16);
expect(clampWidthToCols(24, 'lg')).toBe(24);
expect(clampWidthToCols(3, 'xxs')).toBe(3);
expect(clampWidthToCols(99, 'unbekannt')).toBe(99);
});
it('kein Minimum und keine Vorgabe ist nach der Begrenzung groesser als die Spaltenzahl', () => {
for (const bp of Object.keys(GRID_COLS)) {
for (const c of Object.values(WIDGET_CONSTRAINTS)) {
expect(clampWidthToCols(c.minW, bp)).toBeLessThanOrEqual(GRID_COLS[bp]);
expect(clampWidthToCols(c.defaultW, bp)).toBeLessThanOrEqual(GRID_COLS[bp]);
}
}
});
});
/**
* quick-260922-m1h: Deckungsgleichheit. Die Typliste steht seit diesem Umbau
* EINMAL in `packages/shared`; Registry, Constraints-Tabelle und die
@@ -1,5 +1,5 @@
import type { ComponentType } from 'react';
import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
import type { ComponentType } from 'react';
/**
* Die Typliste der Kacheln steht seit quick-260922-m1h EINMAL, in
@@ -57,11 +57,17 @@ export const WIDGET_CONSTRAINTS: Record<
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
// Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter
// den Monat nur ein Termin.
// quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar — minW 8 von
// 48 Spalten = rund 250 px am lg-Breakpoint (gemessen: Monatsraster,
// Ueberschrift und Terminliste sind dort noch benutzbar; bei rund 185 px
// wird die Ueberschrift abgeschnitten). defaultW 16 = unveraenderte Breite.
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 },
// quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar. Ziel sind
// rund 250 px (Monatsraster, Ueberschrift und Terminliste noch benutzbar;
// bei rund 185 px wird die Ueberschrift abgeschnitten). quick-260930: das
// damalige minW 8 ergab am lg-Breakpoint nur rund 186 px — gerechnet an
// der schmalsten lg-Breite (1200 px Raster, 48 Spalten, 12 px Abstand und
// Rand): Spalte = (1200 - 2*12 - 47*12) / 48 = 12,75 px, Kachel = w*12,75 +
// (w-1)*12. minW 11 = rund 260 px (10 waeren 235 px), auf breiteren
// Bildschirmen entsprechend mehr. defaultW 16 = unveraenderte Breite.
// Bestehende schmalere Kalender hebt dashboard-grid.tsx beim Anzeigen an,
// soweit rechts Platz ist (applyConstraintMinima).
calendar: { minW: 11, minH: 8, defaultW: 16, defaultH: 16 },
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
// Phase 8 new widgets (D-01)
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
@@ -98,6 +104,24 @@ export const WIDGET_CONSTRAINTS: Record<
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
};
/**
* Spalten je Breakpoint (quick-260929-dmx: 48stel am lg-Breakpoint). Steht
* hier und nicht in dashboard-grid.tsx, weil auch der Store (neue Kacheln)
* die Spaltenzahl braucht und die Registry ohnehin von beiden importiert wird.
*/
export const GRID_COLS: Record<string, number> = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 };
/**
* Begrenzt eine Breite (minW, defaultW) auf die Spaltenzahl des Breakpoints
* (quick-260930). Sonst waere z. B. die Suche (minW 12, defaultW 24) am
* xxs-Breakpoint mit 4 Spalten breiter als das ganze Raster. Unbekannte
* Breakpoints bleiben unbegrenzt.
*/
export function clampWidthToCols(width: number, breakpoint: string): number {
const cols = GRID_COLS[breakpoint];
return cols === undefined ? width : Math.min(width, cols);
}
/**
* Definition for a widget type in the catalog.
*/
@@ -71,7 +71,9 @@ describe('WidgetWrapper', () => {
onRemove={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'hideTitle' }));
const toggle = screen.getByRole('button', { name: 'hideTitle' });
expect(toggle).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(toggle);
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: true });
// im Bearbeitungsmodus bleibt der Titel sichtbar
expect(screen.getByRole('article')).not.toHaveAttribute('data-hide-title');
@@ -86,7 +88,11 @@ describe('WidgetWrapper', () => {
onRemove={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'showTitle' }));
// quick-260930: gleiche Beschriftung, der Zustand steckt in aria-pressed.
const toggle = screen.getByRole('button', { name: 'hideTitle' });
expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(screen.queryByRole('button', { name: 'showTitle' })).toBeNull();
fireEvent.click(toggle);
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: false });
rerender(
@@ -48,7 +48,12 @@ interface WidgetWrapperProps {
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
* Renders the actual widget component via WIDGET_REGISTRY lookup.
*/
export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }: WidgetWrapperProps) {
export function WidgetWrapper({
widget,
isEditMode,
onRemove,
enterIndex = 0,
}: WidgetWrapperProps) {
const t = useTranslations('widgets');
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component;
@@ -103,8 +108,11 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
void setWidgetConfig(widget.id, { hideTitle: !titleHidden });
}}
className={`ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-card transition-colors hover:bg-muted hover:text-foreground ${titleHidden ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}
aria-label={titleHidden ? t('showTitle') : t('hideTitle')}
title={titleHidden ? t('showTitle') : t('hideTitle')}
// quick-260930: feste Beschriftung plus aria-pressed — ein
// Umschalter, dessen Name mit dem Zustand wechselt, liest sich
// doppelt („Titel einblenden, gedrueckt“).
aria-label={t('hideTitle')}
title={t('hideTitle')}
aria-pressed={titleHidden}
data-no-drag=""
>
@@ -167,31 +175,29 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und
aendert die Hoehenkette nicht. */}
<div className="flex h-full flex-col">
{WidgetComponent && definition && FRAME_HEADER_TYPES.has(widget.widgetType) && (
<div className="widget-head" data-testid="widget-frame-head">
<WidgetIcon type={widget.widgetType} />
<h2 className="truncate font-semibold text-foreground">{t(definition.nameKey)}</h2>
</div>
)}
<div className="@container-size min-h-0 flex-1">
{WidgetComponent ? (
<WidgetComponent
instanceId={widget.id}
config={widget.config}
isEditMode={isEditMode}
/>
) : (
/* quick-260922-m1h: Kein Bauteil zu diesem Typ — entweder eine alte
{WidgetComponent && definition && FRAME_HEADER_TYPES.has(widget.widgetType) && (
<div className="widget-head" data-testid="widget-frame-head">
<WidgetIcon type={widget.widgetType} />
<h2 className="truncate font-semibold text-foreground">{t(definition.nameKey)}</h2>
</div>
)}
<div className="@container-size min-h-0 flex-1">
{WidgetComponent ? (
<WidgetComponent
instanceId={widget.id}
config={widget.config}
isEditMode={isEditMode}
/>
) : (
/* quick-260922-m1h: Kein Bauteil zu diesem Typ — entweder eine alte
Kachel eines entfernten Typs oder (ab der ersten Modul-Kachel) eine
Kachel, deren Modul dem Benutzer nicht freigegeben ist. Vorher
stand hier der rohe Typname, der dem Anwender nichts sagte. */
<div className="flex h-full items-center justify-center p-3">
<p className="text-center text-sm text-muted-foreground">
{t('unavailable')}
</p>
</div>
)}
</div>
<div className="flex h-full items-center justify-center p-3">
<p className="text-center text-sm text-muted-foreground">{t('unavailable')}</p>
</div>
)}
</div>
</div>
</article>
);
+59 -9
View File
@@ -36,7 +36,15 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
const { layouts, migrated } = migrateGridLayouts(altLayouts());
expect(layouts.lg[0]).toEqual({
i: 'a', x: 4, y: 4, w: 8, h: 6, minW: 8, minH: 4, moved: false, static: false,
i: 'a',
x: 4,
y: 4,
w: 8,
h: 6,
minW: 8,
minH: 4,
moved: false,
static: false,
});
expect(layouts.lg[1]).toEqual({ i: 'b', x: 8, y: 0, w: 24, h: 4, maxW: 48, maxH: 16 });
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 8, h: 4 });
@@ -53,7 +61,15 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 2 });
expect(layouts.lg[0]).toEqual({
i: 'a', x: 2, y: 2, w: 4, h: 3, minW: 4, minH: 2, moved: false, static: false,
i: 'a',
x: 2,
y: 2,
w: 4,
h: 3,
minW: 4,
minH: 2,
moved: false,
static: false,
});
expect(layouts.lg[1]).toEqual({ i: 'b', x: 4, y: 0, w: 12, h: 2, maxW: 24, maxH: 8 });
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 4, h: 2 });
@@ -131,6 +147,7 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
const future = migrateGridLayouts({ ...alt, __gridVersion: 4 });
expect(future.layouts).toEqual(alt);
expect(future.migrated).toBe(false);
expect(future.newer).toBe(true);
const stringMarker = migrateGridLayouts({ ...alt, __gridVersion: '2' });
expect(stringMarker.migrated).toBe(true);
@@ -138,24 +155,57 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
expect(Object.keys(stringMarker.layouts)).not.toContain(GRID_VERSION_KEY);
const odd = migrateGridLayouts({
lg: [{ i: 'c', x: 'kaputt', y: 1, w: 2, h: 2, resizeHandles: ['se'], moved: true, static: false }],
lg: [
{
i: 'c',
x: 'kaputt',
y: 1,
w: 2,
h: 2,
resizeHandles: ['se'],
moved: true,
static: false,
},
],
});
expect(odd.layouts.lg[0]).toEqual({
i: 'c', x: 'kaputt', y: 2, w: 8, h: 4, resizeHandles: ['se'], moved: true, static: false,
i: 'c',
x: 'kaputt',
y: 2,
w: 8,
h: 4,
resizeHandles: ['se'],
moved: true,
static: false,
});
expect(Number.isNaN(odd.layouts.lg[0].x)).toBe(false);
expect(odd.migrated).toBe(true);
});
it('Test 7: Fremdwerte — Nicht-Arrays werden weggelassen, Nicht-Objekte liefern eine leere Anordnung', () => {
const r = migrateGridLayouts({ lg: 'kaputt', md: null, sm: [{ i: 'a', x: 1, y: 1, w: 1, h: 1 }] });
const r = migrateGridLayouts({
lg: 'kaputt',
md: null,
sm: [{ i: 'a', x: 1, y: 1, w: 1, h: 1 }],
});
expect(Object.keys(r.layouts)).toEqual(['sm']);
expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 });
expect(r.migrated).toBe(true);
expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false });
expect(migrateGridLayouts(undefined)).toEqual({ layouts: {}, migrated: false });
expect(migrateGridLayouts(42)).toEqual({ layouts: {}, migrated: false });
expect(migrateGridLayouts([1, 2])).toEqual({ layouts: {}, migrated: false });
expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false, newer: false });
expect(migrateGridLayouts(undefined)).toEqual({ layouts: {}, migrated: false, newer: false });
expect(migrateGridLayouts(42)).toEqual({ layouts: {}, migrated: false, newer: false });
expect(migrateGridLayouts([1, 2])).toEqual({ layouts: {}, migrated: false, newer: false });
});
it('Test 8 (quick-260930): nur eine HOEHERE Marke meldet newer — v1/v2/v3 nicht', () => {
const alt = altLayouts();
expect(migrateGridLayouts(alt).newer).toBe(false);
expect(migrateGridLayouts({ ...alt, [GRID_VERSION_KEY]: 2 }).newer).toBe(false);
expect(migrateGridLayouts({ ...alt, [GRID_VERSION_KEY]: GRID_VERSION }).newer).toBe(false);
const future = migrateGridLayouts({ ...alt, [GRID_VERSION_KEY]: GRID_VERSION + 1 });
expect(future.newer).toBe(true);
// Unveraendert uebernommen, nichts skaliert.
expect(future.layouts).toEqual(alt);
});
});
+16 -3
View File
@@ -34,6 +34,13 @@
* Idempotenz: `migrateGridLayouts(withGridVersion(migrateGridLayouts(alt).layouts))`
* liefert dasselbe Ergebnis wie `migrateGridLayouts(alt)` mit `migrated: false`.
*
* Neuere Marke (quick-260930): Traegt das JSON eine HOEHERE Marke als
* `GRID_VERSION`, hat es ein neuerer Programmstand geschrieben (etwa ein
* frisch geladener Tab neben einem alten). Umgerechnet wird dann nichts, aber
* `newer: true` gemeldet — der Store darf diese Anordnung NIE speichern: er
* wuerde sie mit der alten Marke zurueckschreiben, und der neue Stand wuerde
* sie beim naechsten Laden ein zweites Mal skalieren.
*
* Ort: Frontend, weil die Raster-Einheiten Frontend-Konstanten sind, die API
* das JSON nur durchreicht (`@IsObject()`) und so kein Schreiben auf einem
* GET und keine Aenderung am API-Dienst noetig ist. Reine Funktionen ohne
@@ -72,14 +79,20 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
* Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob
* etwas verdoppelt wurde (dann muss der Aufrufer sofort mit Marker speichern).
*/
export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrated: boolean } {
export function migrateGridLayouts(raw: unknown): {
layouts: GridLayouts;
migrated: boolean;
/** Marke hoeher als `GRID_VERSION` — Anordnung stammt von einem neueren Programmstand. */
newer: boolean;
} {
if (!isPlainObject(raw)) {
return { layouts: {}, migrated: false };
return { layouts: {}, migrated: false, newer: false };
}
const markerValue = raw[GRID_VERSION_KEY];
// Nur eine Zahl ist ein Marker; alles andere (fehlend, Zeichenkette) zaehlt als alt.
const version = typeof markerValue === 'number' ? markerValue : 1;
const newer = version > GRID_VERSION;
const steps = MIGRATION_STEPS.filter((step) => version <= step.from);
const needsScaling = steps.length > 0;
@@ -108,7 +121,7 @@ export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrat
});
}
return { layouts, migrated };
return { layouts, migrated, newer };
}
/**
+142 -8
View File
@@ -53,6 +53,7 @@ beforeEach(() => {
isDirty: false,
isLoading: false,
error: null,
layoutFromNewerVersion: false,
});
vi.clearAllMocks();
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]);
@@ -68,7 +69,11 @@ afterEach(() => {
describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)', () => {
it('Test 1: alte Anordnung wird beim Laden umgerechnet und SOFORT fuer den ersten Reiter mit Marker gespeichert', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [],
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
});
await useDashboardStore.getState().loadDashboard();
@@ -88,7 +93,12 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
it('Test 2: markierte Anordnung bleibt unveraendert, kein Speichern, kein Marker im Zustand', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3,
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
__gridVersion: 3,
});
await useDashboardStore.getState().loadDashboard();
@@ -129,7 +139,11 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
it('Test 5: scheitert das Sofort-Speichern, bleibt der Zustand umgerechnet, kein Fehlerzustand, console.error einmal', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [],
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
});
vi.mocked(api.saveLayout).mockRejectedValue(new Error('PUT failed'));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -197,7 +211,9 @@ describe('dashboard-store — Reiterwechsel (quick-260923-ad9, Task 3)', () => {
it('Test 9: ein Wechsel mit ungespeicherter Anordnung schreibt die Anordnung ZUERST fuer den ALTEN Reiter', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
await useDashboardStore.getState().loadDashboard();
useDashboardStore.getState().updateLayouts({ lg: [{ i: 'x', x: 0, y: 0, w: 1, h: 1 }], md: [], sm: [], xs: [], xxs: [] });
useDashboardStore
.getState()
.updateLayouts({ lg: [{ i: 'x', x: 0, y: 0, w: 1, h: 1 }], md: [], sm: [], xs: [], xxs: [] });
vi.mocked(api.saveLayout).mockClear();
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
vi.mocked(api.fetchWidgets).mockResolvedValue([]);
@@ -213,9 +229,16 @@ describe('dashboard-store — Reiterwechsel (quick-260923-ad9, Task 3)', () => {
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3,
lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
__gridVersion: 3,
});
vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]);
vi.mocked(api.fetchWidgets).mockResolvedValue([
{ id: 'w-on-dash-2', widgetType: 'clock', config: {} },
]);
await useDashboardStore.getState().selectDashboard('dash-2');
@@ -253,7 +276,11 @@ describe('dashboard-store — Reiter anlegen/umbenennen/loeschen (quick-260923-a
it('Test 13: renameDashboard aktualisiert den Namen in der Reiterliste', async () => {
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.renameDashboardTab).mockResolvedValue({ id: 'dash-1', name: 'Finanzen', position: 0 });
vi.mocked(api.renameDashboardTab).mockResolvedValue({
id: 'dash-1',
name: 'Finanzen',
position: 0,
});
await useDashboardStore.getState().renameDashboard('dash-1', 'Finanzen');
@@ -265,7 +292,9 @@ describe('dashboard-store — Reiter anlegen/umbenennen/loeschen (quick-260923-a
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.deleteDashboardTab).mockResolvedValue(undefined);
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]);
vi.mocked(api.fetchWidgets).mockResolvedValue([
{ id: 'w-on-dash-2', widgetType: 'clock', config: {} },
]);
await useDashboardStore.getState().deleteDashboard('dash-1');
@@ -320,3 +349,108 @@ describe('dashboard-store — Reiter per Ziehen umsortieren (quick-260923-ad9, T
expect(errorSpy).toHaveBeenCalledTimes(1);
});
});
describe('dashboard-store — Anordnung eines neueren Programmstands (quick-260930)', () => {
const FUTURE = {
lg: [{ i: 'a', x: 2, y: 0, w: 8, h: 4 }],
md: [],
sm: [],
xs: [],
xxs: [],
__gridVersion: 99,
};
it('Test 18: hoehere Marke -> Merker gesetzt, nichts skaliert, nie gespeichert, Bearbeiten gesperrt', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...FUTURE });
await useDashboardStore.getState().loadDashboard();
const state = useDashboardStore.getState();
expect(state.layoutFromNewerVersion).toBe(true);
expect(state.layouts.lg[0]).toEqual({ i: 'a', x: 2, y: 0, w: 8, h: 4 });
expect(api.saveLayout).not.toHaveBeenCalled();
// Bearbeiten bleibt aus.
useDashboardStore.getState().setEditMode(true);
expect(useDashboardStore.getState().isEditMode).toBe(false);
// Auch ein ausdrueckliches Speichern (etwa nach einer Aenderung) schreibt nichts.
useDashboardStore.getState().updateLayouts({ lg: [{ i: 'a', x: 0, y: 0, w: 4, h: 4 }] });
await useDashboardStore.getState().saveLayout();
expect(api.saveLayout).not.toHaveBeenCalled();
});
it('Test 19: Wechsel von einem Reiter mit neuerer Marke speichert ihn nicht; ein normaler Reiter hebt den Merker wieder auf', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
vi.mocked(api.fetchLayout).mockResolvedValueOnce({ ...FUTURE });
await useDashboardStore.getState().loadDashboard();
useDashboardStore.setState({ isDirty: true });
vi.mocked(api.fetchLayout).mockResolvedValueOnce({ ...EMPTY, __gridVersion: 3 });
await useDashboardStore.getState().selectDashboard('dash-2');
expect(api.saveLayout).not.toHaveBeenCalled();
expect(useDashboardStore.getState().layoutFromNewerVersion).toBe(false);
useDashboardStore.getState().setEditMode(true);
expect(useDashboardStore.getState().isEditMode).toBe(true);
});
it('Test 20: normale Anordnung -> Merker aus, Speichern wie gewohnt', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY, __gridVersion: 3 });
await useDashboardStore.getState().loadDashboard();
expect(useDashboardStore.getState().layoutFromNewerVersion).toBe(false);
await useDashboardStore.getState().saveLayout();
expect(api.saveLayout).toHaveBeenCalledTimes(1);
});
});
describe('dashboard-store — Breiten je Breakpoint (quick-260930)', () => {
it('Test 21: neue Kachel ist in keinem Breakpoint breiter als dessen Spaltenzahl', async () => {
await useDashboardStore.getState().loadDashboard();
useDashboardStore.setState({ layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] } });
vi.mocked(api.addWidget).mockResolvedValue({ id: 's1', widgetType: 'search', config: {} });
await useDashboardStore.getState().addWidget('search');
const { layouts } = useDashboardStore.getState();
expect(layouts.lg[0].w).toBe(24);
expect(layouts.xs[0].w).toBe(16);
expect(layouts.xxs[0].w).toBe(4);
});
});
describe('dashboard-store — optimistische Kachel-Konfiguration (quick-260930)', () => {
it('Test 22: scheitert der erste von zwei schnellen Klicks, bleibt die zweite Wahl stehen', async () => {
useDashboardStore.setState({ widgets: [{ id: 'w1', widgetType: 'clock', config: {} }] });
let rejectFirst: (e: Error) => void = () => {};
vi.mocked(api.updateWidgetConfig)
.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectFirst = reject;
}),
)
.mockResolvedValueOnce(undefined as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const first = useDashboardStore.getState().setWidgetConfig('w1', { hideTitle: true });
await useDashboardStore.getState().setWidgetConfig('w1', { hideTitle: false });
rejectFirst(new Error('PATCH failed'));
await first;
expect(useDashboardStore.getState().widgets[0].config).toEqual({ hideTitle: false });
expect(errorSpy).toHaveBeenCalledTimes(1);
});
it('Test 23: scheitert der einzige Klick, kommt der vorige Wert zurueck', async () => {
useDashboardStore.setState({
widgets: [{ id: 'w1', widgetType: 'clock', config: { hideTitle: false } }],
});
vi.mocked(api.updateWidgetConfig).mockRejectedValue(new Error('PATCH failed'));
vi.spyOn(console, 'error').mockImplementation(() => {});
await useDashboardStore.getState().setWidgetConfig('w1', { hideTitle: true });
expect(useDashboardStore.getState().widgets[0].config).toEqual({ hideTitle: false });
});
});
+42 -13
View File
@@ -1,8 +1,8 @@
import { create } from 'zustand';
import * as api from '@/lib/dashboard-api';
import type { DashboardTab } from '@/lib/dashboard-api';
import type { WidgetType } from '@/components/dashboard/widget-registry';
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
import { clampWidthToCols, WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
import type { DashboardTab } from '@/lib/dashboard-api';
import * as api from '@/lib/dashboard-api';
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration';
export interface WidgetInstance {
@@ -21,6 +21,13 @@ interface DashboardState {
isDirty: boolean;
isLoading: boolean;
error: string | null;
/**
* Die geladene Anordnung traegt eine hoehere Rastermarke, als dieser
* Programmstand kennt (quick-260930) — ein neuerer Stand hat sie
* geschrieben. Solange das gilt, wird nie gespeichert und der
* Bearbeitungsmodus bleibt gesperrt; die Oberflaeche bittet ums Neuladen.
*/
layoutFromNewerVersion: boolean;
setEditMode: (mode: boolean) => void;
updateLayouts: (layouts: Record<string, unknown>) => void;
@@ -79,6 +86,12 @@ function loadDashboardsOnce(): Promise<DashboardTab[]> {
* Marker-Umrechnung oben gilt weiterhin je Reiter: `selectDashboard`
* durchlaeuft dieselbe Umrechnung-plus-Sofort-Speichern-Logik wie
* `loadDashboard`.
*
* quick-260930: Traegt die geladene Anordnung eine HOEHERE Marke als
* `GRID_VERSION` (ein neuerer Programmstand hat sie geschrieben, dieser Tab
* ist alt), setzt der Store `layoutFromNewerVersion`: `saveLayout` speichert
* dann nie, `setEditMode(true)` wird ignoriert, die Oberflaeche bittet ums
* Neuladen. Der Merker gilt je geladenem Reiter.
*/
export const useDashboardStore = create<DashboardState>()((set, get) => ({
dashboards: [],
@@ -90,8 +103,11 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
isDirty: false,
isLoading: false,
error: null,
layoutFromNewerVersion: false,
setEditMode: (mode: boolean) => {
// quick-260930: Anordnung eines neueren Programmstands — nicht bearbeiten.
if (mode && get().layoutFromNewerVersion) return;
const prev = get().isEditMode;
set({ isEditMode: mode });
// On exit edit mode, auto-save if dirty (D-01)
@@ -125,7 +141,8 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
i: newWidget.id,
x: 0,
y: maxY,
w: constraints.defaultW,
// quick-260930: nie breiter als das Raster dieses Breakpoints.
w: clampWidthToCols(constraints.defaultW, bp),
h: constraints.defaultH,
});
newLayouts[bp] = arr;
@@ -168,12 +185,18 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
set((state) => ({
widgets: state.widgets.map((w) => (w.id === id ? { ...w, config } : w)),
}));
patch({ ...before, ...partial });
const optimistic = { ...before, ...partial };
patch(optimistic);
try {
await api.updateWidgetConfig(id, partial);
} catch (err) {
console.error('Failed to update widget config:', err);
patch(before);
// quick-260930: nur zuruecksetzen, wenn noch GENAU dieser optimistische
// Stand angezeigt wird. Hat inzwischen ein zweiter Klick etwas anderes
// gewaehlt, wuerde der Fehler des ersten sonst die zweite Wahl
// ueberschreiben.
const current = get().widgets.find((w) => w.id === id)?.config;
if (current === optimistic) patch(before);
}
},
@@ -191,13 +214,15 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
api.fetchLayout(first.id),
api.fetchWidgets(first.id),
]);
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
const { layouts: migratedLayouts, migrated, newer } = migrateGridLayouts(rawLayouts);
set({
dashboards,
activeDashboardId: first.id,
layouts: migratedLayouts,
widgets,
isLoading: false,
layoutFromNewerVersion: newer,
...(newer ? { isEditMode: false, isDirty: false } : {}),
});
// Umgerechnete Anordnung SOFORT mit Marker persistieren — nach dem set,
// damit die Oberflaeche unabhaengig vom Speichern rendert; eigener
@@ -220,6 +245,9 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
saveLayout: async () => {
const dashboardId = get().activeDashboardId;
if (!dashboardId) return;
// quick-260930: NIE eine Anordnung eines neueren Programmstands speichern —
// sie ginge mit der alten Marke zurueck und wuerde doppelt skaliert.
if (get().layoutFromNewerVersion) return;
try {
await api.saveLayout(dashboardId, withGridVersion(get().layouts));
set({ isDirty: false });
@@ -246,17 +274,16 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
set({ isSwitchingDashboard: true, error: null });
try {
const [rawLayouts, widgets] = await Promise.all([
api.fetchLayout(id),
api.fetchWidgets(id),
]);
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
const [rawLayouts, widgets] = await Promise.all([api.fetchLayout(id), api.fetchWidgets(id)]);
const { layouts: migratedLayouts, migrated, newer } = migrateGridLayouts(rawLayouts);
set({
activeDashboardId: id,
layouts: migratedLayouts,
widgets,
isDirty: false,
isSwitchingDashboard: false,
layoutFromNewerVersion: newer,
...(newer ? { isEditMode: false } : {}),
});
if (migrated) {
try {
@@ -332,7 +359,9 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
reorderDashboards: async (ids: string[]) => {
const previous = get().dashboards;
const byId = new Map(previous.map((d) => [d.id, d]));
const optimistic = ids.map((id) => byId.get(id)).filter((d): d is DashboardTab => d !== undefined);
const optimistic = ids
.map((id) => byId.get(id))
.filter((d): d is DashboardTab => d !== undefined);
set({ dashboards: optimistic });
try {
+5 -1
View File
@@ -246,7 +246,6 @@
"catalogClose": "Dialog schließen",
"deleteTooltip": "Widget entfernen",
"hideTitle": "Titel ausblenden",
"showTitle": "Titel einblenden",
"dragHint": "Ziehen Sie die Kachel, um sie zu verschieben",
"editMode": "Dashboard bearbeiten",
"saveChanges": "Änderungen speichern",
@@ -498,6 +497,7 @@
"snooze10m": "In 10 Minuten",
"snooze1h": "In 1 Stunde",
"snoozeTomorrow": "Morgen um {time}",
"snoozeToday": "Heute um {time}",
"edit": "Erinnerung bearbeiten",
"editTitle": "Erinnerung bearbeiten",
"delete": "Erinnerung löschen",
@@ -515,6 +515,7 @@
},
"editShort": "Bearbeiten",
"done": "Fertig",
"reloadRequired": "Das Dashboard wurde von einer neueren Version von Tessera gespeichert. Bitte laden Sie die Seite neu, um es zu bearbeiten.",
"toolbarLabel": "Dashboard-Aktionen",
"emptyState": {
"title": "Ihr Dashboard ist noch leer",
@@ -1402,6 +1403,7 @@
"customModules": {
"openInNewTab": "In neuem Tab öffnen",
"notFound": "Dieses Modul gibt es nicht mehr.",
"loadError": "Dieses Modul konnte nicht geladen werden. Bitte versuchen Sie es später erneut.",
"invalidUrl": "Die Adresse dieses Moduls ist keine gültige https-Adresse und wird deshalb nicht angezeigt.",
"loading": "Wird geladen …",
"form": {
@@ -1416,6 +1418,8 @@
"urlCredentials": "Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.",
"saveError": "Das Modul konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
"deleteError": "Das Modul konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"forbidden": "Sie haben keine Berechtigung für diese Aktion.",
"invalidInput": "Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.",
"deleteConfirm": {
"title": "Eigenes Modul löschen",
"bodyShared": "Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet für alle Benutzer aus der Seitenleiste.",
+5 -1
View File
@@ -246,7 +246,6 @@
"catalogClose": "Close dialog",
"deleteTooltip": "Remove widget",
"hideTitle": "Hide title",
"showTitle": "Show title",
"dragHint": "Drag the tile to move it",
"editMode": "Edit dashboard",
"saveChanges": "Save changes",
@@ -498,6 +497,7 @@
"snooze10m": "In 10 minutes",
"snooze1h": "In 1 hour",
"snoozeTomorrow": "Tomorrow at {time}",
"snoozeToday": "Today at {time}",
"edit": "Edit reminder",
"editTitle": "Edit reminder",
"delete": "Delete reminder",
@@ -515,6 +515,7 @@
},
"editShort": "Edit",
"done": "Done",
"reloadRequired": "This dashboard was saved by a newer version of Tessera. Please reload the page to edit it.",
"toolbarLabel": "Dashboard actions",
"emptyState": {
"title": "Your dashboard is still empty",
@@ -1402,6 +1403,7 @@
"customModules": {
"openInNewTab": "Open in new tab",
"notFound": "This module no longer exists.",
"loadError": "This module could not be loaded. Please try again later.",
"invalidUrl": "This module's address is not a valid https address and is therefore not shown.",
"loading": "Loading …",
"form": {
@@ -1416,6 +1418,8 @@
"urlCredentials": "The address must not contain a user name or a password.",
"saveError": "The module could not be saved. Please try again.",
"deleteError": "The module could not be deleted. Please try again.",
"forbidden": "You do not have permission for this action.",
"invalidInput": "The details were not accepted. Please check name, address and category.",
"deleteConfirm": {
"title": "Delete custom module",
"bodyShared": "Do you really want to delete “{name}”? The entry disappears from the sidebar for all users.",
@@ -116,6 +116,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'Energiequellen',
'neue',
'neuen',
'neueren',
'neuere',
'Neue',
'Neues',
@@ -730,6 +730,7 @@ werden.
| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. |
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). |
| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | **quick-260924-m4n:** Stand zurück von `system-gebunden` auf `gebunden`. Stufe 2 der Umstellung (Migration 20260924120000_dashboard_image_drop_data) löscht die Spalte `data` und macht `storagePath` zur Pflicht; der Bootstrap-Umzug hatte auf allen Servern gearbeitet und ist samt seinem einzigen `forSystem()`-Aufruf entfernt (Eintrag aus `FORSYSTEM_ALLOWED_CALL_SITES` gestrichen). Dieselbe Migration entfernt die `system_read_policy` auf "DashboardImage" — auf der Tabelle bleibt allein `tenant_isolation_policy` (Mandant UND Benutzer). Alle vier Anfragewege laufen wie bisher ausschließlich über `forTenant(this.prisma, tenantId, userId)`; der Upload vergibt die UUID jetzt selbst und legt die Zeile gleich mit Pfad an. Vorher: **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
| apps/api/src/dashboard/dashboard-images.service.ts | user | muss-mandantengebunden | gebunden | **quick-260930:** `remove` setzt im selben Vorgang die Hintergrund-Wahl des Benutzers (`User.dashboardBackground`) auf `{ kind: 'none' }` zurück, wenn sie auf das gelöschte Bild zeigte — ein bedingtes `updateMany` mit `where: { id: userId, dashboardBackground: { path: ['imageId'], equals: id } }` über denselben gebundenen Klienten `forTenant(this.prisma, tenantId, userId)` wie der Rest der Methode. Nur die eigene Zeile, kein Kennungsparameter von außen (die Bild-Kennung ist zu diesem Zeitpunkt schon als eigenes Bild geprüft). |
| apps/api/src/dashboard/dashboard.service.ts | dashboard | muss-mandantengebunden | gebunden | quick-260923-ad9 — Reiter (mehrere Dashboards je Benutzer), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260923120000, Form aus 20260911120000/20260921120000). Task 1: `listDashboards` liest ueber `forTenant()` und legt bei Bedarf genau einen Reiter an (Transaktionssperre `pg_advisory_xact_lock` innerhalb `withTenantTransaction`, T-AD9-07); der Riegel `assertOwnedDashboard` liest ueber DENSELBEN, bereits gebundenen Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft fuer "gibt es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden Mandanten" dieselbe `NotFoundException` (T-AD9-01/02/03). Task 2: `createDashboard`/`renameDashboard` laufen als Einzeloperationen ueber `forTenant()`, je Methode ein Klient (Riegel zuerst bei `renameDashboard`). `deleteDashboard`/`reorderDashboards` laufen je als EINE `withTenantTransaction` (mehrschrittig, muss atomar sein) — `withTenantTransaction` setzt KEINE Benutzerdimension in der Sitzung, deshalb traegt jede Bedingung `userId` selbst (`tx.dashboard.deleteMany({where:{id,userId}})`, `tx.dashboard.updateMany({where:{id,userId},...})`), wortgleiches Muster zu `favorites.service.ts`/`reorder` (260917-jdd). `deleteDashboard` entfernt zusaetzlich die Kacheln (`tx.widgetInstance.deleteMany`) und die Anordnung (`tx.dashboardLayout.deleteMany`) des Reiters in DERSELBEN Transaktion. |
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Anordnung eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. quick-260923-ad9 (Task 1): die eindeutige Spalte ist jetzt `dashboardId` statt `userId` (D-02) — beide Methoden pruefen vorher ueber `assertOwnedDashboard`, dass der Reiter dem Aufrufer gehoert. |
| apps/api/src/dashboard/dashboard.service.ts | favoriteLink | muss-mandantengebunden | gebunden | quick-260923-lrr — nur LESEND, zum Aufraeumen hochgeladener Favoriten-Symbole: `removeWidget` und `deleteDashboard` lesen VOR dem Loeschen die Favoriten mit hochgeladenem Symbol (`findMany`, Bedingung traegt `userId` UND `widgetId`) ueber DENSELBEN, bereits gebundenen Klienten `tenantPrisma` der Methode (kein zweiter `forTenant()`-Aufruf). Die Zeilen selbst verschwinden ueber den Fremdschluessel-Kaskadenweg; danach werden die Dateien best effort entfernt, ein Dateifehler bricht das Loeschen nie ab. |