Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86ad95f74e | |||
| 12214a948e | |||
| 071082983b | |||
| c2e4467dd8 | |||
| be1e0035e0 | |||
| f7213f5e45 |
+2
-2
@@ -6,7 +6,7 @@ current_phase_name: desktop-client-fertigstellen
|
||||
status: verified
|
||||
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
||||
last_updated: "2026-09-23T15:30:00.000Z"
|
||||
last_activity: 2026-09-23
|
||||
last_activity: 2026-09-30
|
||||
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
|
||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||
progress:
|
||||
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
|
||||
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
||||
Plan: 6 of 6
|
||||
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
||||
Last activity: 2026-09-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live
|
||||
Last activity: 2026-09-30 - Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. mit 4 Fix-Commits (be1e003, c2e4467, 0710829, 12214a9)
|
||||
|
||||
Progress: [██████████] 99%
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -64,4 +64,12 @@ describe('UpdateCustomModuleDto', () => {
|
||||
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
|
||||
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'name',
|
||||
'url',
|
||||
'category',
|
||||
])('lehnt %s: null ab statt es durchzulassen', async (field) => {
|
||||
expect(await errorsFor(UpdateCustomModuleDto, { [field]: null })).toContain(field);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,7 +78,11 @@ export class CreateCustomModuleDto {
|
||||
* `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist,
|
||||
* aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld
|
||||
* dank `whitelist: true`).
|
||||
* `skipNullProperties: false`: fehlende Felder bleiben unveraendert, ein
|
||||
* ausdrueckliches `null` wird aber geprueft und damit abgelehnt (400) — sonst
|
||||
* liefe `{"name": null}` bis in die Datenbank und endete als 500.
|
||||
*/
|
||||
export class UpdateCustomModuleDto extends PartialType(
|
||||
OmitType(CreateCustomModuleDto, ['shared'] as const),
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,24 +18,21 @@ vi.mock('undici', () => ({
|
||||
|
||||
import { Agent } from 'undici';
|
||||
import {
|
||||
discardBody,
|
||||
IconDiscoveryService,
|
||||
isPublicHttpUrl,
|
||||
normalizeUrl,
|
||||
readTextCapped,
|
||||
} from './icon-discovery.service';
|
||||
|
||||
function mockResponse(options: {
|
||||
contentType?: string;
|
||||
body?: ArrayBuffer;
|
||||
}): Response {
|
||||
function mockResponse(options: { contentType?: string; body?: ArrayBuffer }): Response {
|
||||
const body = options.body ?? new ArrayBuffer(10);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === 'content-type'
|
||||
? (options.contentType ?? 'image/png')
|
||||
: null,
|
||||
name.toLowerCase() === 'content-type' ? (options.contentType ?? 'image/png') : null,
|
||||
},
|
||||
arrayBuffer: async () => body,
|
||||
} as unknown as Response;
|
||||
@@ -106,9 +103,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => {
|
||||
status: 200,
|
||||
headers: {
|
||||
get: (n: string) =>
|
||||
n.toLowerCase() === 'content-type'
|
||||
? 'text/html; charset=utf-8'
|
||||
: null,
|
||||
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
|
||||
},
|
||||
text: async () => html,
|
||||
}),
|
||||
@@ -164,7 +159,8 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatu
|
||||
});
|
||||
|
||||
it('Fehlerseite ohne Symbol-Verweis, nur og:image -> Rueckfall <origin>/favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => {
|
||||
const html = '<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></html>';
|
||||
const html =
|
||||
'<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></html>';
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(404, html)));
|
||||
|
||||
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
|
||||
@@ -173,7 +169,10 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatu
|
||||
});
|
||||
|
||||
it('fetchIconBytes bleibt streng: Fehlerstatus -> wirft (kein allowErrorStatus fuer Bilder)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } }));
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
new IconDiscoveryService().fetchIconBytes('http://8.8.8.8/favicon.ico'),
|
||||
@@ -203,16 +202,13 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
||||
});
|
||||
|
||||
it('rejects when Content-Type is not an image', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
|
||||
);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
|
||||
await expect(
|
||||
service.fetchIconBytes('http://8.8.8.8/favicon.ico'),
|
||||
).rejects.toThrow(/not an image/);
|
||||
await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
|
||||
/not an image/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when the SSRF guard blocks the target', async () => {
|
||||
@@ -221,9 +217,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
|
||||
await expect(
|
||||
service.fetchIconBytes('http://127.0.0.1/favicon.ico'),
|
||||
).rejects.toThrow(/blocked or failed/);
|
||||
await expect(service.fetchIconBytes('http://127.0.0.1/favicon.ico')).rejects.toThrow(
|
||||
/blocked or failed/,
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -236,9 +232,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
|
||||
await expect(
|
||||
service.fetchIconBytes('http://8.8.8.8/favicon.ico'),
|
||||
).rejects.toThrow(/size limit/);
|
||||
await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
|
||||
/size limit/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,10 +254,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', (
|
||||
});
|
||||
|
||||
it('still returns a URL string', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
|
||||
);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page');
|
||||
@@ -335,3 +328,143 @@ describe('IconDiscoveryService — Dispatcher (260917-jdd)', () => {
|
||||
expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readTextCapped / discardBody — Groessendeckel beim Lesen (T-08-09)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** Stream aus `chunks` Stuecken je `chunkChars` ASCII-Zeichen; zaehlt gelesene Stuecke und Abbruch. */
|
||||
function countingStream(chunks: number, chunkChars: number) {
|
||||
const state = { pulled: 0, cancelled: false };
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (state.pulled >= chunks) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
state.pulled += 1;
|
||||
controller.enqueue(encoder.encode('a'.repeat(chunkChars)));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { body, state };
|
||||
}
|
||||
|
||||
it('bricht den Stream nach der Grenze ab statt alles zu lesen', async () => {
|
||||
const { body, state } = countingStream(1000, 1000);
|
||||
const text = await readTextCapped({ body, text: async () => 'unbenutzt' } as never, 2500);
|
||||
|
||||
expect(text).toHaveLength(2500);
|
||||
expect(state.pulled).toBeLessThan(10);
|
||||
expect(state.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('gibt nach der Zeitgrenze zurueck, was bis dahin da ist (tropfender Server)', async () => {
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<link rel="icon">'));
|
||||
// danach kommt nichts mehr, der Stream bleibt offen
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
|
||||
const text = await readTextCapped({ body, text: async () => '' } as never, 200000, 50);
|
||||
|
||||
expect(text).toBe('<link rel="icon">');
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('liest kurze Seiten vollstaendig, auch Mehrbyte-Zeichen ueber Chunk-Grenzen', async () => {
|
||||
const bytes = new TextEncoder().encode('<p>Grüße</p>');
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
// Das "ü" (2 Bytes) wird absichtlich zerteilt.
|
||||
controller.enqueue(bytes.slice(0, 5));
|
||||
controller.enqueue(bytes.slice(5));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const text = await readTextCapped({ body, text: async () => '' } as never, 200000);
|
||||
|
||||
expect(text).toBe('<p>Grüße</p>');
|
||||
});
|
||||
|
||||
it('ohne Stream: Rueckfall auf text() mit Deckel', async () => {
|
||||
const text = await readTextCapped(
|
||||
{ body: null, text: async () => 'x'.repeat(50) } as never,
|
||||
10,
|
||||
);
|
||||
|
||||
expect(text).toBe('x'.repeat(10));
|
||||
});
|
||||
|
||||
it('discardBody bricht einen offenen Body ab und vertraegt fehlenden Body', () => {
|
||||
const { body, state } = countingStream(5, 10);
|
||||
discardBody({ body } as never);
|
||||
expect(state.cancelled).toBe(true);
|
||||
expect(() => discardBody({ body: null } as never)).not.toThrow();
|
||||
});
|
||||
|
||||
it('Discovery: Fehlerstatus ohne HTML-Typ -> Body wird verworfen, Rueckfall favicon.ico', async () => {
|
||||
const { body, state } = countingStream(5, 10);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
headers: {
|
||||
get: (n: string) => (n.toLowerCase() === 'content-type' ? 'application/json' : null),
|
||||
},
|
||||
body,
|
||||
}),
|
||||
);
|
||||
|
||||
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
|
||||
|
||||
expect(icon).toBe('http://8.8.8.8/favicon.ico');
|
||||
expect(state.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('Discovery: riesige HTML-Seite wird nur bis zur Grenze gelesen, Symbol am Anfang gefunden', async () => {
|
||||
const head = '<html><head><link rel="icon" href="/klein.png" /></head><body>';
|
||||
const encoder = new TextEncoder();
|
||||
const state = { pulled: 0, cancelled: false };
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
state.pulled += 1;
|
||||
controller.enqueue(encoder.encode(state.pulled === 1 ? head : 'a'.repeat(64 * 1024)));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: (n: string) => (n.toLowerCase() === 'content-type' ? 'text/html' : null) },
|
||||
body,
|
||||
text: async () => {
|
||||
throw new Error('text() darf bei vorhandenem Stream nicht laufen');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/');
|
||||
|
||||
expect(icon).toBe('http://8.8.8.8/klein.png');
|
||||
expect(state.cancelled).toBe(true);
|
||||
// 200 000 Zeichen bei 64-KiB-Stuecken: hoechstens eine Handvoll gelesen.
|
||||
expect(state.pulled).toBeLessThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Agent, type Response as UndiciResponse, fetch as undiciFetch } from 'undici';
|
||||
|
||||
/**
|
||||
* Server-side favicon / icon discovery with SSRF protection (T-08-05).
|
||||
@@ -58,9 +58,7 @@ function isPrivateIpv4(address: string): boolean {
|
||||
|
||||
if (
|
||||
parts.length !== 4 ||
|
||||
parts.some(
|
||||
(part) => !Number.isInteger(part) || part < 0 || part > 255,
|
||||
)
|
||||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -117,12 +115,7 @@ function isPrivateIpAddress(address: string): boolean {
|
||||
function isBlockedHostname(hostname: string): boolean {
|
||||
const h = hostname.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
h === 'localhost' ||
|
||||
h.endsWith('.localhost') ||
|
||||
h.endsWith('.local') ||
|
||||
h === '0.0.0.0'
|
||||
);
|
||||
return h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h === '0.0.0.0';
|
||||
}
|
||||
|
||||
export async function isPublicHttpUrl(url: URL): Promise<boolean> {
|
||||
@@ -204,11 +197,7 @@ function toAbsoluteUrl(value: string | undefined, base: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function extractIconFromHtml(
|
||||
html: string,
|
||||
baseUrl: string,
|
||||
linkTagsOnly = false,
|
||||
): string | null {
|
||||
function extractIconFromHtml(html: string, baseUrl: string, linkTagsOnly = false): string | null {
|
||||
const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
|
||||
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
|
||||
|
||||
@@ -220,27 +209,19 @@ function extractIconFromHtml(
|
||||
}))
|
||||
.filter((c) => c.href);
|
||||
|
||||
const appleTouchIcon = linkCandidates.find((c) =>
|
||||
c.rel.includes('apple-touch-icon'),
|
||||
)?.href;
|
||||
const appleTouchIcon = linkCandidates.find((c) => c.rel.includes('apple-touch-icon'))?.href;
|
||||
|
||||
if (appleTouchIcon) return appleTouchIcon;
|
||||
|
||||
const icon = linkCandidates.find((c) =>
|
||||
c.rel.split(/\s+/).includes('icon'),
|
||||
)?.href;
|
||||
const icon = linkCandidates.find((c) => c.rel.split(/\s+/).includes('icon'))?.href;
|
||||
|
||||
if (icon) return icon;
|
||||
|
||||
const shortcutIcon = linkCandidates.find((c) =>
|
||||
c.rel.includes('shortcut icon'),
|
||||
)?.href;
|
||||
const shortcutIcon = linkCandidates.find((c) => c.rel.includes('shortcut icon'))?.href;
|
||||
|
||||
if (shortcutIcon) return shortcutIcon;
|
||||
|
||||
const imageSrc = linkCandidates.find((c) =>
|
||||
c.rel.includes('image_src'),
|
||||
)?.href;
|
||||
const imageSrc = linkCandidates.find((c) => c.rel.includes('image_src'))?.href;
|
||||
|
||||
if (imageSrc) return imageSrc;
|
||||
|
||||
@@ -257,9 +238,7 @@ function extractIconFromHtml(
|
||||
.find(
|
||||
(c) =>
|
||||
c.content &&
|
||||
(c.property === 'og:image' ||
|
||||
c.property === 'og:logo' ||
|
||||
c.property === 'twitter:image'),
|
||||
(c.property === 'og:image' || c.property === 'og:logo' || c.property === 'twitter:image'),
|
||||
)?.content;
|
||||
|
||||
return metaImage ?? null;
|
||||
@@ -327,6 +306,71 @@ async function fetchWithRedirectGuard(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Minimaler Ausschnitt einer Antwort, den die beiden Helfer brauchen. */
|
||||
type BodyResponse = Pick<UndiciResponse, 'body' | 'text'>;
|
||||
|
||||
/**
|
||||
* Verwirft den Body einer nicht gebrauchten Antwort. Fehler (bereits
|
||||
* gelesen/abgebrochen) sind egal.
|
||||
*/
|
||||
export function discardBody(response: Pick<UndiciResponse, 'body'>): void {
|
||||
try {
|
||||
response.body?.cancel().catch(() => {});
|
||||
} catch {
|
||||
// Body gesperrt oder schon verbraucht — nichts zu tun.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den Antworttext hoechstens bis `maxChars` Zeichen und bricht den
|
||||
* Stream danach ab (T-08-09). Vorher wurde der komplette Body gelesen und
|
||||
* erst danach abgeschnitten — eine riesige Seite landete ganz im Speicher.
|
||||
* Dekodiert wird UTF-8 wie bei `Response.text()`; da jedes Zeichen aus
|
||||
* mindestens einem Byte entsteht, bleibt der Speicher bei ~maxChars plus
|
||||
* einem Chunk. Ohne Stream (`body === null`) wie bisher ueber `text()`.
|
||||
* `timeoutMs` begrenzt zusaetzlich die Lesedauer: die Zeitgrenze von
|
||||
* `fetchWithRedirectGuard` endet mit den Kopfzeilen, ein Server, der den
|
||||
* Body tropfenweise liefert, hielte die Anfrage sonst beliebig lange auf.
|
||||
* Nach Ablauf zaehlt, was bis dahin gelesen ist.
|
||||
*/
|
||||
export async function readTextCapped(
|
||||
response: BodyResponse,
|
||||
maxChars: number,
|
||||
timeoutMs = HTML_FETCH_TIMEOUT_MS,
|
||||
): Promise<string> {
|
||||
if (!response.body) {
|
||||
return (await response.text()).slice(0, maxChars);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = '';
|
||||
// cancel() beendet ein haengendes read() mit done: true.
|
||||
const deadline = setTimeout(() => void reader.cancel().catch(() => {}), timeoutMs);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
text += decoder.decode();
|
||||
break;
|
||||
}
|
||||
|
||||
text += decoder.decode(value, { stream: true });
|
||||
|
||||
if (text.length >= maxChars) {
|
||||
await reader.cancel().catch(() => {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
}
|
||||
|
||||
return text.slice(0, maxChars);
|
||||
}
|
||||
|
||||
async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
||||
const result = await fetchWithRedirectGuard(pageUrl, {
|
||||
accept: 'text/html,application/xhtml+xml,*/*',
|
||||
@@ -340,12 +384,18 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
||||
|
||||
const contentType = result.response.headers.get('content-type') ?? '';
|
||||
|
||||
if (!contentType.toLowerCase().includes('text/html')) return null;
|
||||
if (!contentType.toLowerCase().includes('text/html')) {
|
||||
// Kein HTML (auch bei Fehlerstatus dank allowErrorStatus hier moeglich):
|
||||
// Body verwerfen, sonst haelt undici die Verbindung bis zum Timeout offen.
|
||||
discardBody(result.response);
|
||||
return null;
|
||||
}
|
||||
|
||||
const html = await result.response.text();
|
||||
// T-08-09: HTML cap — schon beim Lesen, nicht erst nach dem kompletten Body.
|
||||
const html = await readTextCapped(result.response, MAX_HTML_CHARS);
|
||||
|
||||
return {
|
||||
html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap
|
||||
html,
|
||||
finalUrl: result.finalUrl.toString(),
|
||||
ok: result.response.ok,
|
||||
};
|
||||
@@ -370,10 +420,7 @@ export class IconDiscoveryService {
|
||||
|
||||
if (!htmlResult) return fallback;
|
||||
|
||||
return (
|
||||
extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ??
|
||||
fallback
|
||||
);
|
||||
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
@@ -388,9 +435,7 @@ export class IconDiscoveryService {
|
||||
* or an oversized body. Callers must not return a placeholder image; let
|
||||
* the caller map the failure to an HTTP error status instead.
|
||||
*/
|
||||
async fetchIconBytes(
|
||||
iconUrl: string,
|
||||
): Promise<{ contentType: string; body: Buffer }> {
|
||||
async fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }> {
|
||||
const url = new URL(iconUrl);
|
||||
|
||||
const result = await fetchWithRedirectGuard(url, {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
const trimString = ({ value }: { value: unknown }) =>
|
||||
@@ -44,12 +44,40 @@ export class CreateReminderDto {
|
||||
emailEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** Nur ein FEHLENDES Feld wird uebersprungen — `null` wird geprueft und damit abgelehnt. */
|
||||
const whenPresent = ValidateIf((_obj: object, value: unknown) => value !== undefined);
|
||||
|
||||
/**
|
||||
* Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen.
|
||||
* Eine faellige Erinnerung laesst sich nicht aendern (409 im Dienst) — dafuer
|
||||
* gibt es „Erledigt“ (loeschen) und „Spaeter erinnern“ (`SnoozeReminderDto`).
|
||||
*
|
||||
* WARUM KEIN `PartialType`: das setzt `@IsOptional()`, und das laesst auch
|
||||
* `null` ungeprueft durch — `{"title": null}` kaeme dann als 500 aus der
|
||||
* Datenbank statt als 400 aus der Pruefung. `whenPresent` ueberspringt nur
|
||||
* fehlende Felder.
|
||||
*/
|
||||
export class UpdateReminderDto extends PartialType(CreateReminderDto) {}
|
||||
export class UpdateReminderDto {
|
||||
@whenPresent
|
||||
@Transform(trimString)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
@whenPresent
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
description?: string;
|
||||
|
||||
@whenPresent
|
||||
@IsISO8601({ strict: true })
|
||||
dueAt?: string;
|
||||
|
||||
@whenPresent
|
||||
@IsBoolean()
|
||||
emailEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** Neuer Zeitpunkt beim Spaeter-Erinnern (D-03); der Client rechnet ihn aus (E-05). */
|
||||
export class SnoozeReminderDto {
|
||||
|
||||
@@ -48,7 +48,11 @@ function row(over: Partial<Row> & { id: string }): Row {
|
||||
const sameTime = (a: Date | null, b: Date | null) =>
|
||||
a === null || b === null ? a === b : a.getTime() === b.getTime();
|
||||
|
||||
function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u1@example.invalid' }) {
|
||||
function makeStore(
|
||||
rows: Row[],
|
||||
emails: Record<string, string | null> = { u1: 'u1@example.invalid' },
|
||||
inactive: string[] = [],
|
||||
) {
|
||||
const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => {
|
||||
let list = rows.filter(
|
||||
(r) =>
|
||||
@@ -58,8 +62,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
|
||||
r.dueAt.getTime() <= where.dueAt.lte.getTime() &&
|
||||
r.dueAt.getTime() >= where.dueAt.gte.getTime(),
|
||||
);
|
||||
if (orderBy?.dueAt === 'asc') list = [...list].sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
|
||||
return list.slice(0, take).map(({ id, tenantId, userId, dueAt }) => ({ id, tenantId, userId, dueAt }));
|
||||
if (orderBy?.dueAt === 'asc')
|
||||
list = [...list].sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
|
||||
return list
|
||||
.slice(0, take)
|
||||
.map(({ id, tenantId, userId, dueAt }) => ({ id, tenantId, userId, dueAt }));
|
||||
});
|
||||
|
||||
const boundLog: string[] = [];
|
||||
@@ -69,11 +76,13 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
|
||||
boundLog.push(`updateMany:${tenantId}`);
|
||||
let count = 0;
|
||||
for (const r of rows) {
|
||||
if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId) continue;
|
||||
if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId)
|
||||
continue;
|
||||
if ('dueAt' in where && !sameTime(r.dueAt, where.dueAt)) continue;
|
||||
if ('emailEnabled' in where && r.emailEnabled !== where.emailEnabled) continue;
|
||||
if ('emailSentAt' in where && !sameTime(r.emailSentAt, where.emailSentAt)) continue;
|
||||
if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt)) continue;
|
||||
if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt))
|
||||
continue;
|
||||
if (data.emailSentAt !== undefined) r.emailSentAt = data.emailSentAt;
|
||||
if (data.emailAttempts?.increment) r.emailAttempts += data.emailAttempts.increment;
|
||||
count++;
|
||||
@@ -89,7 +98,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
findFirst: vi.fn(async ({ where }: any) => ({ email: emails[where.id] ?? null })),
|
||||
findFirst: vi.fn(async ({ where }: any) => {
|
||||
const isActive = !inactive.includes(where.id);
|
||||
if ('isActive' in where && where.isActive !== isActive) return null;
|
||||
return { email: emails[where.id] ?? null };
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -100,10 +113,7 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
|
||||
return { prisma, rows, systemFindMany, boundLog };
|
||||
}
|
||||
|
||||
function makeScheduler(
|
||||
prisma: any,
|
||||
opts: { smtp?: unknown; sendResult?: boolean | Error } = {},
|
||||
) {
|
||||
function makeScheduler(prisma: any, opts: { smtp?: unknown; sendResult?: boolean | Error } = {}) {
|
||||
const registry = { addInterval: vi.fn(), deleteInterval: vi.fn() };
|
||||
const settings = { getSmtpConfig: vi.fn(async () => (opts.smtp === undefined ? {} : opts.smtp)) };
|
||||
const mail = {
|
||||
@@ -112,7 +122,12 @@ function makeScheduler(
|
||||
return opts.sendResult ?? true;
|
||||
}),
|
||||
};
|
||||
const scheduler = new ReminderMailScheduler(registry as any, prisma, settings as any, mail as any);
|
||||
const scheduler = new ReminderMailScheduler(
|
||||
registry as any,
|
||||
prisma,
|
||||
settings as any,
|
||||
mail as any,
|
||||
);
|
||||
return { scheduler, registry, settings, mail };
|
||||
}
|
||||
|
||||
@@ -126,7 +141,9 @@ describe('ReminderMailScheduler — Anspruch (T-IF2-06)', () => {
|
||||
const one = makeScheduler(store.prisma);
|
||||
const two = makeScheduler(store.prisma);
|
||||
await Promise.all([one.scheduler.runTick(NOW), two.scheduler.runTick(NOW)]);
|
||||
expect(one.mail.sendReminderEmail.mock.calls.length + two.mail.sendReminderEmail.mock.calls.length).toBe(1);
|
||||
expect(
|
||||
one.mail.sendReminderEmail.mock.calls.length + two.mail.sendReminderEmail.mock.calls.length,
|
||||
).toBe(1);
|
||||
expect(store.rows[0].emailSentAt).toEqual(NOW);
|
||||
expect(store.rows[0].emailAttempts).toBe(1);
|
||||
});
|
||||
@@ -223,6 +240,23 @@ describe('ReminderMailScheduler — Fehlschlag und Wiederholung (E-04)', () => {
|
||||
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
|
||||
expect(store.rows[0].emailSentAt).toEqual(NOW);
|
||||
});
|
||||
|
||||
it('deaktivierter Benutzer: kein Versand, Anspruch bleibt wie ohne Adresse', async () => {
|
||||
const store = makeStore([row({ id: 'a' })], { u1: 'u1@example.invalid' }, ['u1']);
|
||||
const { scheduler, mail } = makeScheduler(store.prisma);
|
||||
const spy = vi.spyOn(store.prisma, '__tenantClient');
|
||||
await scheduler.runTick(NOW);
|
||||
await scheduler.runTick(new Date(NOW.getTime() + 30_000));
|
||||
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
|
||||
expect(store.rows[0].emailSentAt).toEqual(NOW);
|
||||
expect(store.rows[0].emailAttempts).toBe(1);
|
||||
const client = spy.mock.results[0].value;
|
||||
expect(client.user.findFirst.mock.calls[0][0].where).toEqual({
|
||||
id: 'u1',
|
||||
tenantId: 't1',
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReminderMailScheduler — Kandidaten (E-03, T-IF2-07)', () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ const BATCH = 200;
|
||||
* Scheitert der Transport, gibt der Planer den Anspruch wieder frei
|
||||
* (`emailSentAt = null`), sodass der naechste Durchlauf es erneut versucht —
|
||||
* hoechstens dreimal. Fehlt beim Senden die SMTP-Einrichtung oder die Adresse
|
||||
* des Benutzers, bleibt der Anspruch: die Faelligkeit gilt als erledigt und
|
||||
* des Benutzers oder ist sein Konto deaktiviert (`isActive = false`), bleibt der Anspruch: die Faelligkeit gilt als erledigt und
|
||||
* wird nur protokolliert, es gibt keine Wiederholschleife. Ein Verschieben
|
||||
* („Spaeter erinnern“) setzt beide Felder zurueck (siehe `RemindersService`).
|
||||
*
|
||||
@@ -104,9 +104,7 @@ export class ReminderMailScheduler implements OnApplicationBootstrap {
|
||||
await this.processCandidate(candidate, now);
|
||||
} catch (err) {
|
||||
// Eine kaputte Zeile darf die uebrigen nicht anhalten.
|
||||
this.logger.error(
|
||||
`Reminder email for ${candidate.id} failed: ${(err as Error).message}`,
|
||||
);
|
||||
this.logger.error(`Reminder email for ${candidate.id} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -140,14 +138,15 @@ export class ReminderMailScheduler implements OnApplicationBootstrap {
|
||||
where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt },
|
||||
select: { title: true, description: true, dueAt: true },
|
||||
});
|
||||
// Nur aktive Konten: ein deaktivierter Benutzer gilt wie einer ohne Adresse.
|
||||
const user = await tenantPrisma.user.findFirst({
|
||||
where: { id: c.userId, tenantId: c.tenantId },
|
||||
where: { id: c.userId, tenantId: c.tenantId, isActive: true },
|
||||
select: { email: true },
|
||||
});
|
||||
const smtp = await this.settingsService.getSmtpConfig(c.tenantId);
|
||||
if (!row || !user?.email || smtp === null) {
|
||||
this.logger.log(
|
||||
`Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user?.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`,
|
||||
`Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user ? 'Benutzer deaktiviert' : !user.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`,
|
||||
);
|
||||
return; // Anspruch bleibt: gilt als erledigt, keine Wiederholschleife (E-04)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'reflect-metadata';
|
||||
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
|
||||
@@ -24,7 +24,14 @@ const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
|
||||
const proto = RemindersController.prototype as any;
|
||||
|
||||
describe('RemindersController — Rollen', () => {
|
||||
it.each(['list', 'emailStatus', 'create', 'update', 'snooze', 'remove'])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
|
||||
it.each([
|
||||
'list',
|
||||
'emailStatus',
|
||||
'create',
|
||||
'update',
|
||||
'snooze',
|
||||
'remove',
|
||||
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -55,7 +62,9 @@ describe('RemindersController — Mandant', () => {
|
||||
const controller = new RemindersController(makeService() as any);
|
||||
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.emailStatus(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
await expect(
|
||||
controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
@@ -96,6 +105,28 @@ describe('RemindersController — Pipe fuer Aendern und Verschieben', () => {
|
||||
expect(out).toEqual({ title: 'b' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'title',
|
||||
'description',
|
||||
'dueAt',
|
||||
'emailEnabled',
|
||||
])('Aendern mit %s: null ergibt 400 statt eines Datenbankfehlers', async (field) => {
|
||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||
await expect(
|
||||
pipe.transform({ [field]: null }, { type: 'body', metatype: UpdateReminderDto }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('Aendern: ein leerer Titel wird abgelehnt, ein leeres Objekt ist erlaubt', async () => {
|
||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||
await expect(
|
||||
pipe.transform({ title: ' ' }, { type: 'body', metatype: UpdateReminderDto }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
pipe.transform({}, { type: 'body', metatype: UpdateReminderDto }),
|
||||
).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('Verschieben verlangt einen ISO-Zeitpunkt und verwirft Fremdfelder', async () => {
|
||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||
const out: any = await pipe.transform(
|
||||
|
||||
@@ -16,7 +16,13 @@ function makeFakePrisma() {
|
||||
const reminder = {
|
||||
create: vi.fn(async ({ data }: { data: any }) => {
|
||||
const id = `r-${++seq}`;
|
||||
const row = { id, createdAt: new Date(), updatedAt: new Date(), emailEnabled: false, ...data };
|
||||
const row = {
|
||||
id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
emailEnabled: false,
|
||||
...data,
|
||||
};
|
||||
rows.set(id, row);
|
||||
return row;
|
||||
}),
|
||||
@@ -37,6 +43,14 @@ function makeFakePrisma() {
|
||||
rows.set(where.id, row);
|
||||
return row;
|
||||
}),
|
||||
// Wie die eine SQL-Anweisung: Bedingung pruefen und schreiben ohne `await` dazwischen.
|
||||
updateMany: vi.fn(async ({ where, data }: { where: any; data: any }) => {
|
||||
const r = rows.get(where.id);
|
||||
if (!r || r.tenantId !== where.tenantId || r.userId !== where.userId) return { count: 0 };
|
||||
if (where.dueAt?.gt && !(r.dueAt.getTime() > where.dueAt.gt.getTime())) return { count: 0 };
|
||||
rows.set(where.id, { ...r, ...data });
|
||||
return { count: 1 };
|
||||
}),
|
||||
delete: vi.fn(async ({ where }: { where: any }) => {
|
||||
rows.delete(where.id);
|
||||
}),
|
||||
@@ -106,7 +120,9 @@ describe('RemindersService — anlegen', () => {
|
||||
service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
// ein anderer Benutzer ist davon nicht betroffen
|
||||
await expect(service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) })).resolves.toBeTruthy();
|
||||
await expect(
|
||||
service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) }),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,15 +169,21 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
|
||||
])('%s: aendern, verschieben und loeschen ergeben 404', async (_n, owner) => {
|
||||
const { prisma, service } = setup();
|
||||
seed(prisma, 'x', past(), owner);
|
||||
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.remove('t1', 'u1', 'x')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.rows.has('x')).toBe(true);
|
||||
});
|
||||
|
||||
it('eine unbekannte Kennung ergibt 404', async () => {
|
||||
const { service } = setup();
|
||||
await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('das where jeder Abfrage traegt Mandant und Benutzer', async () => {
|
||||
@@ -173,10 +195,11 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
|
||||
tenantId: 't1',
|
||||
userId: 'u1',
|
||||
});
|
||||
expect(prisma.reminder.update.mock.calls[0]?.[0]?.where).toEqual({
|
||||
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.where).toEqual({
|
||||
id: 'x',
|
||||
tenantId: 't1',
|
||||
userId: 'u1',
|
||||
dueAt: { gt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -185,7 +208,9 @@ describe('RemindersService — aendern', () => {
|
||||
it('eine faellige Erinnerung laesst sich nicht aendern (409)', async () => {
|
||||
const { prisma, service } = setup();
|
||||
seed(prisma, 'x', past());
|
||||
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(ConflictException);
|
||||
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('eine vergangene neue Faelligkeit ergibt 400', async () => {
|
||||
@@ -200,14 +225,61 @@ describe('RemindersService — aendern', () => {
|
||||
const { prisma, service } = setup();
|
||||
seed(prisma, 'x', future());
|
||||
await service.update('t1', 'u1', 'x', { title: 'neu' });
|
||||
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' });
|
||||
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' });
|
||||
const newDue = inHours(9);
|
||||
await service.update('t1', 'u1', 'x', { dueAt: newDue, description: '' });
|
||||
expect(prisma.reminder.update.mock.calls[1]?.[0]?.data).toEqual({
|
||||
expect(prisma.reminder.updateMany.mock.calls[1]?.[0]?.data).toEqual({
|
||||
description: '',
|
||||
dueAt: new Date(newDue),
|
||||
emailSentAt: null,
|
||||
emailAttempts: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('eine neue Faelligkeit setzt emailSentAt und emailAttempts zurueck, sonst bleiben sie', async () => {
|
||||
const { prisma, service } = setup();
|
||||
seed(prisma, 'x', future());
|
||||
await service.update('t1', 'u1', 'x', { title: 'neu' });
|
||||
expect(prisma.rows.get('x').emailAttempts).toBe(2);
|
||||
expect(prisma.rows.get('x').emailSentAt).not.toBeNull();
|
||||
const newDue = inHours(5);
|
||||
const out: any = await service.update('t1', 'u1', 'x', { dueAt: newDue });
|
||||
expect(prisma.rows.get('x')).toMatchObject({
|
||||
dueAt: new Date(newDue),
|
||||
emailSentAt: null,
|
||||
emailAttempts: 0,
|
||||
});
|
||||
expect(out.dueAt).toEqual(new Date(newDue));
|
||||
});
|
||||
|
||||
it('wird die Erinnerung zwischen Pruefung und Schreiben faellig, gibt es 409 und keine Aenderung', async () => {
|
||||
const { prisma, service } = setup();
|
||||
seed(prisma, 'x', future());
|
||||
// Nach dem Laden (Pruefung bestanden) ist die Zeile inzwischen faellig.
|
||||
prisma.reminder.findFirst.mockImplementationOnce(async () => {
|
||||
const r = prisma.rows.get('x');
|
||||
const loaded = { ...r };
|
||||
prisma.rows.set('x', { ...r, dueAt: past() });
|
||||
return loaded;
|
||||
});
|
||||
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(prisma.rows.get('x').title).toBe('alt');
|
||||
});
|
||||
|
||||
it('wird die Erinnerung zwischen Pruefung und Schreiben geloescht, gibt es 404', async () => {
|
||||
const { prisma, service } = setup();
|
||||
seed(prisma, 'x', future());
|
||||
prisma.reminder.findFirst.mockImplementationOnce(async () => {
|
||||
const loaded = { ...prisma.rows.get('x') };
|
||||
prisma.rows.delete('x');
|
||||
return loaded;
|
||||
});
|
||||
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemindersService — spaeter erinnern (D-03)', () => {
|
||||
@@ -276,7 +348,10 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
||||
hasEmail: false,
|
||||
});
|
||||
expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u-ohne-mail');
|
||||
expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({ id: 'u-ohne-mail', tenantId: 't1' });
|
||||
expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({
|
||||
id: 'u-ohne-mail',
|
||||
tenantId: 't1',
|
||||
});
|
||||
});
|
||||
|
||||
it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => {
|
||||
@@ -298,7 +373,11 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
const noMail = setup();
|
||||
await expect(
|
||||
noMail.service.create('t1', 'u-ohne-mail', { title: 'a', dueAt: inHours(1), emailEnabled: true }),
|
||||
noMail.service.create('t1', 'u-ohne-mail', {
|
||||
title: 'a',
|
||||
dueAt: inHours(1),
|
||||
emailEnabled: true,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(noSmtp.prisma.rows.size).toBe(0);
|
||||
expect(noMail.prisma.rows.size).toBe(0);
|
||||
@@ -311,6 +390,6 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
||||
BadRequestException,
|
||||
);
|
||||
await service.update('t1', 'u1', 'x', { emailEnabled: false });
|
||||
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
|
||||
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
|
||||
|
||||
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
|
||||
@@ -107,26 +107,58 @@ export class RemindersService {
|
||||
/**
|
||||
* Bearbeiten einer noch NICHT faelligen Erinnerung. Eine faellige ist tabu
|
||||
* (409): dafuer gibt es „Erledigt“ und „Spaeter erinnern“ (D-03).
|
||||
*
|
||||
* WARUM `updateMany` MIT `dueAt > jetzt`: Pruefung und Schreiben sind sonst
|
||||
* getrennt — wird die Erinnerung dazwischen faellig (und der E-Mail-Planer
|
||||
* hat sie womoeglich schon beansprucht), wuerde trotzdem geschrieben. Die
|
||||
* Bedingung im selben Schreibzugriff schliesst das aus; `count === 0` heisst
|
||||
* dann 409 (oder 404, falls die Zeile inzwischen geloescht ist).
|
||||
*
|
||||
* Eine neue Faelligkeit setzt wie beim Verschieben die Spuren des
|
||||
* E-Mail-Planers zurueck, damit zur neuen Faelligkeit eine Mail geht.
|
||||
*/
|
||||
async update(tenantId: string, userId: string, id: string, dto: UpdateReminderDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const row = await this.loadOwn(tenantPrisma, tenantId, userId, id);
|
||||
if (row.dueAt.getTime() <= Date.now()) {
|
||||
const now = Date.now();
|
||||
if (row.dueAt.getTime() <= now) {
|
||||
throw new ConflictException('Die Erinnerung ist bereits fällig');
|
||||
}
|
||||
const data: { title?: string; description?: string; dueAt?: Date; emailEnabled?: boolean } = {};
|
||||
const data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
dueAt?: Date;
|
||||
emailEnabled?: boolean;
|
||||
emailSentAt?: null;
|
||||
emailAttempts?: number;
|
||||
} = {};
|
||||
if (dto.title !== undefined) data.title = dto.title;
|
||||
if (dto.description !== undefined) data.description = dto.description;
|
||||
if (dto.dueAt !== undefined) data.dueAt = this.assertValidDueAt(dto.dueAt);
|
||||
if (dto.dueAt !== undefined) {
|
||||
data.dueAt = this.assertValidDueAt(dto.dueAt);
|
||||
data.emailSentAt = null;
|
||||
data.emailAttempts = 0;
|
||||
}
|
||||
if (dto.emailEnabled !== undefined) {
|
||||
if (dto.emailEnabled) await this.assertEmailAvailable(tenantId, userId);
|
||||
data.emailEnabled = dto.emailEnabled;
|
||||
}
|
||||
return tenantPrisma.reminder.update({
|
||||
where: { id, tenantId, userId },
|
||||
const result = await tenantPrisma.reminder.updateMany({
|
||||
where: { id, tenantId, userId, dueAt: { gt: new Date() } },
|
||||
data,
|
||||
});
|
||||
if (result.count !== 1) {
|
||||
await this.loadOwn(tenantPrisma, tenantId, userId, id); // inzwischen geloescht: 404
|
||||
throw new ConflictException('Die Erinnerung ist bereits fällig');
|
||||
}
|
||||
const updated = await tenantPrisma.reminder.findFirst({
|
||||
where: { id, tenantId, userId },
|
||||
select: REMINDER_SELECT,
|
||||
});
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Erinnerung nicht gefunden');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use semver::Version;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tauri::{
|
||||
@@ -48,6 +49,59 @@ struct VersionResponse {
|
||||
/// `Update` ist Clone + Send + Sync, `app.manage` verlangt das.
|
||||
struct PendingUpdate(Mutex<Option<(Update, String)>>);
|
||||
|
||||
/// Ablauf-Merker des Updaters neben `PendingUpdate`.
|
||||
///
|
||||
/// `installing`: von `spawn_update_install` bis zum Fehlerfall gesetzt (im
|
||||
/// Erfolgsfall startet die App neu bzw. beendet sich, der Merker bleibt).
|
||||
/// Solange er gesetzt ist, tun Tray-Klick, `spawn_version_check` und die
|
||||
/// 4-h-Schleife nichts -- vorher konnte eine Pruefung waehrend des Downloads
|
||||
/// ein neues Update ablegen und den Eintrag wieder aktiv schalten, ein
|
||||
/// zweiter Klick startete dann einen parallelen Download samt Installer.
|
||||
///
|
||||
/// `offered`: eine Pruefung hat ein Update angeboten ("Auf Version …
|
||||
/// aktualisieren"). Der Klick darauf prueft frisch (`install_after`); schlug
|
||||
/// diese Pruefung fehl, war der abgelegte Stand weg und der naechste Klick
|
||||
/// bot nur wieder an -- zwei weitere Klicks bis zur Installation. Der Merker
|
||||
/// ueberlebt die fehlgeschlagene Pruefung, der naechste erfolgreiche Klick
|
||||
/// installiert direkt. Geleert, wenn eine Pruefung "kein Update" ergibt oder
|
||||
/// der Server wechselt.
|
||||
#[derive(Default)]
|
||||
struct UpdateFlow {
|
||||
installing: AtomicBool,
|
||||
offered: AtomicBool,
|
||||
}
|
||||
|
||||
impl UpdateFlow {
|
||||
/// Beansprucht die Installation. `false`, wenn bereits eine laeuft --
|
||||
/// dann darf der Aufrufer nichts starten.
|
||||
fn try_begin_install(&self) -> bool {
|
||||
self.installing
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Gibt die Installation nach einem Fehler wieder frei.
|
||||
fn install_failed(&self) {
|
||||
self.installing.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn is_installing(&self) -> bool {
|
||||
self.installing.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn set_offered(&self, offered: bool) {
|
||||
self.offered.store(offered, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Soll der Tray-Klick nach der frischen Pruefung installieren? Ja, wenn
|
||||
/// ein Stand abgelegt war ODER vorher schon ein Update angeboten wurde
|
||||
/// (Klick auf "Update-Prüfung fehlgeschlagen … – erneut prüfen" nach
|
||||
/// einem Angebot).
|
||||
fn install_on_click(&self, had_pending: bool) -> bool {
|
||||
had_pending || self.offered.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
/// Benachrichtigungstext der zuletzt gemeldeten fehlgeschlagenen
|
||||
/// Update-Pruefung. Die Pruefung laeuft alle `UPDATE_CHECK_INTERVAL` erneut;
|
||||
/// gegen einen dauerhaft sperrenden Proxy wuerde sonst alle vier Stunden
|
||||
@@ -429,28 +483,44 @@ fn grant_server_notifications(app: &AppHandle, url: &str) {
|
||||
);
|
||||
return;
|
||||
};
|
||||
{
|
||||
let mut granted = match GRANTED_SERVER_ORIGINS.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
if granted.contains(&pattern) {
|
||||
return;
|
||||
let result = grant_origin_once(&GRANTED_SERVER_ORIGINS, &pattern, |pattern| {
|
||||
let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications")
|
||||
.remote(pattern.to_string())
|
||||
.local(false)
|
||||
.window("main");
|
||||
for permission in SERVER_NOTIFICATION_PERMISSIONS {
|
||||
capability = capability.permission(permission);
|
||||
}
|
||||
granted.push(pattern.clone());
|
||||
}
|
||||
let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications")
|
||||
.remote(pattern)
|
||||
.local(false)
|
||||
.window("main");
|
||||
for permission in SERVER_NOTIFICATION_PERMISSIONS {
|
||||
capability = capability.permission(permission);
|
||||
}
|
||||
if let Err(e) = app.add_capability(capability) {
|
||||
app.add_capability(capability)
|
||||
});
|
||||
if let Err(e) = result {
|
||||
eprintln!("Benachrichtigungen: Berechtigung nicht erteilt: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fuehrt `grant` fuer `pattern` hoechstens einmal erfolgreich aus. Die Sperre
|
||||
/// von `granted` bleibt ueber den Aufruf gehalten, damit zwei gleichzeitige
|
||||
/// Aufrufe nicht doppelt berechtigen; vermerkt wird der Ursprung erst NACH
|
||||
/// dem Erfolg. Frueher stand er schon vorher in der Liste -- schlug
|
||||
/// `add_capability` fehl, gab es bis zum Neustart keinen neuen Versuch.
|
||||
/// Bereits vermerkt: `Ok(())` ohne Aufruf.
|
||||
fn grant_origin_once<E>(
|
||||
granted: &Mutex<Vec<String>>,
|
||||
pattern: &str,
|
||||
grant: impl FnOnce(&str) -> Result<(), E>,
|
||||
) -> Result<(), E> {
|
||||
let mut granted = match granted.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
if granted.iter().any(|p| p == pattern) {
|
||||
return Ok(());
|
||||
}
|
||||
grant(pattern)?;
|
||||
granted.push(pattern.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide
|
||||
/// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in
|
||||
/// `update_labels`.
|
||||
@@ -596,7 +666,13 @@ fn clear_check_notice(app: &AppHandle) {
|
||||
/// `/desktop/download/<os>` liefert immer den AKTUELLEN Installer; ein
|
||||
/// Stunden alter Stand trug noch die Signatur der Vorversion, nach dem
|
||||
/// Server-Update passten Datei und Signatur nicht mehr zusammen.
|
||||
///
|
||||
/// Waehrend einer Installation (`UpdateFlow::installing`) tut die Funktion
|
||||
/// nichts: kein Menuetext, kein Leeren, keine Ablage.
|
||||
fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool) {
|
||||
if app.state::<UpdateFlow>().is_installing() {
|
||||
return;
|
||||
}
|
||||
let update_item = app.state::<TrayItems>().update.clone();
|
||||
let _ = update_item.set_text(UPDATE_ITEM_CHECKING);
|
||||
let _ = update_item.set_enabled(false);
|
||||
@@ -615,7 +691,14 @@ fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool)
|
||||
.expect("CARGO_PKG_VERSION muss eine gueltige SemVer-Version sein");
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match check_for_update(&app, endpoint).await {
|
||||
let result = check_for_update(&app, endpoint).await;
|
||||
// Eine parallel gestartete Pruefung (Klick waehrend der 4-h-Schleife)
|
||||
// kann inzwischen eine Installation ausgeloest haben: dann weder
|
||||
// Menuetext noch Ablage anfassen.
|
||||
if app.state::<UpdateFlow>().is_installing() {
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(Some(mut update)) => {
|
||||
let (menu_text, body) = Version::parse(&update.version)
|
||||
.map(|release| release_labels(¤t, &release))
|
||||
@@ -640,10 +723,12 @@ fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool)
|
||||
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
|
||||
*pending = Some((update, menu_text));
|
||||
}
|
||||
app.state::<UpdateFlow>().set_offered(true);
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = update_item.set_text(UPDATE_ITEM_NONE);
|
||||
let _ = update_item.set_enabled(true);
|
||||
app.state::<UpdateFlow>().set_offered(false);
|
||||
clear_check_notice(&app);
|
||||
}
|
||||
// Der Release-Bau verweigert http-Endpunkte (config.rs
|
||||
@@ -692,7 +777,13 @@ fn open_download_page(app: &AppHandle) {
|
||||
/// gesperrten Eintrags. Fehler: Menuetext und Stand zurueck, Eintrag wieder
|
||||
/// aktiv, Benachrichtigung mit Grund, Einstellungsseite im Browser als
|
||||
/// Rueckfall.
|
||||
///
|
||||
/// Beansprucht `UpdateFlow::installing`; laeuft bereits eine Installation,
|
||||
/// passiert nichts. Freigegeben wird der Merker nur im Fehlerfall.
|
||||
fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
|
||||
if !app.state::<UpdateFlow>().try_begin_install() {
|
||||
return;
|
||||
}
|
||||
let item = app.state::<TrayItems>().update.clone();
|
||||
let _ = item.set_enabled(false);
|
||||
let _ = item.set_text("Update wird heruntergeladen…");
|
||||
@@ -738,6 +829,7 @@ fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
|
||||
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
|
||||
*pending = Some((update, menu_text.clone()));
|
||||
}
|
||||
app.state::<UpdateFlow>().install_failed();
|
||||
let _ = item.set_enabled(true);
|
||||
let _ = app
|
||||
.notification()
|
||||
@@ -796,6 +888,8 @@ fn save_server_url(app: AppHandle, url: String) -> Result<(), String> {
|
||||
// Vor dem Navigieren: die Seite darf danach sofort Desktop-Meldungen ausloesen.
|
||||
grant_server_notifications(&app, &normalized);
|
||||
apply_server(&app, Some(&normalized));
|
||||
// Ein Angebot des alten Servers gilt nicht fuer den neuen.
|
||||
app.state::<UpdateFlow>().set_offered(false);
|
||||
spawn_version_check(app.clone(), normalized.clone(), false);
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
@@ -993,6 +1087,7 @@ pub fn run() {
|
||||
update: update.clone(),
|
||||
});
|
||||
app.manage(PendingUpdate(Mutex::new(None)));
|
||||
app.manage(UpdateFlow::default());
|
||||
app.manage(LastCheckNotice(Mutex::new(String::new())));
|
||||
|
||||
let autostart_for_menu = autostart.clone();
|
||||
@@ -1021,6 +1116,14 @@ pub fn run() {
|
||||
// nur erneut. Der Browser-Weg (`open_download_page`)
|
||||
// bleibt Rueckfall einer fehlgeschlagenen Installation.
|
||||
// Ohne gespeicherte Adresse gibt es nichts zu pruefen.
|
||||
// Waehrend einer Installation tut der Klick nichts
|
||||
// (`UpdateFlow`). War vorher schon ein Update
|
||||
// angeboten (Stand nach fehlgeschlagener Pruefung
|
||||
// weg), installiert der Klick trotzdem direkt.
|
||||
let flow = app.state::<UpdateFlow>();
|
||||
if flow.is_installing() {
|
||||
return;
|
||||
}
|
||||
let had_pending = app
|
||||
.state::<PendingUpdate>()
|
||||
.0
|
||||
@@ -1029,7 +1132,8 @@ pub fn run() {
|
||||
.and_then(|mut guard| guard.take())
|
||||
.is_some();
|
||||
if let Some(url) = stored_server_url(app) {
|
||||
spawn_version_check(app.clone(), url, had_pending);
|
||||
let install_after = flow.install_on_click(had_pending);
|
||||
spawn_version_check(app.clone(), url, install_after);
|
||||
}
|
||||
}
|
||||
"autostart" => {
|
||||
@@ -1081,11 +1185,16 @@ pub fn run() {
|
||||
// ein Update, wird der Durchlauf uebersprungen: die Pruefung
|
||||
// wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden.
|
||||
// Waehrend einer Installation ist der Stand per `take()` leer;
|
||||
// faellt die 4-h-Marke genau in den Download, wird nur der
|
||||
// Fortschrittstext ueberschrieben (T-FRG-04, akzeptiert).
|
||||
// darum prueft die Schleife zusaetzlich `UpdateFlow::installing`
|
||||
// und ueberspringt den Durchlauf (vorher: Pruefung mitten im
|
||||
// Download legte ein neues Update ab und schaltete den Eintrag
|
||||
// wieder aktiv -- zweiter paralleler Download moeglich).
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || loop {
|
||||
std::thread::sleep(UPDATE_CHECK_INTERVAL);
|
||||
if handle.state::<UpdateFlow>().is_installing() {
|
||||
continue;
|
||||
}
|
||||
let pending = handle
|
||||
.state::<PendingUpdate>()
|
||||
.0
|
||||
@@ -1493,6 +1602,78 @@ mod tests {
|
||||
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
|
||||
assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600));
|
||||
}
|
||||
|
||||
// --- UpdateFlow ---
|
||||
|
||||
#[test]
|
||||
fn update_flow_nur_eine_installation_gleichzeitig() {
|
||||
let flow = UpdateFlow::default();
|
||||
assert!(!flow.is_installing());
|
||||
assert!(flow.try_begin_install());
|
||||
assert!(flow.is_installing());
|
||||
assert!(
|
||||
!flow.try_begin_install(),
|
||||
"zweite Installation muss abgewiesen werden"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_flow_fehler_gibt_installation_frei() {
|
||||
let flow = UpdateFlow::default();
|
||||
assert!(flow.try_begin_install());
|
||||
flow.install_failed();
|
||||
assert!(!flow.is_installing());
|
||||
assert!(flow.try_begin_install());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_flow_klick_installiert_bei_abgelegtem_stand() {
|
||||
let flow = UpdateFlow::default();
|
||||
assert!(flow.install_on_click(true));
|
||||
assert!(!flow.install_on_click(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_flow_klick_nach_angebot_und_fehlgeschlagener_pruefung_installiert() {
|
||||
let flow = UpdateFlow::default();
|
||||
flow.set_offered(true);
|
||||
// Stand wurde vom ersten Klick entnommen, frische Pruefung schlug fehl.
|
||||
assert!(flow.install_on_click(false));
|
||||
// "Kein Update" oder Serverwechsel leert das Angebot.
|
||||
flow.set_offered(false);
|
||||
assert!(!flow.install_on_click(false));
|
||||
}
|
||||
|
||||
// --- grant_origin_once ---
|
||||
|
||||
#[test]
|
||||
fn grant_origin_once_vermerkt_erst_nach_erfolg() {
|
||||
let granted = Mutex::new(Vec::new());
|
||||
let r: Result<(), &str> = grant_origin_once(&granted, "https://a", |_| Err("kaputt"));
|
||||
assert_eq!(r, Err("kaputt"));
|
||||
assert!(granted.lock().unwrap().is_empty());
|
||||
|
||||
let mut calls = 0;
|
||||
let r: Result<(), &str> = grant_origin_once(&granted, "https://a", |p| {
|
||||
calls += 1;
|
||||
assert_eq!(p, "https://a");
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(r, Ok(()));
|
||||
assert_eq!(calls, 1, "nach einem Fehler muss ein neuer Versuch laufen");
|
||||
assert_eq!(*granted.lock().unwrap(), vec!["https://a".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grant_origin_once_ruft_bei_vermerktem_ursprung_nicht_erneut() {
|
||||
let granted = Mutex::new(vec!["https://a".to_string()]);
|
||||
let r: Result<(), &str> =
|
||||
grant_origin_once(&granted, "https://a", |_| panic!("darf nicht laufen"));
|
||||
assert_eq!(r, Ok(()));
|
||||
let r: Result<(), &str> = grant_origin_once(&granted, "https://b", |_| Ok(()));
|
||||
assert_eq!(r, Ok(()));
|
||||
assert_eq!(granted.lock().unwrap().len(), 2);
|
||||
}
|
||||
// --- server_origin_* (quick-260929-if2, E-01) ---
|
||||
|
||||
fn muster(p: &str) -> tauri::utils::acl::RemoteUrlPattern {
|
||||
|
||||
@@ -28,6 +28,8 @@ const messages: Record<string, Record<string, unknown>> = {
|
||||
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
|
||||
saveError: 'Das Modul konnte nicht gespeichert werden.',
|
||||
deleteError: 'Das Modul konnte nicht gelöscht werden.',
|
||||
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? Für alle Benutzer.',
|
||||
@@ -58,8 +60,18 @@ vi.mock('next-intl', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
|
||||
<a href={href} className={className}>{children}</a>
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
}) => (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -68,13 +80,7 @@ vi.mock('@/lib/use-category-label', () => ({
|
||||
({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug,
|
||||
}));
|
||||
|
||||
const {
|
||||
mockList,
|
||||
mockCreate,
|
||||
mockUpdate,
|
||||
mockDelete,
|
||||
mockBump,
|
||||
} = vi.hoisted(() => ({
|
||||
const { mockList, mockCreate, mockUpdate, mockDelete, mockBump } = vi.hoisted(() => ({
|
||||
mockList: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
@@ -106,6 +112,7 @@ vi.mock('@/lib/stores/auth-store', () => ({
|
||||
selector({ user: { role: mockRole } }),
|
||||
}));
|
||||
|
||||
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
|
||||
import AdminCustomModulesPage from './page';
|
||||
|
||||
const wiki = {
|
||||
@@ -136,7 +143,9 @@ describe('AdminCustomModulesPage', () => {
|
||||
render(<AdminCustomModulesPage />);
|
||||
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
|
||||
expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => {
|
||||
@@ -154,13 +163,17 @@ describe('AdminCustomModulesPage', () => {
|
||||
it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => {
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||
await userEvent.click(
|
||||
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||
);
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
|
||||
expect(await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.'),
|
||||
).toBeInTheDocument();
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
expect(mockBump).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -168,7 +181,9 @@ describe('AdminCustomModulesPage', () => {
|
||||
it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => {
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||
await userEvent.click(
|
||||
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||
);
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com');
|
||||
@@ -183,7 +198,9 @@ describe('AdminCustomModulesPage', () => {
|
||||
it('Anlegen: leerer Name zeigt die Meldung', async () => {
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||
await userEvent.click(
|
||||
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||
);
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Name'), ' ');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||
@@ -196,7 +213,9 @@ describe('AdminCustomModulesPage', () => {
|
||||
it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => {
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||
await userEvent.click(
|
||||
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||
);
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite ');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||
@@ -217,11 +236,62 @@ describe('AdminCustomModulesPage', () => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Anlegen: die Kategorie ist vorbelegt mit Infrastruktur', async () => {
|
||||
it('Anlegen: die Kategorie ist vorbelegt mit „Eigene Module“', async () => {
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||
expect(screen.getByLabelText('Kategorie')).toHaveValue('infrastructure');
|
||||
await userEvent.click(
|
||||
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||
);
|
||||
expect(screen.getByLabelText('Kategorie')).toHaveValue('custom-modules');
|
||||
});
|
||||
|
||||
it('Anlegen ohne Kategoriewahl sendet die Kategorie „Eigene Module“', async () => {
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
await userEvent.click(
|
||||
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||
);
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ category: 'custom-modules' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('Speichern mit 403 zeigt „keine Berechtigung“, nicht den allgemeinen Fehler', async () => {
|
||||
mockCreate.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden'));
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
await userEvent.click(
|
||||
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||
);
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Sie haben keine Berechtigung für diese Aktion.',
|
||||
);
|
||||
expect(screen.queryByText('Das Modul konnte nicht gespeichert werden.')).toBeNull();
|
||||
});
|
||||
|
||||
it('Speichern mit 400 zeigt die Meldung des Servers', async () => {
|
||||
mockList.mockResolvedValue([wiki]);
|
||||
mockUpdate.mockRejectedValue(
|
||||
new CustomModuleRequestError(400, 'Nur https-Adressen ohne Zugangsdaten sind erlaubt.'),
|
||||
);
|
||||
render(<AdminCustomModulesPage />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
|
||||
const dialog = screen.getByRole('dialog');
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' }));
|
||||
|
||||
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
|
||||
'Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.',
|
||||
);
|
||||
});
|
||||
|
||||
it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => {
|
||||
@@ -254,13 +324,17 @@ describe('AdminCustomModulesPage', () => {
|
||||
mockCreate.mockRejectedValue(new Error('500'));
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
|
||||
await userEvent.click(
|
||||
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||
);
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
|
||||
expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText('Das Modul konnte nicht gespeichert werden.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(mockBump).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -271,7 +345,9 @@ describe('AdminCustomModulesPage', () => {
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.')).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.'),
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
|
||||
|
||||
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
|
||||
@@ -283,7 +359,9 @@ describe('AdminCustomModulesPage', () => {
|
||||
mockList.mockResolvedValue([wiki]);
|
||||
render(<AdminCustomModulesPage />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' }));
|
||||
await userEvent.click(
|
||||
within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' }),
|
||||
);
|
||||
|
||||
expect(mockDelete).not.toHaveBeenCalled();
|
||||
expect(mockBump).not.toHaveBeenCalled();
|
||||
@@ -295,13 +373,38 @@ describe('AdminCustomModulesPage', () => {
|
||||
mockDelete.mockRejectedValue(new Error('500'));
|
||||
render(<AdminCustomModulesPage />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }));
|
||||
await userEvent.click(
|
||||
within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(mockBump).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Löschen mit 403 zeigt „keine Berechtigung“', async () => {
|
||||
mockList.mockResolvedValue([wiki]);
|
||||
mockDelete.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden'));
|
||||
render(<AdminCustomModulesPage />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||
await userEvent.click(
|
||||
within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }),
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Sie haben keine Berechtigung für diese Aktion.',
|
||||
);
|
||||
expect(screen.queryByText('Das Modul konnte nicht gelöscht werden.')).toBeNull();
|
||||
});
|
||||
|
||||
it('Ladefehler zeigt nur die Fehlermeldung, nicht zusätzlich den Leer-Zustand', async () => {
|
||||
mockList.mockRejectedValue(new CustomModuleRequestError(500, 'Internal server error'));
|
||||
render(<AdminCustomModulesPage />);
|
||||
|
||||
expect(await screen.findByText('Laden fehlgeschlagen.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Noch keine gemeinsamen Einträge')).toBeNull();
|
||||
});
|
||||
|
||||
it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => {
|
||||
mockList.mockResolvedValue([
|
||||
wiki,
|
||||
|
||||
@@ -129,9 +129,9 @@ describe('CustomModulesSettingsPage', () => {
|
||||
render(<CustomModulesSettingsPage />);
|
||||
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
|
||||
expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => {
|
||||
@@ -158,7 +158,7 @@ describe('CustomModulesSettingsPage', () => {
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
name: 'Mein Wiki',
|
||||
url: 'https://example.com',
|
||||
category: 'infrastructure',
|
||||
category: 'custom-modules',
|
||||
});
|
||||
});
|
||||
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared');
|
||||
@@ -228,7 +228,9 @@ describe('CustomModulesSettingsPage', () => {
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
|
||||
expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText('Das Modul konnte nicht gespeichert werden.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(mockBump).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
type CustomModule,
|
||||
CustomModuleRequestError,
|
||||
checkCustomModuleUrl,
|
||||
createCustomModule,
|
||||
updateCustomModule,
|
||||
@@ -27,6 +28,23 @@ interface CustomModuleFormModalProps {
|
||||
const NAME_MAX = 100;
|
||||
const URL_MAX = 2048;
|
||||
|
||||
/**
|
||||
* Fehlertext fuer Speichern und Loeschen: 403 = keine Berechtigung, 400 = die
|
||||
* API hat die Angaben abgelehnt (mit ihrer Meldung), alles andere (500,
|
||||
* Netzwerk) = der allgemeine „bitte erneut versuchen“-Text `fallbackKey`.
|
||||
*/
|
||||
export function customModuleErrorText(
|
||||
err: unknown,
|
||||
t: (key: string, values?: Record<string, string>) => string,
|
||||
fallbackKey: 'saveError' | 'deleteError',
|
||||
): string {
|
||||
if (err instanceof CustomModuleRequestError) {
|
||||
if (err.status === 403) return t('forbidden');
|
||||
if (err.status === 400) return t('invalidInput');
|
||||
}
|
||||
return t(fallbackKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`;
|
||||
* quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame,
|
||||
@@ -47,7 +65,9 @@ export function CustomModuleFormModal({
|
||||
|
||||
const [name, setName] = useState(mod?.name ?? '');
|
||||
const [url, setUrl] = useState(mod?.url ?? '');
|
||||
const [category, setCategory] = useState<string>(mod?.category ?? 'infrastructure');
|
||||
// Neue Eintraege landen vorbelegt in der Gruppe „Eigene Module“; beim
|
||||
// Bearbeiten bleibt die gespeicherte Kategorie.
|
||||
const [category, setCategory] = useState<string>(mod?.category ?? CUSTOM_MODULE_CATEGORY);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -81,8 +101,8 @@ export function CustomModuleFormModal({
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
setError(t('saveError'));
|
||||
} catch (err) {
|
||||
setError(customModuleErrorText(err, t, 'saveError'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
@@ -86,9 +86,7 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1
|
||||
className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}
|
||||
>
|
||||
<h1 className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}>
|
||||
{texts.title}
|
||||
</h1>
|
||||
<button type="button" onClick={openCreate} className="btn btn-primary">
|
||||
@@ -104,9 +102,11 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bei einem Ladefehler ohne Eintraege steht nur die Fehlermeldung da —
|
||||
„keine Eintraege“ waere dann falsch. */}
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||||
) : modules.length === 0 ? (
|
||||
) : modules.length === 0 && loadFailed ? null : modules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2>
|
||||
<p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p>
|
||||
@@ -119,9 +119,15 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('name')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('url')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('category')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
{t('name')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
{t('url')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
{t('category')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
||||
{tManage('actions')}
|
||||
</th>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api';
|
||||
import { customModuleErrorText } from './custom-module-form-modal';
|
||||
|
||||
interface DeleteCustomModuleDialogProps {
|
||||
mod: CustomModule;
|
||||
@@ -37,8 +38,8 @@ export function DeleteCustomModuleDialog({
|
||||
await deleteCustomModule(mod.id);
|
||||
onDeleted();
|
||||
onClose();
|
||||
} catch {
|
||||
setError(t('deleteError'));
|
||||
} catch (err) {
|
||||
setError(customModuleErrorText(err, t, 'deleteError'));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
@@ -55,7 +56,11 @@ export function DeleteCustomModuleDialog({
|
||||
<h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground">
|
||||
{t('deleteConfirm.title')}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-foreground">{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', { name: mod.name })}</p>
|
||||
<p className="mb-4 text-sm text-foreground">
|
||||
{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', {
|
||||
name: mod.name,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -6,10 +6,10 @@ import { createPortal } from 'react-dom';
|
||||
import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
|
||||
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
|
||||
import {
|
||||
createReminder,
|
||||
type Reminder,
|
||||
type ReminderEmailStatus,
|
||||
ReminderRequestError,
|
||||
createReminder,
|
||||
updateReminder,
|
||||
} from '@/lib/reminders-api';
|
||||
|
||||
@@ -85,9 +85,15 @@ export function ReminderFormModal({
|
||||
setError(t('reminder.titleRequired'));
|
||||
return;
|
||||
}
|
||||
const dueAt = localInputsToIso(date, time);
|
||||
// Beim Bearbeiten nur senden, wenn Datum oder Uhrzeit wirklich geaendert
|
||||
// wurden: das Formular kennt keine Sekunden, ein unveraendertes Feld wuerde
|
||||
// die Faelligkeit sonst um bis zu 59 s verschieben — und kurz vor der
|
||||
// Faelligkeit faelschlich „in der Vergangenheit“ melden. Ist sie inzwischen
|
||||
// faellig, antwortet der Server ohnehin mit 409 (`alreadyDue`).
|
||||
const dueChanged = !reminder || date !== initial.date || time !== initial.time;
|
||||
const dueAt = dueChanged ? localInputsToIso(date, time) : undefined;
|
||||
// Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen.
|
||||
if (dueAt === null || new Date(dueAt).getTime() <= Date.now()) {
|
||||
if (dueChanged && (!dueAt || new Date(dueAt).getTime() <= Date.now())) {
|
||||
setError(t('reminder.pastError'));
|
||||
return;
|
||||
}
|
||||
@@ -98,16 +104,15 @@ export function ReminderFormModal({
|
||||
// ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400
|
||||
// ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen.
|
||||
const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled;
|
||||
const input = {
|
||||
const common = {
|
||||
title: trimmedTitle,
|
||||
description: description.trim(),
|
||||
dueAt,
|
||||
...(emailChanged ? { emailEnabled } : {}),
|
||||
};
|
||||
if (reminder) {
|
||||
await updateReminder(reminder.id, input);
|
||||
} else {
|
||||
await createReminder(input);
|
||||
await updateReminder(reminder.id, { ...common, ...(dueAt ? { dueAt } : {}) });
|
||||
} else if (dueAt) {
|
||||
await createReminder({ ...common, dueAt });
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
|
||||
@@ -21,11 +21,11 @@ vi.mock('@/lib/reminders-api', async () => {
|
||||
});
|
||||
|
||||
import {
|
||||
ReminderRequestError,
|
||||
createReminder,
|
||||
deleteReminder,
|
||||
getReminderEmailStatus,
|
||||
listReminders,
|
||||
ReminderRequestError,
|
||||
snoozeReminder,
|
||||
updateReminder,
|
||||
} from '@/lib/reminders-api';
|
||||
@@ -79,7 +79,9 @@ async function openForm() {
|
||||
|
||||
function fillAndSubmit(title: string) {
|
||||
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: title } });
|
||||
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2099-03-04' } });
|
||||
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
|
||||
target: { value: '2099-03-04' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '14:30' } });
|
||||
fireEvent.click(screen.getByText('reminder.save'));
|
||||
}
|
||||
@@ -146,7 +148,9 @@ describe('ReminderWidget', () => {
|
||||
render(<ReminderWidget {...props} />);
|
||||
await openForm();
|
||||
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'x' } });
|
||||
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2001-01-01' } });
|
||||
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
|
||||
target: { value: '2001-01-01' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('reminder.save'));
|
||||
expect(await screen.findByText('reminder.pastError')).toBeTruthy();
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
@@ -260,6 +264,38 @@ describe('ReminderWidget — faellig und Aktionen (D-03)', () => {
|
||||
expect(requestPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Bearbeiten ohne Aenderung von Datum/Uhrzeit sendet kein dueAt (keine Sekunden-Verschiebung)', async () => {
|
||||
// Faellig in 30 s, mit Sekunden: auf Minuten gekuerzt laege sie in der Vergangenheit.
|
||||
const due = new Date(Date.now() + 30_000);
|
||||
due.setSeconds(due.getSeconds(), 500);
|
||||
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
|
||||
mockUpdate.mockResolvedValue({});
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByLabelText('reminder.edit'));
|
||||
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'Neu' } });
|
||||
fireEvent.click(screen.getByText('reminder.save'));
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByText('reminder.pastError')).toBeNull();
|
||||
expect(mockUpdate.mock.calls[0][1]).not.toHaveProperty('dueAt');
|
||||
expect(mockUpdate.mock.calls[0][1].title).toBe('Neu');
|
||||
});
|
||||
|
||||
it('Bearbeiten mit geaenderter Uhrzeit sendet das neue dueAt', async () => {
|
||||
const due = new Date(Date.now() + 2 * 3600_000);
|
||||
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
|
||||
mockUpdate.mockResolvedValue({});
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByLabelText('reminder.edit'));
|
||||
const next = new Date(Date.now() + 26 * 3600_000);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const nextDate = `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`;
|
||||
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: nextDate } });
|
||||
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '09:15' } });
|
||||
fireEvent.click(screen.getByText('reminder.save'));
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(mockUpdate.mock.calls[0][1].dueAt).toBe(new Date(`${nextDate}T09:15`).toISOString());
|
||||
});
|
||||
|
||||
it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => {
|
||||
mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]);
|
||||
mockSnooze.mockRejectedValue(new ReminderRequestError(409));
|
||||
@@ -313,6 +349,27 @@ describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReminderWidget — Spaeter erinnern kurz nach Mitternacht', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('faellig gestern 23:50, jetzt 00:10: die Option heisst „Heute“ und verschiebt auf heute 23:50', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.setSystemTime(new Date(2026, 8, 30, 0, 10));
|
||||
mockList.mockResolvedValue([
|
||||
reminder('a', 'Spaet', new Date(2026, 8, 29, 23, 50).toISOString()),
|
||||
]);
|
||||
mockSnooze.mockResolvedValue({});
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByText('reminder.snooze'));
|
||||
expect(screen.queryByText('reminder.snoozeTomorrow')).toBeNull();
|
||||
fireEvent.click(await screen.findByText('reminder.snoozeToday'));
|
||||
await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1));
|
||||
expect(mockSnooze.mock.calls[0][1]).toBe(new Date(2026, 8, 30, 23, 50).toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
||||
async function openFormAndWaitForStatus() {
|
||||
await openForm();
|
||||
@@ -383,7 +440,10 @@ describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
||||
it('Bearbeiten einer Erinnerung mit angehakter Mail sendet emailEnabled nur bei Aenderung', async () => {
|
||||
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
|
||||
mockList.mockResolvedValue([
|
||||
{ ...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()), emailEnabled: true },
|
||||
{
|
||||
...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()),
|
||||
emailEnabled: true,
|
||||
},
|
||||
]);
|
||||
mockUpdate.mockResolvedValue({});
|
||||
render(<ReminderWidget {...props} />);
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { browserPermissionState } from '@/lib/reminder-notify';
|
||||
import { type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
|
||||
import { isSameLocalDay, type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
|
||||
import {
|
||||
deleteReminder,
|
||||
getReminderEmailStatus,
|
||||
listReminders,
|
||||
REMINDERS_CHANGED_EVENT,
|
||||
type Reminder,
|
||||
type ReminderEmailStatus,
|
||||
ReminderRequestError,
|
||||
deleteReminder,
|
||||
getReminderEmailStatus,
|
||||
listReminders,
|
||||
snoozeReminder,
|
||||
} from '@/lib/reminders-api';
|
||||
import type { WidgetProps } from '../widget-registry';
|
||||
@@ -107,7 +107,9 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
/** Fehler einer Zeilenaktion: 409 heisst, der Stand war veraltet — Text zeigen und neu laden. */
|
||||
const handleFailure = (err: unknown, conflictKey: string) => {
|
||||
setNotice(
|
||||
err instanceof ReminderRequestError && err.status === 409 ? t(conflictKey) : t('reminder.saveError'),
|
||||
err instanceof ReminderRequestError && err.status === 409
|
||||
? t(conflictKey)
|
||||
: t('reminder.saveError'),
|
||||
);
|
||||
void refetch();
|
||||
};
|
||||
@@ -134,6 +136,16 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// „Heute“ oder „Morgen“ aus dem tatsaechlich berechneten Zeitpunkt: kurz nach
|
||||
// Mitternacht liegt das naechste Vorkommen der Uhrzeit noch heute.
|
||||
const tomorrowLabel = (r: Reminder) => {
|
||||
const target = snoozeTarget('tomorrow', new Date(r.dueAt), new Date(now));
|
||||
const time = timeOnly.format(target);
|
||||
return isSameLocalDay(target, new Date(now))
|
||||
? t('reminder.snoozeToday', { time })
|
||||
: t('reminder.snoozeTomorrow', { time });
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
setNotice(null);
|
||||
setConfirmDeleteId(null);
|
||||
@@ -177,7 +189,9 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{r.title}</p>
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
|
||||
{r.title}
|
||||
</p>
|
||||
{r.emailEnabled && (
|
||||
<span
|
||||
className="shrink-0 text-muted-foreground"
|
||||
@@ -244,8 +258,7 @@ export function ReminderWidget(_props: WidgetProps) {
|
||||
>
|
||||
{preset === '10m' && t('reminder.snooze10m')}
|
||||
{preset === '1h' && t('reminder.snooze1h')}
|
||||
{preset === 'tomorrow' &&
|
||||
t('reminder.snoozeTomorrow', { time: timeOnly.format(new Date(r.dueAt)) })}
|
||||
{preset === 'tomorrow' && tomorrowLabel(r)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,18 @@ vi.mock('next/navigation', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
|
||||
<a href={href} className={className}>{children}</a>
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
}) => (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -100,9 +110,7 @@ const mockActiveModules = [
|
||||
function stubFetch() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }),
|
||||
),
|
||||
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) })),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -316,7 +324,9 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Mein Wiki')).toBeInTheDocument();
|
||||
});
|
||||
const groups = Array.from(
|
||||
screen.getByRole('group', { name: 'Kategorien' }).querySelectorAll(':scope > ul > li > button span.truncate'),
|
||||
screen
|
||||
.getByRole('group', { name: 'Kategorien' })
|
||||
.querySelectorAll(':scope > ul > li > button span.truncate'),
|
||||
).map((n) => n.textContent);
|
||||
expect(groups.at(-1)).toBe('custom-modules');
|
||||
expect(groups).toContain('fleet');
|
||||
@@ -340,12 +350,40 @@ describe('Sidebar', () => {
|
||||
render(<Sidebar />);
|
||||
|
||||
const hrefs = () =>
|
||||
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) => a.getAttribute('href'));
|
||||
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
|
||||
a.getAttribute('href'),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(hrefs()).toContain('/modules/custom/cm-1');
|
||||
});
|
||||
expect(hrefs().at(-1)).toBe('/modules/custom/cm-1');
|
||||
expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(hrefs().indexOf('/modules/custom/cm-1'));
|
||||
expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(
|
||||
hrefs().indexOf('/modules/custom/cm-1'),
|
||||
);
|
||||
});
|
||||
|
||||
it('eingeklappt dieselbe Gruppenfolge wie ausgeklappt (eigenes Modul bei seiner Kategorie)', async () => {
|
||||
mockCustomModules = [
|
||||
customModule({ id: 'cm-1', category: 'Domain-Tools', name: 'Mein Tool' }),
|
||||
customModule({ id: 'cm-2', category: 'custom-modules', name: 'Mein Wiki' }),
|
||||
];
|
||||
mockSidebarCollapsed(true);
|
||||
const Sidebar = await importSidebar();
|
||||
render(<Sidebar />);
|
||||
|
||||
const hrefs = () =>
|
||||
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
|
||||
a.getAttribute('href'),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(hrefs()).toContain('/modules/custom/cm-2');
|
||||
});
|
||||
expect(hrefs()).toEqual([
|
||||
'/modules/Domain-Tools/domaincheck',
|
||||
'/modules/custom/cm-1',
|
||||
'/modules/Utilities/converter',
|
||||
'/modules/custom/cm-2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
|
||||
@@ -360,7 +398,10 @@ describe('Sidebar', () => {
|
||||
.getByText('Domain-Tools')
|
||||
.closest('li')
|
||||
?.querySelectorAll('a span.truncate');
|
||||
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual(['Domaincheck', 'Aaa Eigenes']);
|
||||
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual([
|
||||
'Domaincheck',
|
||||
'Aaa Eigenes',
|
||||
]);
|
||||
});
|
||||
|
||||
it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
|
||||
@@ -379,7 +420,10 @@ describe('Sidebar', () => {
|
||||
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||
screen
|
||||
.getByText('Domaincheck')
|
||||
.closest('a')
|
||||
?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
||||
import { AppVersionBadge } from '@/components/layout/app-version-badge';
|
||||
import { ModuleTile } from '@/components/modules/module-tile';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
import { useNavStore } from '@/lib/stores/nav-store';
|
||||
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
|
||||
import { AppVersionBadge } from '@/components/layout/app-version-badge';
|
||||
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
||||
import { ModuleTile } from '@/components/modules/module-tile';
|
||||
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { useNavStore } from '@/lib/stores/nav-store';
|
||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
|
||||
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
|
||||
function SelectionPill() {
|
||||
@@ -109,13 +109,17 @@ export function Sidebar() {
|
||||
const linkClass = (href: string) => itemClass(isActive(href));
|
||||
|
||||
const isEntryActive = (entry: SidebarEntry) =>
|
||||
isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
|
||||
isActive(entry.href) ||
|
||||
(entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
|
||||
|
||||
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die
|
||||
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
|
||||
// die 20-px-Modulkachel passt weiter hinein.
|
||||
const moduleItemClass = (active: boolean) =>
|
||||
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
|
||||
itemClass(active)
|
||||
.replace('h-9', 'h-8')
|
||||
.replace('gap-3', 'gap-2.5')
|
||||
.replace('text-sm', 'text-[13px]');
|
||||
|
||||
// Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt
|
||||
// 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch
|
||||
@@ -186,28 +190,29 @@ export function Sidebar() {
|
||||
});
|
||||
};
|
||||
|
||||
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — „Eigene Module“
|
||||
// also auch hier immer zuletzt.
|
||||
// Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt,
|
||||
// die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
|
||||
const orderedCategories = useMemo(() => {
|
||||
const categories = new Map<string, SidebarEntry[]>();
|
||||
for (const entry of entries) {
|
||||
const list = categories.get(entry.category);
|
||||
if (list) list.push(entry);
|
||||
else categories.set(entry.category, [entry]);
|
||||
}
|
||||
return Array.from(categories.entries()).sort(
|
||||
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
|
||||
);
|
||||
}, [entries]);
|
||||
|
||||
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — die Eintraege
|
||||
// stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt.
|
||||
const collapsedEntries = useMemo(
|
||||
() =>
|
||||
[...entries].sort(
|
||||
(a, b) =>
|
||||
Number(a.category === CUSTOM_MODULE_CATEGORY) - Number(b.category === CUSTOM_MODULE_CATEGORY),
|
||||
),
|
||||
[entries],
|
||||
() => orderedCategories.flatMap(([, categoryEntries]) => categoryEntries),
|
||||
[orderedCategories],
|
||||
);
|
||||
|
||||
const filteredCategories = useMemo(() => {
|
||||
const categories = new Map<string, SidebarEntry[]>();
|
||||
for (const entry of entries) {
|
||||
if (!categories.has(entry.category)) categories.set(entry.category, []);
|
||||
categories.get(entry.category)!.push(entry);
|
||||
}
|
||||
|
||||
// „Eigene Module“ steht immer zuletzt, die uebrigen in Fundreihenfolge.
|
||||
const ordered = Array.from(categories.entries()).sort(
|
||||
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
|
||||
);
|
||||
const ordered = orderedCategories;
|
||||
if (!searchQuery) return ordered;
|
||||
|
||||
const q = searchQuery.toLowerCase();
|
||||
@@ -221,10 +226,22 @@ export function Sidebar() {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [entries, searchQuery, categoryLabel]);
|
||||
}, [orderedCategories, searchQuery, categoryLabel]);
|
||||
|
||||
const navIcon = (children: React.ReactNode) => (
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width={isCollapsed ? 22 : 20} height={isCollapsed ? 22 : 20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={isCollapsed ? 22 : 20}
|
||||
height={isCollapsed ? 22 : 20}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
@@ -234,7 +251,12 @@ export function Sidebar() {
|
||||
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
|
||||
<ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}>
|
||||
<li>
|
||||
<Link href="/" className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}>
|
||||
<Link
|
||||
href="/"
|
||||
className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')}
|
||||
aria-label={t('dashboard')}
|
||||
title={isCollapsed ? t('dashboard') : undefined}
|
||||
>
|
||||
{isActive('/') && <SelectionPill />}
|
||||
{navIcon(
|
||||
<>
|
||||
@@ -248,7 +270,16 @@ export function Sidebar() {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/marketplace" className={isCollapsed ? collapsedItemClass(isActive('/marketplace')) : linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
|
||||
<Link
|
||||
href="/marketplace"
|
||||
className={
|
||||
isCollapsed
|
||||
? collapsedItemClass(isActive('/marketplace'))
|
||||
: linkClass('/marketplace')
|
||||
}
|
||||
aria-label={t('marketplace')}
|
||||
title={isCollapsed ? t('marketplace') : undefined}
|
||||
>
|
||||
{isActive('/marketplace') && <SelectionPill />}
|
||||
{navIcon(
|
||||
<>
|
||||
@@ -268,9 +299,18 @@ export function Sidebar() {
|
||||
const active = isEntryActive(entry);
|
||||
return (
|
||||
<li key={entry.key}>
|
||||
<Link href={entry.href} className={collapsedItemClass(active)} aria-label={entry.name} title={entry.name}>
|
||||
<Link
|
||||
href={entry.href}
|
||||
className={collapsedItemClass(active)}
|
||||
aria-label={entry.name}
|
||||
title={entry.name}
|
||||
>
|
||||
{active && <SelectionPill />}
|
||||
<ModuleTile slug={entry.tileSlug} size={24} tone={active ? 'accent' : 'neutral'} />
|
||||
<ModuleTile
|
||||
slug={entry.tileSlug}
|
||||
size={24}
|
||||
tone={active ? 'accent' : 'neutral'}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
@@ -290,9 +330,7 @@ export function Sidebar() {
|
||||
{t('categories.empty')}
|
||||
</div>
|
||||
) : filteredCategories.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-sm text-muted-foreground">
|
||||
{t('noResults')}
|
||||
</div>
|
||||
<div className="px-2.5 py-2 text-sm text-muted-foreground">{t('noResults')}</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{filteredCategories.map(([category, categoryEntries]) => {
|
||||
@@ -307,7 +345,19 @@ export function Sidebar() {
|
||||
className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
<span className="truncate">{categoryLabel(category)}</span>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -319,7 +369,11 @@ export function Sidebar() {
|
||||
<li key={entry.key}>
|
||||
<Link href={entry.href} className={moduleItemClass(active)}>
|
||||
{active && <SelectionPill />}
|
||||
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
|
||||
<ModuleTile
|
||||
slug={entry.tileSlug}
|
||||
size={20}
|
||||
tone={active ? 'accent' : 'neutral'}
|
||||
/>
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -353,7 +407,19 @@ export function Sidebar() {
|
||||
aria-label={isCollapsed ? t('expand') : t('collapse')}
|
||||
title={isCollapsed ? t('expand') : undefined}
|
||||
>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
<path d="m16 15-3-3 3-3" />
|
||||
@@ -377,9 +443,7 @@ export function Sidebar() {
|
||||
<aside
|
||||
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block"
|
||||
style={{
|
||||
width: isCollapsed
|
||||
? 'var(--sidebar-width-collapsed)'
|
||||
: 'var(--sidebar-width)',
|
||||
width: isCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)',
|
||||
}}
|
||||
>
|
||||
{sidebarContent}
|
||||
@@ -408,7 +472,18 @@ export function Sidebar() {
|
||||
aria-label={tCommon('close')}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground"
|
||||
>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
||||
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
openInNewTab: 'In neuem Tab öffnen',
|
||||
notFound: 'Dieses Modul gibt es nicht mehr.',
|
||||
loadError: 'Dieses Modul konnte nicht geladen werden.',
|
||||
invalidUrl: 'Ungültige Adresse',
|
||||
loading: 'Wird geladen',
|
||||
};
|
||||
@@ -106,4 +108,19 @@ describe('CustomModuleView', () => {
|
||||
});
|
||||
expect(container.querySelector('iframe')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['500', new CustomModuleRequestError(500, 'Internal server error')],
|
||||
['403', new CustomModuleRequestError(403, 'Forbidden')],
|
||||
['Netzwerkfehler', new TypeError('Failed to fetch')],
|
||||
])('zeigt bei %s „konnte nicht geladen werden“ statt „nicht gefunden“', async (_, err) => {
|
||||
mockGetCustomModule.mockRejectedValue(err);
|
||||
const { container } = render(<CustomModuleView id="abc" />);
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Dieses Modul konnte nicht geladen werden.',
|
||||
);
|
||||
expect(screen.queryByText('Dieses Modul gibt es nicht mehr.')).toBeNull();
|
||||
expect(container.querySelector('iframe')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
||||
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
||||
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
||||
import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
|
||||
|
||||
type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'ready'; mod: CustomModule };
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'missing' }
|
||||
| { status: 'failed' }
|
||||
| { status: 'ready'; mod: CustomModule };
|
||||
|
||||
/**
|
||||
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen
|
||||
@@ -19,6 +23,8 @@ type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'read
|
||||
* Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne
|
||||
* Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit
|
||||
* `javascript:` oder `http:` wird nie gerendert (T-9WC-03).
|
||||
* Nur ein 404 heisst „gibt es nicht mehr“; 403, 500 oder ein Netzwerkfehler
|
||||
* zeigen „konnte nicht geladen werden“ — der Eintrag existiert vielleicht noch.
|
||||
*/
|
||||
export function CustomModuleView({ id }: { id: string }) {
|
||||
const t = useTranslations('customModules');
|
||||
@@ -37,7 +43,7 @@ export function CustomModuleView({ id }: { id: string }) {
|
||||
setState(mod ? { status: 'ready', mod } : { status: 'missing' });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setState({ status: 'missing' });
|
||||
if (!cancelled) setState({ status: 'failed' });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -50,6 +56,13 @@ export function CustomModuleView({ id }: { id: string }) {
|
||||
if (state.status === 'missing') {
|
||||
return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>;
|
||||
}
|
||||
if (state.status === 'failed') {
|
||||
return (
|
||||
<div role="alert" className="p-4 text-sm text-destructive">
|
||||
{t('loadError')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { mod } = state;
|
||||
const urlOk = checkCustomModuleUrl(mod.url) === 'ok';
|
||||
|
||||
@@ -17,12 +17,13 @@ vi.mock('@/lib/reminders-api', async () => {
|
||||
});
|
||||
|
||||
vi.mock('@/lib/reminder-notify', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
|
||||
const actual =
|
||||
await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
|
||||
return { ...actual, showReminderNotification: vi.fn(async () => undefined) };
|
||||
});
|
||||
|
||||
import { showReminderNotification } from '@/lib/reminder-notify';
|
||||
import { ReminderRequestError, listReminders } from '@/lib/reminders-api';
|
||||
import { resetNotifiedMemoryForTests, showReminderNotification } from '@/lib/reminder-notify';
|
||||
import { listReminders, ReminderRequestError } from '@/lib/reminders-api';
|
||||
import { ReminderNotifier } from './reminder-notifier';
|
||||
|
||||
const mockList = listReminders as ReturnType<typeof vi.fn>;
|
||||
@@ -53,6 +54,7 @@ beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
window.localStorage.clear();
|
||||
resetNotifiedMemoryForTests();
|
||||
mockList.mockReset();
|
||||
mockShow.mockClear();
|
||||
});
|
||||
@@ -77,7 +79,9 @@ describe('ReminderNotifier', () => {
|
||||
});
|
||||
|
||||
it('meldet nichts, solange nichts faellig ist', async () => {
|
||||
mockList.mockResolvedValue([reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() })]);
|
||||
mockList.mockResolvedValue([
|
||||
reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() }),
|
||||
]);
|
||||
render(<ReminderNotifier />);
|
||||
await advance(30_000);
|
||||
expect(mockShow).not.toHaveBeenCalled();
|
||||
@@ -85,7 +89,10 @@ describe('ReminderNotifier', () => {
|
||||
|
||||
it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => {
|
||||
mockList.mockResolvedValue([
|
||||
reminder({ dueAt: new Date(NOW.getTime() - 1000).toISOString(), description: 'x'.repeat(300) }),
|
||||
reminder({
|
||||
dueAt: new Date(NOW.getTime() - 1000).toISOString(),
|
||||
description: 'x'.repeat(300),
|
||||
}),
|
||||
]);
|
||||
render(<ReminderNotifier />);
|
||||
await advance(0);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isTauriWebview,
|
||||
remindersToNotify,
|
||||
requestBrowserPermissionOnce,
|
||||
resetNotifiedMemoryForTests,
|
||||
showReminderNotification,
|
||||
withNotifyLock,
|
||||
} from './reminder-notify';
|
||||
@@ -34,6 +35,7 @@ function installNotification(permission: NotificationPermission) {
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
resetNotifiedMemoryForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -65,6 +67,28 @@ describe('claimNotification', () => {
|
||||
const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}');
|
||||
expect(Object.keys(stored)).toEqual(['neu']);
|
||||
});
|
||||
|
||||
it('ohne nutzbaren localStorage (wirft beim Lesen und Schreiben) meldet es trotzdem nur einmal', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('SecurityError');
|
||||
});
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('QuotaExceededError');
|
||||
});
|
||||
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
|
||||
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 10_000)).toBe(false);
|
||||
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 3600_000)).toBe(false);
|
||||
// ein neuer Schluessel (verschoben) wird weiterhin gemeldet
|
||||
expect(claimNotification('r1|2026-09-29T12:10:00.000Z', NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('ohne window.localStorage (Zugriff wirft) meldet es trotzdem nur einmal', () => {
|
||||
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
|
||||
throw new Error('SecurityError');
|
||||
});
|
||||
expect(claimNotification('k', NOW)).toBe(true);
|
||||
expect(claimNotification('k', NOW + 10_000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remindersToNotify', () => {
|
||||
@@ -93,7 +117,9 @@ describe('showReminderNotification', () => {
|
||||
const invoke = installTauri();
|
||||
invoke.mockRejectedValue(new Error('nope'));
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
await expect(showReminderNotification({ title: 'T', body: 'B', tag: 'x' })).resolves.toBeUndefined();
|
||||
await expect(
|
||||
showReminderNotification({ title: 'T', body: 'B', tag: 'x' }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -30,12 +30,7 @@ export const CATCH_UP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
/** Eintraege der lokalen Merkliste, die aelter sind, werden entfernt. */
|
||||
const NOTIFIED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export type BrowserPermissionState =
|
||||
| 'desktop'
|
||||
| 'granted'
|
||||
| 'default'
|
||||
| 'denied'
|
||||
| 'unsupported';
|
||||
export type BrowserPermissionState = 'desktop' | 'granted' | 'default' | 'denied' | 'unsupported';
|
||||
|
||||
type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
@@ -135,6 +130,19 @@ function readNotified(): Record<string, number> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merkliste im Arbeitsspeicher dieses Tabs. Sie greift IMMER, auch wenn der
|
||||
* localStorage gesperrt, voll oder im privaten Modus nicht nutzbar ist — sonst
|
||||
* meldete der Melder dieselbe faellige Erinnerung 24 Stunden lang bei jeder
|
||||
* Pruefung erneut. Der localStorage bleibt fuer die Abstimmung zwischen Tabs.
|
||||
*/
|
||||
const notifiedInMemory = new Map<string, number>();
|
||||
|
||||
/** Nur fuer Tests: leert die Merkliste im Arbeitsspeicher. */
|
||||
export function resetNotifiedMemoryForTests(): void {
|
||||
notifiedInMemory.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merkt sich `key` (`${id}|${dueAt}`) lokal. `true` nur beim ERSTEN Mal — so
|
||||
* meldet jeder Client jede Faelligkeit hoechstens einmal, auch ueber mehrere
|
||||
@@ -143,16 +151,22 @@ function readNotified(): Record<string, number> {
|
||||
* aelter als 7 Tage werden dabei entfernt.
|
||||
*/
|
||||
export function claimNotification(key: string, nowMs: number): boolean {
|
||||
for (const [k, at] of notifiedInMemory) {
|
||||
if (nowMs - at > NOTIFIED_TTL_MS) notifiedInMemory.delete(k);
|
||||
}
|
||||
const record = readNotified();
|
||||
for (const [k, at] of Object.entries(record)) {
|
||||
if (nowMs - at > NOTIFIED_TTL_MS) delete record[k];
|
||||
}
|
||||
const first = !(key in record);
|
||||
if (first) record[key] = nowMs;
|
||||
const first = !(key in record) && !notifiedInMemory.has(key);
|
||||
if (first) {
|
||||
record[key] = nowMs;
|
||||
notifiedInMemory.set(key, nowMs);
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(NOTIFIED_KEY, JSON.stringify(record));
|
||||
} catch {
|
||||
/* Speicher voll oder gesperrt: dann eben ohne Merkliste */
|
||||
/* Speicher voll oder gesperrt: dann greift nur die Merkliste im Arbeitsspeicher */
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
defaultNewReminderInputs,
|
||||
isoToLocalInputs,
|
||||
isSameLocalDay,
|
||||
localInputsToIso,
|
||||
snoozeTarget,
|
||||
} from './reminder-time';
|
||||
@@ -50,7 +51,9 @@ describe('snoozeTarget (E-05)', () => {
|
||||
});
|
||||
|
||||
it('morgen = urspruengliche Ortszeit am naechsten Tag', () => {
|
||||
expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(local(2026, 9, 30, 14, 0).getTime());
|
||||
expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(
|
||||
local(2026, 9, 30, 14, 0).getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it('morgen ueber das Monatsende', () => {
|
||||
@@ -67,3 +70,24 @@ describe('snoozeTarget (E-05)', () => {
|
||||
expect(target.getTime()).toBe(local(2026, 9, 30, 8, 15).getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('snoozeTarget kurz nach Mitternacht / isSameLocalDay', () => {
|
||||
it('faellig gestern 23:50, verschoben heute 00:10 -> heute 23:50 (heute, nicht morgen)', () => {
|
||||
const n = local(2026, 9, 30, 0, 10);
|
||||
const target = snoozeTarget('tomorrow', local(2026, 9, 29, 23, 50), n);
|
||||
expect(target.getTime()).toBe(local(2026, 9, 30, 23, 50).getTime());
|
||||
expect(isSameLocalDay(target, n)).toBe(true);
|
||||
});
|
||||
|
||||
it('der Normalfall liegt am naechsten Tag', () => {
|
||||
const n = local(2026, 9, 29, 14, 5);
|
||||
const target = snoozeTarget('tomorrow', local(2026, 9, 29, 14, 0), n);
|
||||
expect(isSameLocalDay(target, n)).toBe(false);
|
||||
});
|
||||
|
||||
it('isSameLocalDay vergleicht den Kalendertag in Ortszeit', () => {
|
||||
expect(isSameLocalDay(local(2026, 9, 30, 0, 0), local(2026, 9, 30, 23, 59))).toBe(true);
|
||||
expect(isSameLocalDay(local(2026, 9, 30, 23, 59), local(2026, 10, 1, 0, 0))).toBe(false);
|
||||
expect(isSameLocalDay(local(2026, 9, 30), local(2025, 9, 30))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,10 @@ export type SnoozePreset = '10m' | '1h' | 'tomorrow';
|
||||
* Ortszeit und geht einen Kalendertag weiter (`setDate(+1)` ist sommerzeit-
|
||||
* sicher), so oft, bis das Ergebnis in der Zukunft liegt: faellig heute 14:00,
|
||||
* verschoben um 14:05 -> morgen 14:00; war die Erinnerung schon Tage ueberfaellig,
|
||||
* landet sie beim naechsten kuenftigen Vorkommen dieser Uhrzeit.
|
||||
* landet sie beim naechsten kuenftigen Vorkommen dieser Uhrzeit. Das kann auch
|
||||
* HEUTE sein (faellig gestern 23:50, verschoben heute 00:10 -> heute 23:50) —
|
||||
* die Beschriftung leitet „heute“/„morgen“ deshalb aus dem Ergebnis ab
|
||||
* (`isSameLocalDay`), nicht aus dem Namen der Option.
|
||||
*/
|
||||
export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Date): Date {
|
||||
if (preset === '10m') return new Date(now.getTime() + 10 * 60_000);
|
||||
@@ -56,3 +59,12 @@ export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Dat
|
||||
} while (d.getTime() <= now.getTime());
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Liegen beide Zeitpunkte am selben Kalendertag (Ortszeit)? */
|
||||
export function isSameLocalDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user